diff --git a/Cargo.lock b/Cargo.lock index 6c4a1a7a0..2c33d22d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "ast-grep-core", @@ -2399,7 +2399,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "15.7.6" +version = "15.8.0" dependencies = [ "async-trait", "libc", @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "arboard", @@ -2457,7 +2457,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "15.7.6" +version = "15.8.0" dependencies = [ "anyhow", "brush-builtins", diff --git a/Cargo.toml b/Cargo.toml index d2f80d436..f6ccba834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "15.7.6" +version = "15.8.0" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index f75e8c84b..deefdc60e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -30,9 +30,8 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { - "@anthropic-ai/sdk": "catalog:", "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", @@ -45,7 +44,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp": "src/cli.ts", }, @@ -85,7 +84,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -96,7 +95,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "15.7.6", + "version": "15.8.0", "bin": { "mnemopi": "src/cli.ts", }, @@ -113,7 +112,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "15.7.6", + "version": "15.8.0", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -121,7 +120,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp-stats": "./src/index.ts", }, @@ -146,7 +145,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "15.7.6", + "version": "15.8.0", "bin": { "omp-swarm": "src/cli.ts", }, @@ -162,7 +161,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -203,7 +202,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "15.7.6", + "version": "15.8.0", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "beautiful-mermaid": "catalog:", @@ -233,7 +232,6 @@ }, "catalog": { "@agentclientprotocol/sdk": "0.22.1", - "@anthropic-ai/sdk": "^0.99.0", "@babel/generator": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", @@ -244,15 +242,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.7.6", - "@oh-my-pi/omp-stats": "15.7.6", - "@oh-my-pi/pi-agent-core": "15.7.6", - "@oh-my-pi/pi-ai": "15.7.6", - "@oh-my-pi/pi-coding-agent": "15.7.6", - "@oh-my-pi/pi-mnemopi": "15.7.6", - "@oh-my-pi/pi-natives": "15.7.6", - "@oh-my-pi/pi-tui": "15.7.6", - "@oh-my-pi/pi-utils": "15.7.6", + "@oh-my-pi/hashline": "15.8.0", + "@oh-my-pi/omp-stats": "15.8.0", + "@oh-my-pi/pi-agent-core": "15.8.0", + "@oh-my-pi/pi-ai": "15.8.0", + "@oh-my-pi/pi-coding-agent": "15.8.0", + "@oh-my-pi/pi-mnemopi": "15.8.0", + "@oh-my-pi/pi-natives": "15.8.0", + "@oh-my-pi/pi-tui": "15.8.0", + "@oh-my-pi/pi-utils": "15.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", @@ -305,8 +303,6 @@ "packages": { "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.22.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.99.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-vdicFA9YjtvgpG8rxp39hqW4oxpkdRGPTq0QEts5TZhr2GkLozDduK/GiXrLQ7PzrKYBtIkQFdRW+QBjzlsABg=="], - "@anush008/tokenizers": ["@anush008/tokenizers@0.0.0", "", { "optionalDependencies": { "@anush008/tokenizers-darwin-universal": "0.0.0", "@anush008/tokenizers-linux-x64-gnu": "0.0.0", "@anush008/tokenizers-win32-x64-msvc": "0.0.0" } }, "sha512-IQD9wkVReKAhsEAbDjh/0KrBGTEXelqZLpOBRDaIRvlzZ9sjmUP+gKbpvzyJnei2JHQiE8JAgj7YcNloINbGBw=="], "@anush008/tokenizers-darwin-universal": ["@anush008/tokenizers-darwin-universal@0.0.0", "", { "os": "darwin" }, "sha512-SACpWEooTjFX89dFKRVUhivMxxcZRtA3nJGVepdLyrwTkQ1TZQ8581B5JoXp0TcTMHfgnDaagifvVoBiFEdNCQ=="], @@ -345,8 +341,6 @@ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], @@ -449,37 +443,37 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inquirer/ansi": ["@inquirer/ansi@2.0.6", "", {}, "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "@inquirer/checkbox": ["@inquirer/checkbox@5.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1HJt+3fqxblp/GQjdntSyoSHYBc0e3CzXVgjFpKA6qFLd9FHBBqwN8Co0xYH6t2JVUZrtFwZ4bBiwptkiLxyOg=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], - "@inquirer/confirm": ["@inquirer/confirm@6.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA=="], + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], - "@inquirer/core": ["@inquirer/core@11.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^4.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g=="], + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], "@inquirer/editor": ["@inquirer/editor@5.2.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/external-editor": "^3.0.1", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/m+sgRmzSdK6HDtVnl3PmI6MnZC4O+LLezedoJcrX7mINhTjjb0hlC7aEDGZXkFTB4b5uQ0q59AhYTah88KbNg=="], - "@inquirer/expand": ["@inquirer/expand@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fR7g4BVnIcs+4NApF6C5byflNM/EULxSxsv/2Jvg+gmop0R6eBIPvZqE6RYnTy1tQTFnf9wyHkwNoQSZbofaGA=="], + "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], "@inquirer/external-editor": ["@inquirer/external-editor@3.0.1", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tam+Gwjsxg2sx3iUVPkAnhKT/yrk2rd2NAa7XJU/J8OYpU0ifXsnp12xlvzp/DCpWBXVv+vLQsqnpAWwUcWD5Q=="], - "@inquirer/figures": ["@inquirer/figures@2.0.6", "", {}, "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw=="], + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], "@inquirer/input": ["@inquirer/input@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-sVZCz6P6e8tW5g2bSFel1oLpa6jK/u7BexFfrgTqR8syIdnHqy+iopnlSbYBZMsCK52chLjhGNBxt0eRqhsghw=="], - "@inquirer/number": ["@inquirer/number@4.1.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VMXB/XejCbaSTf9Xucl7dqjzzsaGsrs6XwSYXPbGZ2QbSuq/Gz8XamhSi9ClRubNXZlGry9xVg1tKkJdTDgCtQ=="], + "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], - "@inquirer/password": ["@inquirer/password@5.1.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5tqRuKCDIUxdPxTI/CuLnh914kz+WMPmURHKnZgui9gk43ebudEsdu4EwSn1CPSi5R+17YpBG+ba/YqTnRAcJA=="], + "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], "@inquirer/prompts": ["@inquirer/prompts@8.5.0", "", { "dependencies": { "@inquirer/checkbox": "^5.2.0", "@inquirer/confirm": "^6.1.0", "@inquirer/editor": "^5.2.0", "@inquirer/expand": "^5.1.0", "@inquirer/input": "^5.1.0", "@inquirer/number": "^4.1.0", "@inquirer/password": "^5.1.0", "@inquirer/rawlist": "^5.3.0", "@inquirer/search": "^4.2.0", "@inquirer/select": "^5.2.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-pLjXOnY4y3R1mgyHP3pXD/8eXejp+L/dde/0N2NLKgKfMstqhNZrpvs7Wkzbl9FYFQh10LRQ7QZwq+cz9rrhyw=="], - "@inquirer/rawlist": ["@inquirer/rawlist@5.3.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p+vAeTAD+cGXjGleP1F5LXrX2ISxNDZm+lqeBpnJausNLSZskZZkcggwhomqP8Igx9oIjnoeOrw98xvdFvdm2w=="], + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], - "@inquirer/search": ["@inquirer/search@4.2.0", "", { "dependencies": { "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ByURoSGIaSl5O5Q0AmYmVmUsXbMUcBGNoA3FRL7TOyiA22IeFHymJKRkuILbOIlJwqnBk7AnPpseodyFUBzg+g=="], + "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], - "@inquirer/select": ["@inquirer/select@5.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6IzkcmEbEXfgVbxZ2d1UyJFbCBoc6dTofulFmrYuomIp88HXiVqRbqbg4/mbfZhvnNo6xYmnYo2AEmDof6fQkg=="], + "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], - "@inquirer/type": ["@inquirer/type@4.0.6", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g=="], + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -721,8 +715,6 @@ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], @@ -883,7 +875,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -943,8 +935,6 @@ "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1027,8 +1017,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], @@ -1119,7 +1107,7 @@ "music-metadata": ["music-metadata@11.12.3", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ=="], - "mute-stream": ["mute-stream@4.0.0", "", {}, "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -1241,13 +1229,11 @@ "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1271,8 +1257,6 @@ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], - "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], @@ -1403,6 +1387,8 @@ "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -1417,6 +1403,8 @@ "fastembed/onnxruntime-node/tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index c2a66ac52..f62b20155 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -68,5 +68,5 @@ use napi_derive::napi; /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV15_7_6")] +#[napi(js_name = "__piNativesV15_8_0")] pub const fn pi_natives_version_sentinel() {} diff --git a/docs/tools/find.md b/docs/tools/find.md index b54a60a69..823365047 100644 --- a/docs/tools/find.md +++ b/docs/tools/find.md @@ -18,7 +18,7 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `paths` | `string[]` | Yes | One or more globs, files, directories, or internal URLs with backing files. Empty strings and comma-joined multi-path entries such as `["a,b"]` are rejected. Multiple entries may be merged into one brace-union search when their base paths can be resolved together. | +| `paths` | `string[]` | Yes | One or more globs, files, directories, or internal URLs with backing files. Empty strings are rejected. Single entries accidentally joined with comma, semicolon, or whitespace are expanded only after existence validation; existing paths containing delimiters stay intact. Multiple entries may be merged into one brace-union search when their base paths can be resolved together. | | `hidden` | `boolean` | No | Whether hidden files are included. Defaults to `true` (`hidden ?? true`). | | `gitignore` | `boolean` | No | Whether `.gitignore` is respected during local native globbing. Defaults to `true`; set `false` to include gitignored files. | | `limit` | `number` | No | Max returned paths. Defaults to `200`; finite positive inputs are floored then clamped to `1..200`. | @@ -41,22 +41,24 @@ The tool returns a single text block plus structured `details`. - Streaming: when the runtime supplies `onUpdate`, the local implementation emits incremental newline-delimited text snapshots during globbing, throttled to 200 ms. Final output is grouped; streaming snapshots are not. ## Flow -1. `FindTool.execute()` normalizes each `paths` entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/find.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``. -2. For multi-path local calls, `partitionExistingPaths(..., parseFindPattern)` (`packages/coding-agent/src/tools/path-utils.ts`) stats each base path. Missing entries are skipped; if all are missing, the tool throws `Path not found: ...`. Single missing paths still hard-fail. -3. The tool tries `resolveExplicitFindPatterns()` to merge multiple inputs into one search rooted at a common base path. If that does not apply, it parses one input with `parseFindPattern()`. -4. `parseFindPattern()` determines `(basePath, globPattern, hasGlob)`: + +1. `FindTool.execute()` expands delimiter-flattened local `paths` entries with `expandDelimitedPathEntries(..., parseFindPattern)` unless custom operations are injected. The splitter validates candidate parts by statting their parsed base paths, keeps existing delimiter-containing paths intact, accepts comma/semicolon splits when at least one part resolves, and accepts whitespace splits only when every part resolves. +2. The tool normalizes each resulting entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/find.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``. +3. For multi-path local calls, `partitionExistingPaths(..., parseFindPattern)` (`packages/coding-agent/src/tools/path-utils.ts`) stats each base path. Missing entries are skipped; if all are missing, the tool throws `Path not found: ...`. Single missing paths still hard-fail. +4. The tool tries `resolveExplicitFindPatterns()` to merge multiple inputs into one search rooted at a common base path. If that does not apply, it parses one input with `parseFindPattern()`. +5. `parseFindPattern()` determines `(basePath, globPattern, hasGlob)`: - no glob chars (`*`, `?`, `[`, `{`) => search that path with implicit `**/*`. - glob in the first segment => search from `.` and, unless the pattern already starts with `**/`, prefix it with `**/`. - glob later in the path => split at the first glob-bearing segment. -5. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`. -6. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`. -7. Execution then branches: +6. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`. +7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`. +8. Execution then branches: - **Custom operations branch**: if `FindToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`. - **Built-in local branch**: the tool stats `searchPath`. Exact-file inputs return immediately. Directory inputs call `natives.glob()` with `hidden`, `maxResults: effectiveLimit`, `sortByMtime: true`, `gitignore: useGitignore`, and the combined abort signal. -8. In the local branch, optional `onMatch` callbacks convert each match to a cwd-relative display path and emit throttled progress updates. -9. After native glob returns, JS sorts `result.matches` by `mtime` descending (`(b.mtime ?? 0) - (a.mtime ?? 0)`) before formatting paths. -10. `buildResult()` applies `applyListLimit()` to cap the array again at `effectiveLimit`, formats paths with `formatFindGroupedOutput()`, appends notices, then runs `truncateHead()` with `maxLines: Number.MAX_SAFE_INTEGER`. In practice this leaves the 50 KB byte cap in place while disabling the default 3000-line cap. -11. `toolResult()` packages text plus `details`, and records result-limit / truncation metadata for renderers. +9. In the local branch, optional `onMatch` callbacks convert each match to a cwd-relative display path and emit throttled progress updates. +10. After native glob returns, JS sorts `result.matches` by `mtime` descending (`(b.mtime ?? 0) - (a.mtime ?? 0)`) before formatting paths. +11. `buildResult()` applies `applyListLimit()` to cap the array again at `effectiveLimit`, formats paths with `formatFindGroupedOutput()`, appends notices, then runs `truncateHead()` with `maxLines: Number.MAX_SAFE_INTEGER`. In practice this leaves the 50 KB byte cap in place while disabling the default 3000-line cap. +12. `toolResult()` packages text plus `details`, and records result-limit / truncation metadata for renderers. ## Modes / Variants - **Exact file path**: if the parsed input has no glob and the resolved path stats as a file, output is that one path. @@ -90,7 +92,6 @@ The tool returns a single text block plus structured `details`. ## Errors - User-facing `ToolError`s from `FindTool.execute()` include: - - `paths is an array — pass ["a", "b"] not ["a,b"] ...` - `` `paths` must contain non-empty globs or paths `` - `Path not found: ...` - `Searching from root directory '/' is not allowed` diff --git a/docs/tools/search.md b/docs/tools/search.md index e63894765..46afb3b53 100644 --- a/docs/tools/search.md +++ b/docs/tools/search.md @@ -21,7 +21,7 @@ | Field | Type | Required | Description | | --- | --- | --- | --- | | `pattern` | `string` | Yes | Regex pattern. `search.ts` trims it and rejects empty input. The native matcher enables multiline only when the pattern text contains a literal newline or the two-character sequence `\\n`. The model prompt explicitly documents literal-brace escaping such as ``interface\\{\\}``, although the native layer also auto-escapes braces that cannot be valid repetition quantifiers. | -| `paths` | `string \| string[]` | Yes | One file path, directory path, glob-like path, archive member, internal URL, or an array of those. Append a line-range selector such as `:50-100` or `:5-16,960-973` to a single file/archive/internal-resource input to constrain matches. Empty strings and comma-joined multi-path entries are rejected after trimming/quote stripping. Filesystem-backed internal URLs search their backing file; virtual internal resources search resolved text in memory. Internal URLs cannot contain glob characters. | +| `paths` | `string \| string[]` | Yes | One file path, directory path, glob-like path, archive member, internal URL, or an array of those. Append a line-range selector such as `:50-100` or `:5-16,960-973` to a single file/archive/internal-resource input to constrain matches. Empty strings are rejected after trimming/quote stripping. Single entries accidentally joined with comma, semicolon, or whitespace are expanded only after existence validation; existing paths containing delimiters stay intact. Filesystem-backed internal URLs search their backing file; virtual internal resources search resolved text in memory. Internal URLs cannot contain glob characters. | | `i` | `boolean` | No | Case-insensitive search. Defaults to `false`. Passed to native `ignoreCase` or JS `RegExp` flags for virtual resources. | | `gitignore` | `boolean` | No | Respect `.gitignore` during directory scans. Defaults to `true`. Passed to native `gitignore`. | | `skip` | `number` | No | File-page offset for multi-file results. Defaults to `0`; `search.ts` floors finite numbers and rejects negative or non-finite values. Single-file searches ignore it because they do not paginate by file. | @@ -48,10 +48,11 @@ The tool returns a single text block in `content[0].text` plus structured `detai 1. `SearchTool.execute()` validates and normalizes input in `packages/coding-agent/src/tools/search.ts`: - trims `pattern`, rejects empty patterns; - normalizes `skip` to a non-negative integer; + - expands delimiter-flattened `paths` entries with `expandDelimitedPathEntries()`, keeping existing delimiter-containing paths intact, accepting comma/semicolon splits when at least one part resolves, and accepting whitespace splits only when every part resolves; + - peels any line-range selector from each resulting entry; - reads `search.contextBefore` and `search.contextAfter` from session settings (`1` and `3` by default); - - enables multiline only when `pattern` contains `\n` or an actual newline; - - wraps a single string `paths` value into a one-element list and peels any line-range selector from each entry. -2. Each `paths` entry is normalized with `normalizePathLikeInput()`. + - enables multiline only when `pattern` contains `\n` or an actual newline. +2. Each `paths` entry is normalized with `normalizePathLikeInput()` again during shared scope resolution; this is a no-op for entries already normalized by delimiter expansion. 3. Archive member paths such as `bundle.zip:src/foo.ts` are materialized to temporary UTF-8 scratch files before native grep. Binary or non-UTF-8 archive members are reported as skipped/unreadable. 4. Internal URLs are resolved before filesystem scope resolution: - glob metacharacters (`*`, `?`, `[`, `{`) are rejected for internal URLs; @@ -139,7 +140,6 @@ The tool returns a single text block in `content[0].text` plus structured `detai - `Pattern must not be empty` when trimmed `pattern` is empty. - `Skip must be a non-negative number` for negative or non-finite `skip`. - `` `paths` must contain non-empty paths or globs `` when any normalized path is empty. -- `paths is an array — pass ["a", "b"] not ["a,b"] ...` for comma-joined multi-path entries outside brace expansion. - `Glob patterns are not supported for internal URLs: ...` for internal URL + glob metacharacters. - Line-range selector errors include `Line-range selector requires a single file, not a glob: ...`, `Line-range selector requires a single file: ... is a directory`, and `Path not found for line-range selector: ...`. - `Cannot search archive member(s): ...` when all archive selectors are unreadable, binary, or non-UTF-8. diff --git a/package.json b/package.json index 709948641..397502658 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ ], "catalog": { "@agentclientprotocol/sdk": "0.22.1", - "@anthropic-ai/sdk": "^0.99.0", "@babel/generator": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", @@ -21,15 +20,15 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "15.7.6", - "@oh-my-pi/omp-stats": "15.7.6", - "@oh-my-pi/pi-agent-core": "15.7.6", - "@oh-my-pi/pi-ai": "15.7.6", - "@oh-my-pi/pi-coding-agent": "15.7.6", - "@oh-my-pi/pi-mnemopi": "15.7.6", - "@oh-my-pi/pi-natives": "15.7.6", - "@oh-my-pi/pi-tui": "15.7.6", - "@oh-my-pi/pi-utils": "15.7.6", + "@oh-my-pi/hashline": "15.8.0", + "@oh-my-pi/omp-stats": "15.8.0", + "@oh-my-pi/pi-agent-core": "15.8.0", + "@oh-my-pi/pi-ai": "15.8.0", + "@oh-my-pi/pi-coding-agent": "15.8.0", + "@oh-my-pi/pi-mnemopi": "15.8.0", + "@oh-my-pi/pi-natives": "15.8.0", + "@oh-my-pi/pi-tui": "15.8.0", + "@oh-my-pi/pi-utils": "15.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3a7b8f2c9..02beb32d8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + ### Fixed - Engaged GPT-5 Harmony leak detection on the committed assistant message (openai-codex only). `detectHarmonyLeakInAssistantMessage` now runs on the streamed `done`/`error` result and the trailing fallback, so a leaked final response is aborted-and-retried by the existing mitigation instead of being committed as-is. Tool-argument (`tool_arg`) scanning is gated on the trailing-garbage `T` co-signal and only fires when a caller supplies a parse boundary via `detectHarmonyLeakInAssistantMessage`'s new optional `toolArgParseEnd` resolver. The agent loop passes none — it cannot bound a streamed tool DSL — so that surface stays inert and a legitimate codex tool call whose content legitimately carries `to=functions.*` next to a channel word or non-Latin script (e.g. editing the harmony fixtures) is never hard-aborted. diff --git a/packages/agent/package.json b/packages/agent/package.json index cfa97c22c..841dc7921 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "15.7.6", + "version": "15.8.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fc3bbf7a7..d25584946 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,16 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 +### Added + +- Added `AnthropicMessagesClient` and related Anthropic wire types/errors via `anthropic-client` export so callers can build a standalone Anthropic Messages client without depending on `@anthropic-ai/sdk` +- Added `parseClaudeRateLimitHeaders` and `AuthStorage.ingestUsageHeaders` so Anthropic rate-limit response headers can warm the per-credential usage cache with throttling while preserving per-tier data from the last full usage report. + ### Changed +- Changed Anthropic request handling to use the package-local `AnthropicMessagesClient` implementation instead of `@anthropic-ai/sdk` as the default transport +- Updated the `AnthropicOptions.client` surface to accept any `AnthropicMessagesClientLike` implementation with `messages.create`, enabling custom compatible clients - Changed generated OAuth metadata `user_id` to use a deterministic `device_id` derived from the install ID instead of a random value - `claudeCodeVersion` bumped to `2.1.148` to match current Claude Code release. - `X-Stainless-Package-Version` updated to `0.94.0` (matches the bundled `@anthropic-ai/sdk` version); `X-Stainless-Runtime-Version` pinned to `v24.3.0` (Bun version bundled with CC 2.1.148); `X-Stainless-Os` header key corrected to `X-Stainless-OS`. @@ -19,10 +27,19 @@ ### Fixed +- Fixed tool argument validation to wrap a plain string in a singleton array when the schema requires an array, allowing tool-level path/list normalization to recover from bare string arguments. +- Restored `eager_input_streaming` and strict flags on OAuth Anthropic tool definitions when model compatibility allows eager streaming. +- Fixed OAuth stream calls with injected custom clients missing a `beta` client by falling back to `client.messages.create` instead of requiring `client.beta.messages.create` +- Fixed direct use of internal API client typing so retry/timeouts and malformed-error classification remain compatible while not requiring the external SDK - Fixed Cursor provider requests failing with `Cannot send empty user message to Cursor API` after tool-result history by selecting the latest user/developer turn instead of assuming the final context message is the active user turn. - Fixed Anthropic web search dropping `ANTHROPIC_CUSTOM_HEADERS` when `CLAUDE_CODE_USE_FOUNDRY` was unset, causing 401s from corporate API gateways. `resolveAnthropicCustomHeadersForBaseUrl` now forwards the parsed headers whenever the base URL is non-Anthropic (or Foundry is enabled), and `buildAnthropicSearchHeaders` threads them through `buildAnthropicHeaders` so the search and streaming paths behave identically ([#1693](https://github.com/can1357/oh-my-pi/issues/1693)). - Fixed OpenCode Go Anthropic-format models such as `qwen3.7-max` sending Anthropic `X-Api-Key` auth alongside the OpenCode bearer token, avoiding spurious Alibaba `401 Invalid API-key provided` errors. ([#1661](https://github.com/can1357/oh-my-pi/issues/1661)) - Fixed OAuth token exchange and refresh flows to fetch Claude CLI bootstrap identity when token responses omit account information, so `accountId` and `email` are now recovered when available +- Fixed Anthropic thinking traces being lost on direct OAuth requests. OAuth requests no longer send `redact-thinking-2026-02-12` unless thinking is explicitly hidden, Opus 4.7+ adaptive thinking opts into `display: "summarized"`, and the top user-facing thinking tier now sends Anthropic's `output_config.effort = "max"` rather than the next-lower `"xhigh"` tier. + +### Removed + +- Removed the `@anthropic-ai/sdk` runtime dependency. The Anthropic provider now uses the package-local `AnthropicMessagesClient` and hand-maintained wire types in `providers/anthropic-wire.ts`; the SDK was only ever used for URL assembly, auth-header injection, bounded retries, the pre-response timeout, and HTTP-error-to-status mapping, all of which are reproduced with identical observable behavior. ## [15.7.5] - 2026-06-01 @@ -2894,4 +2911,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/package.json b/packages/ai/package.json index 824cf269b..ea3f49a18 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "15.7.6", + "version": "15.8.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", @@ -38,7 +38,6 @@ "generate-models": "bun scripts/generate-models.ts" }, "dependencies": { - "@anthropic-ai/sdk": "catalog:", "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 65549e02f..ed27cdb2a 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -460,6 +460,7 @@ const USAGE_CACHE_PREFIX = "usage_cache:"; // each credential's last-known value sticks visible while peers retry. UI // data (5h / 7d / monthly limits) is fine being a few minutes stale. const USAGE_REPORT_TTL_MS = 5 * 60_000; +const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000; const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000; /** * Per-credential cool-down after a usage fetch fails. While this window is @@ -750,6 +751,7 @@ export class AuthStorage { #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; #usageCache: UsageCache; #usageRequestInFlight: Map> = new Map(); + #usageHeaderIngestAt: Map = new Map(); #usageReportsInFlight: Map> = new Map(); #usageFetch: typeof fetch; #usageRequestTimeoutMs: number; @@ -1393,14 +1395,7 @@ export class AuthStorage { ); } - /** - * Get the OAuth `accountId` for a provider, preferring the credential that is - * session-sticky for `sessionId` when multiple OAuth credentials are configured. - * Falls back to the first OAuth credential when no session preference exists (e.g. - * first call before any `getApiKey` has been issued, or single-credential setups). - * Returns `undefined` when no OAuth credential carries an `accountId`. - */ - getOAuthAccountId(provider: string, sessionId?: string): string | undefined { + #resolveActiveOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined { const allCredentials = this.#getCredentialsForProvider(provider); const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth"); if (oauthCredentials.length === 0) return undefined; @@ -1427,7 +1422,18 @@ export class AuthStorage { // filtered array would be off-by-N when any non-OAuth credential precedes the // OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order). const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined; - const preferred = stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0]; + return stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0]; + } + + /** + * Get the OAuth `accountId` for a provider, preferring the credential that is + * session-sticky for `sessionId` when multiple OAuth credentials are configured. + * Falls back to the first OAuth credential when no session preference exists (e.g. + * first call before any `getApiKey` has been issued, or single-credential setups). + * Returns `undefined` when no OAuth credential carries an `accountId`. + */ + getOAuthAccountId(provider: string, sessionId?: string): string | undefined { + const preferred = this.#resolveActiveOAuthCredential(provider, sessionId); const accountId = preferred?.accountId; return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined; } @@ -2115,6 +2121,59 @@ export class AuthStorage { return promise; } + ingestUsageHeaders( + provider: Provider, + headers: Record, + options?: { sessionId?: string; baseUrl?: string }, + ): boolean { + if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false; + + const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId); + if (!credential) return false; + + const cacheKey = this.#buildUsageReportCacheKey( + this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl), + ); + const now = Date.now(); + const last = this.#usageHeaderIngestAt.get(cacheKey); + if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false; + + const report = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now); + if (!report) return false; + + const prior = this.#usageCache.getStale(cacheKey)?.value; + let merged = report; + if (prior && Array.isArray(prior.limits)) { + const headerLimitsById = new Map(report.limits.map(limit => [limit.id, limit])); + const limits: UsageLimit[] = []; + for (const limit of prior.limits) { + const replacement = headerLimitsById.get(limit.id); + if (replacement) { + limits.push(replacement); + headerLimitsById.delete(limit.id); + } else { + limits.push(limit); + } + } + for (const limit of headerLimitsById.values()) { + limits.push(limit); + } + merged = { + ...prior, + fetchedAt: now, + limits, + metadata: { + ...(prior.metadata ?? {}), + headersUpdatedAt: now, + }, + }; + } + + this.#usageCache.set(cacheKey, { value: merged, expiresAt: now + USAGE_REPORT_TTL_MS }); + this.#usageHeaderIngestAt.set(cacheKey, now); + return true; + } + #collectUsageRequests(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; }): UsageRequestDescriptor[] { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 213f30a2b..ecdce180e 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -11,6 +11,7 @@ export * from "./models"; export * from "./provider-details"; export * from "./provider-models"; export * from "./providers/anthropic"; +export * from "./providers/anthropic-client"; export * from "./providers/azure-openai-responses"; export type * from "./providers/cursor"; export * from "./providers/gitlab-duo"; diff --git a/packages/ai/src/providers/anthropic-client.ts b/packages/ai/src/providers/anthropic-client.ts new file mode 100644 index 000000000..0639f6397 --- /dev/null +++ b/packages/ai/src/providers/anthropic-client.ts @@ -0,0 +1,318 @@ +/** + * Minimal HTTP client for the Anthropic Messages API. + * + * pi-ai builds every request header itself (`buildAnthropicHeaders`), serializes + * the body itself (`buildParams`), and parses SSE frames itself + * (`iterateAnthropicEvents`), so the only `@anthropic-ai/sdk` surface this + * package ever exercised was URL assembly, auth-header injection, bounded + * retries, the pre-response timeout, and HTTP-error-to-status mapping. This + * module implements exactly that surface and nothing else. + * + * Behavioral contract (kept compatible with the SDK so downstream error + * classification keeps working): + * - Non-2xx responses throw {@link AnthropicApiError} whose `status` property + * carries the HTTP status and whose message is `" "`. + * - Pre-response timeouts throw {@link AnthropicConnectionTimeoutError} + * ("Request timed out."). + * - Caller aborts throw an `Error` with message "Request was aborted.". + * - Retries: connection errors and 408/409/429/5xx (or `x-should-retry: true`) + * are retried up to `maxRetries` times, honoring `retry-after-ms` / + * `retry-after`, otherwise exponential backoff (0.5s * 2^n, capped at 8s, + * with up to 25% jitter). + */ +import { scheduler } from "node:timers/promises"; +import type { FetchImpl } from "../types"; +import type { MessageCreateParamsStreaming } from "./anthropic-wire"; + +/** Default pre-response timeout, matching the SDK's 10-minute default. */ +const DEFAULT_TIMEOUT_MS = 600_000; +/** Default retry budget, matching the SDK's default. */ +const DEFAULT_MAX_RETRIES = 2; +const INITIAL_RETRY_DELAY_S = 0.5; +const MAX_RETRY_DELAY_S = 8; + +/** Per-request options accepted by {@link AnthropicMessages.create}. */ +export interface AnthropicRequestOptions { + signal?: AbortSignal; + /** Pre-response timeout in milliseconds. */ + timeout?: number; + /** Per-request retry budget override. */ + maxRetries?: number; +} + +/** + * Extra `RequestInit` fields merged into every fetch call. Bun extends + * `RequestInit` with a `tls` option used for the Claude Code TLS profile and + * Foundry mTLS. + */ +export type AnthropicFetchOptions = RequestInit & { + tls?: { + rejectUnauthorized?: boolean; + serverName?: string; + ciphers?: string; + ca?: string | string[]; + cert?: string; + key?: string; + }; +}; + +export interface AnthropicClientOptions { + /** Sent as `X-Api-Key` unless the header is already present in `defaultHeaders`. */ + apiKey?: string | null; + /** Sent as `Authorization: Bearer ` unless the header is already present in `defaultHeaders`. */ + authToken?: string | null; + baseURL?: string | null; + maxRetries?: number; + /** Pre-response timeout in milliseconds. Defaults to 10 minutes. */ + timeout?: number; + defaultHeaders?: Record; + fetch?: FetchImpl; + fetchOptions?: AnthropicFetchOptions; +} + +/** Non-2xx response from the Anthropic API. */ +export class AnthropicApiError extends Error { + readonly status: number; + readonly headers: Headers; + readonly requestId: string | null; + + constructor(status: number, message: string, headers: Headers) { + super(message); + this.name = "AnthropicApiError"; + this.status = status; + this.headers = headers; + this.requestId = headers.get("request-id"); + } + + static async fromResponse(response: Response): Promise { + const body = await response.text().catch(() => ""); + const detail = body.trim() || "status code (no body)"; + return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers); + } +} + +/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */ +export class AnthropicConnectionError extends Error { + constructor(cause: unknown) { + super("Connection error.", { cause }); + this.name = "AnthropicConnectionError"; + } +} + +/** No response headers arrived within the configured request timeout. */ +export class AnthropicConnectionTimeoutError extends Error { + constructor() { + super("Request timed out."); + this.name = "AnthropicConnectionTimeoutError"; + } +} + +function createAbortError(): Error { + return new Error("Request was aborted."); +} + +/** `x-should-retry` override, then 408/409/429/5xx. */ +function shouldRetryResponse(response: Response): boolean { + const shouldRetryHeader = response.headers.get("x-should-retry"); + if (shouldRetryHeader === "true") return true; + if (shouldRetryHeader === "false") return false; + const status = response.status; + return status === 408 || status === 409 || status === 429 || status >= 500; +} + +/** Server-suggested delay (`retry-after-ms`, then `retry-after` seconds or HTTP date). */ +function retryDelayFromHeaders(headers: Headers | undefined): number | undefined { + if (!headers) return undefined; + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs) { + const ms = Number.parseFloat(retryAfterMs); + if (Number.isFinite(ms) && ms >= 0) return ms; + } + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const seconds = Number.parseFloat(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + const dateMs = Date.parse(retryAfter) - Date.now(); + if (Number.isFinite(dateMs) && dateMs >= 0) return dateMs; + } + return undefined; +} + +function defaultRetryDelayMs(attempt: number): number { + const sleepSeconds = Math.min(INITIAL_RETRY_DELAY_S * 2 ** attempt, MAX_RETRY_DELAY_S); + const jitter = 1 - Math.random() * 0.25; + return sleepSeconds * jitter * 1000; +} + +function hasHeaderCaseInsensitive(headers: Record, lowerName: string): boolean { + for (const key in headers) { + if (key.toLowerCase() === lowerName) return true; + } + return false; +} + +/** + * Lazy in-flight request handle. The HTTP request starts on the first + * `asResponse()` call; subsequent calls return the same promise. + * + * Shape-compatible with the SDK's `APIPromise.asResponse()` so + * `getAnthropicStreamResponse` treats internal and injected clients uniformly. + */ +export class AnthropicApiRequest { + #start: () => Promise; + #response: Promise | undefined; + + constructor(start: () => Promise) { + this.#start = start; + } + + asResponse(): Promise { + this.#response ??= this.#start(); + return this.#response; + } +} + +/** + * `messages` resource. `create` lives on the prototype so tests can intercept + * every outgoing request with `vi.spyOn(AnthropicMessages.prototype, "create")`. + */ +export class AnthropicMessages { + #client: AnthropicMessagesClient; + #path: string; + + constructor(client: AnthropicMessagesClient, path: string) { + this.#client = client; + this.#path = path; + } + + create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest { + return this.#client.request(this.#path, params, options); + } +} + +/** + * Structural interface satisfied by both {@link AnthropicMessagesClient} and + * SDK-style clients (e.g. `AnthropicVertex`), so callers can inject an + * alternative Messages-API client via `AnthropicOptions.client`. + */ +export interface AnthropicMessagesClientLike { + messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown }; + beta?: { messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown } }; +} + +export class AnthropicMessagesClient implements AnthropicMessagesClientLike { + readonly messages: AnthropicMessages; + readonly beta: { readonly messages: AnthropicMessages }; + #options: AnthropicClientOptions; + + constructor(options: AnthropicClientOptions) { + this.#options = options; + this.messages = new AnthropicMessages(this, "/v1/messages"); + this.beta = { messages: new AnthropicMessages(this, "/v1/messages?beta=true") }; + } + + request(path: string, params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest { + return new AnthropicApiRequest(() => this.#send(path, params, options)); + } + + #buildHeaders(): Record { + const opts = this.#options; + const defaults = opts.defaultHeaders ?? {}; + const headers: Record = {}; + if (opts.apiKey != null && !hasHeaderCaseInsensitive(defaults, "x-api-key")) { + headers["X-Api-Key"] = opts.apiKey; + } + if (opts.authToken != null && !hasHeaderCaseInsensitive(defaults, "authorization")) { + headers.Authorization = `Bearer ${opts.authToken}`; + } + Object.assign(headers, defaults); + return headers; + } + + async #send( + path: string, + params: MessageCreateParamsStreaming, + options?: AnthropicRequestOptions, + ): Promise { + const opts = this.#options; + const fetchFn: FetchImpl = opts.fetch ?? fetch; + const callerSignal = options?.signal; + const timeoutMs = options?.timeout ?? opts.timeout ?? DEFAULT_TIMEOUT_MS; + const maxRetries = Math.max(0, options?.maxRetries ?? opts.maxRetries ?? DEFAULT_MAX_RETRIES); + const url = `${opts.baseURL ?? "https://api.anthropic.com"}${path}`; + const headers = this.#buildHeaders(); + const body = JSON.stringify(params); + + for (let attempt = 0; ; attempt++) { + if (callerSignal?.aborted) throw createAbortError(); + + let response: Response; + try { + response = await this.#fetchOnce(fetchFn, url, headers, body, timeoutMs, callerSignal); + } catch (error) { + if (callerSignal?.aborted) throw createAbortError(); + if (attempt < maxRetries) { + await this.#backoff(attempt, undefined, callerSignal); + continue; + } + if (error instanceof AnthropicConnectionTimeoutError) throw error; + throw new AnthropicConnectionError(error); + } + + if (response.ok) return response; + + if (attempt < maxRetries && shouldRetryResponse(response)) { + await response.body?.cancel().catch(() => {}); + await this.#backoff(attempt, response.headers, callerSignal); + continue; + } + throw await AnthropicApiError.fromResponse(response); + } + } + + async #fetchOnce( + fetchFn: FetchImpl, + url: string, + headers: Record, + body: string, + timeoutMs: number, + callerSignal: AbortSignal | undefined, + ): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + const onAbort = () => controller.abort(); + callerSignal?.addEventListener("abort", onAbort, { once: true }); + try { + return await fetchFn(url, { + method: "POST", + headers, + body, + signal: controller.signal, + ...(this.#options.fetchOptions ?? {}), + }); + } catch (error) { + if (timedOut && !callerSignal?.aborted) throw new AnthropicConnectionTimeoutError(); + throw error; + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", onAbort); + } + } + + async #backoff( + attempt: number, + responseHeaders: Headers | undefined, + signal: AbortSignal | undefined, + ): Promise { + const delayMs = retryDelayFromHeaders(responseHeaders) ?? defaultRetryDelayMs(attempt); + try { + await scheduler.wait(delayMs, { signal }); + } catch { + throw createAbortError(); + } + } +} diff --git a/packages/ai/src/providers/anthropic-messages-server-schema.ts b/packages/ai/src/providers/anthropic-messages-server-schema.ts index 1ba88d3b2..fffb6f5ad 100644 --- a/packages/ai/src/providers/anthropic-messages-server-schema.ts +++ b/packages/ai/src/providers/anthropic-messages-server-schema.ts @@ -7,6 +7,8 @@ * Used by `anthropic-messages.ts:parseRequest` to validate the inbound JSON * before walking it into pi-ai's canonical `Context`. */ + +import * as z from "zod/v4"; import type { ContentBlockParam, ImageBlockParam, @@ -15,8 +17,7 @@ import type { TextBlockParam, Tool, ToolChoice, -} from "@anthropic-ai/sdk/resources/messages"; -import * as z from "zod/v4"; +} from "./anthropic-wire"; // `cache_control` is accepted and translated to pi-ai's per-request // `cacheRetention` (any `ttl: "1h"` marker upgrades the request to "long"; diff --git a/packages/ai/src/providers/anthropic-wire.ts b/packages/ai/src/providers/anthropic-wire.ts new file mode 100644 index 000000000..463b78101 --- /dev/null +++ b/packages/ai/src/providers/anthropic-wire.ts @@ -0,0 +1,268 @@ +/** + * Anthropic Messages API wire types. + * + * Hand-maintained against https://docs.anthropic.com/en/api/messages so pi-ai + * does not depend on `@anthropic-ai/sdk` for type information. Only the shapes + * this package actually reads or writes are modeled; fields we never touch are + * intentionally omitted. Names mirror the SDK so call sites read the same. + * + * Unlike the SDK, beta fields pi-ai uses (`speed`, `context_management`, + * `output_config.effort`/`task_budget`, `thinking.display`, cache-control + * `scope`, tool `strict`/`eager_input_streaming`, mid-conversation `system` + * role) are first-class here instead of being patched in via casts. + */ +import type { TokenTaskBudget } from "../types"; + +// ─── Cache control ────────────────────────────────────────────────────────── + +/** Ephemeral prefix-cache breakpoint marker. */ +export type CacheControlEphemeral = { + type: "ephemeral"; + ttl?: "1h" | "5m"; + /** Claude Code prompt-caching-scope beta: shares the breakpoint across sessions. */ + scope?: "global"; +}; + +// ─── Content blocks (request) ─────────────────────────────────────────────── + +export type Base64ImageSource = { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; +}; + +export type URLImageSource = { type: "url"; url: string }; + +export type FileImageSource = { type: "file"; file_id: string }; + +export type ImageSource = Base64ImageSource | URLImageSource | FileImageSource; + +export type TextBlockParam = { + type: "text"; + text: string; + cache_control?: CacheControlEphemeral | null; +}; + +export type ImageBlockParam = { + type: "image"; + source: ImageSource; + cache_control?: CacheControlEphemeral | null; +}; + +export type ToolUseBlockParam = { + type: "tool_use"; + id: string; + name: string; + input: unknown; + cache_control?: CacheControlEphemeral | null; +}; + +export type ToolResultBlockParam = { + type: "tool_result"; + tool_use_id: string; + content?: string | Array; + is_error?: boolean; + cache_control?: CacheControlEphemeral | null; +}; + +export type ThinkingBlockParam = { + type: "thinking"; + thinking: string; + signature: string; +}; + +export type RedactedThinkingBlockParam = { + type: "redacted_thinking"; + data: string; +}; + +export type ContentBlockParam = + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam; + +/** + * A single conversation turn. + * + * `system` is the Opus 4.8+ mid-conversation system role + * (`mid-conversation-system-2026-04-07` beta); the public API otherwise only + * accepts `user` / `assistant`. + */ +export type MessageParam = { + role: "user" | "assistant" | "system"; + content: string | ContentBlockParam[]; +}; + +// ─── Tools ────────────────────────────────────────────────────────────────── + +export type ToolInputSchema = { + type: "object"; + properties?: unknown | null; + required?: string[] | null; + [k: string]: unknown; +}; + +export type Tool = { + name: string; + description?: string; + input_schema: ToolInputSchema; + cache_control?: CacheControlEphemeral | null; + /** Structured-outputs beta: enforce the schema as a strict grammar. */ + strict?: boolean; + /** Fine-grained tool streaming beta: stream tool input as it is generated. */ + eager_input_streaming?: boolean; +}; + +export type ToolChoiceAuto = { type: "auto"; disable_parallel_tool_use?: boolean }; +export type ToolChoiceAny = { type: "any"; disable_parallel_tool_use?: boolean }; +export type ToolChoiceTool = { type: "tool"; name: string; disable_parallel_tool_use?: boolean }; +export type ToolChoiceNone = { type: "none" }; + +export type ToolChoice = ToolChoiceAuto | ToolChoiceAny | ToolChoiceTool | ToolChoiceNone; + +// ─── Request ──────────────────────────────────────────────────────────────── + +export type Metadata = { user_id?: string | null }; + +export type ThinkingConfigEnabled = { + type: "enabled"; + budget_tokens: number; + /** Opus 4.7+ reasoning display mode. */ + display?: "summarized" | "omitted"; +}; + +export type ThinkingConfigDisabled = { type: "disabled" }; + +export type ThinkingConfigAdaptive = { + type: "adaptive"; + /** Opus 4.7+ reasoning display mode. */ + display?: "summarized" | "omitted"; +}; + +export type ThinkingConfigParam = ThinkingConfigEnabled | ThinkingConfigDisabled | ThinkingConfigAdaptive; + +export type OutputConfig = { + /** Adaptive-thinking effort level (effort beta). */ + effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; + /** Task-budgets beta. */ + task_budget?: TokenTaskBudget | null; +}; + +/** Claude Code context-management beta payload. */ +export type ContextManagement = { + edits: Array<{ type: "clear_thinking_20251015"; keep: "all" }>; +}; + +export type MessageCreateParams = { + model: string; + messages: MessageParam[]; + max_tokens: number; + system?: string | TextBlockParam[]; + temperature?: number; + top_p?: number; + top_k?: number; + stop_sequences?: string[]; + stream?: boolean; + tools?: Tool[]; + tool_choice?: ToolChoice; + metadata?: Metadata; + thinking?: ThinkingConfigParam; + output_config?: OutputConfig; + /** Fast-mode beta: realization of priority service tier. */ + speed?: "fast"; + /** Claude Code context-management beta. */ + context_management?: ContextManagement; +}; + +export type MessageCreateParamsStreaming = MessageCreateParams & { stream: true }; + +// ─── Response / usage ─────────────────────────────────────────────────────── + +export type StopReason = + | "end_turn" + | "max_tokens" + | "stop_sequence" + | "tool_use" + | "pause_turn" + | "refusal" + | "sensitive"; + +export type CacheCreation = { + ephemeral_5m_input_tokens?: number | null; + ephemeral_1h_input_tokens?: number | null; +}; + +export type ServerToolUsage = { + web_search_requests?: number | null; + web_fetch_requests?: number | null; +}; + +export type Usage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; + cache_creation?: CacheCreation | null; + server_tool_use?: ServerToolUsage | null; +}; + +/** The `message` envelope carried by `message_start`. */ +export type ResponseMessage = { + id: string; + type?: "message"; + role?: "assistant"; + model?: string; + content?: unknown[]; + stop_reason?: StopReason | null; + stop_sequence?: string | null; + usage: Usage; +}; + +// ─── Stream events ────────────────────────────────────────────────────────── + +/** `content_block` payload carried by `content_block_start`. */ +export type ResponseContentBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "redacted_thinking"; data: string } + | { type: "tool_use"; id: string; name: string; input?: Record | null }; + +export type ContentBlockDelta = + | { type: "text_delta"; text: string } + | { type: "input_json_delta"; partial_json: string } + | { type: "thinking_delta"; thinking: string } + | { type: "signature_delta"; signature: string }; + +export type StopDetails = { + type: string; + category?: string | null; + explanation?: string | null; +}; + +export type MessageDelta = { + stop_reason?: StopReason | null; + stop_sequence?: string | null; + stop_details?: StopDetails | null; +}; + +export type RawMessageStartEvent = { type: "message_start"; message: ResponseMessage }; +export type RawContentBlockStartEvent = { + type: "content_block_start"; + index: number; + content_block: ResponseContentBlock; +}; +export type RawContentBlockDeltaEvent = { type: "content_block_delta"; index: number; delta: ContentBlockDelta }; +export type RawContentBlockStopEvent = { type: "content_block_stop"; index: number }; +export type RawMessageDeltaEvent = { type: "message_delta"; delta: MessageDelta; usage: Usage }; +export type RawMessageStopEvent = { type: "message_stop" }; + +export type RawMessageStreamEvent = + | RawMessageStartEvent + | RawContentBlockStartEvent + | RawContentBlockDeltaEvent + | RawContentBlockStopEvent + | RawMessageDeltaEvent + | RawMessageStopEvent; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 13a57856e..061fdf13a 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,17 +2,6 @@ import * as nodeCrypto from "node:crypto"; import * as fs from "node:fs"; import { scheduler } from "node:timers/promises"; import * as tls from "node:tls"; -import Anthropic, { - APIConnectionTimeoutError as AnthropicConnectionTimeoutError, - type ClientOptions as AnthropicSdkClientOptions, -} from "@anthropic-ai/sdk"; -import type { MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages"; -import type { - ContentBlockParam, - MessageCreateParamsStreaming, - MessageParam, - RawMessageStreamEvent, -} from "@anthropic-ai/sdk/resources/messages"; import { $env, extractHttpStatusFromError, @@ -25,7 +14,6 @@ import { } from "@oh-my-pi/pi-utils"; import { disablesParallelToolUse, - Effort, hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort, supportsMidConversationSystemMessages, @@ -50,7 +38,6 @@ import type { StreamOptions, TextContent, ThinkingContent, - TokenTaskBudget, Tool, ToolCall, ToolResultMessage, @@ -77,6 +64,21 @@ import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; import { spillToDescription } from "../utils/schema/spill"; import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout"; import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug"; +import { + AnthropicConnectionTimeoutError, + type AnthropicFetchOptions, + AnthropicMessagesClient, + type AnthropicMessagesClientLike, +} from "./anthropic-client"; +import type { + ToolInputSchema as AnthropicToolInputSchema, + Tool as AnthropicWireTool, + ContentBlockParam, + MessageCreateParamsStreaming, + MessageParam, + RawMessageStreamEvent, + TextBlockParam, +} from "./anthropic-wire"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput, @@ -123,7 +125,6 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon const claudeCodeUtilityBetaDefaults = [ "oauth-2025-04-20", "interleaved-thinking-2025-05-14", - "redact-thinking-2026-02-12", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", "structured-outputs-2025-12-15", @@ -133,7 +134,6 @@ const claudeCodeAgentBetaDefaults = [ "oauth-2025-04-20", "context-1m-2025-08-07", "interleaved-thinking-2025-05-14", - "redact-thinking-2026-02-12", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", "mid-conversation-system-2026-04-07", @@ -142,14 +142,30 @@ const claudeCodeAgentBetaDefaults = [ const claudeCodeAgentPostEffortBetas = ["extended-cache-ttl-2025-04-11"] as const; const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14"; const interleavedThinkingBeta = "interleaved-thinking-2025-05-14"; +// Asks the API to redact thinking blocks from responses. Only sent when the +// caller explicitly hides thinking (`thinkingDisplay: "omitted"`); sending it +// by default suppresses the thinking traces callers expect to stream. +const redactThinkingBeta = "redact-thinking-2026-02-12"; const fastModeBeta = "fast-mode-2026-02-01"; const taskBudgetBeta = "task-budgets-2026-03-13"; const effortBeta = "effort-2025-11-24"; -function buildClaudeCodeBetas(agentRequest: boolean, thinkingRequest: boolean): readonly string[] { - if (!agentRequest) return claudeCodeUtilityBetaDefaults; - if (!thinkingRequest) return [...claudeCodeAgentBetaDefaults, ...claudeCodeAgentPostEffortBetas]; - return [...claudeCodeAgentBetaDefaults, effortBeta, ...claudeCodeAgentPostEffortBetas]; +function buildClaudeCodeBetas( + agentRequest: boolean, + thinkingRequest: boolean, + redactThinking: boolean, +): readonly string[] { + if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults; + const betas: string[] = []; + for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) { + betas.push(beta); + // Match CC's header order: redact-thinking immediately follows interleaved-thinking. + if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta); + } + if (!agentRequest) return betas; + if (thinkingRequest) betas.push(effortBeta); + betas.push(...claudeCodeAgentPostEffortBetas); + return betas; } function getHeaderCaseInsensitive(headers: Record | undefined, headerName: string): string | undefined { @@ -189,7 +205,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record !enforcedHeaderKeys.has(key.toLowerCase())), @@ -240,20 +256,13 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record; -type AnthropicSamplingParams = MessageCreateParamsStreaming & { - top_p?: number; - top_k?: number; -}; - -type AnthropicOutputConfig = NonNullable & { - task_budget?: TokenTaskBudget | null; -}; +type AnthropicOutputConfig = NonNullable; function getAnthropicOutputConfig(params: MessageCreateParamsStreaming): AnthropicOutputConfig { - const outputConfig = (params.output_config ?? {}) as AnthropicOutputConfig; - params.output_config = outputConfig as typeof params.output_config; + const outputConfig = params.output_config ?? {}; + params.output_config = outputConfig; return outputConfig; } @@ -354,28 +363,16 @@ export function isAnthropicFastModeUnsupportedError(error: unknown): boolean { } function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean { - const tools = params.tools as Array<{ strict?: unknown }> | undefined; - return tools?.some(tool => tool.strict === true) ?? false; + return params.tools?.some(tool => tool.strict === true) ?? false; } -/** - * `speed` and `context_management` live on Beta message params. API-key - * requests still use `client.messages.create`, so these aliases narrow casts to - * one place. - */ -type ParamsWithSpeed = MessageCreateParamsStreaming & { speed?: "fast" }; -type ParamsWithContextManagement = MessageCreateParamsStreaming & { - context_management?: { edits: [{ type: "clear_thinking_20251015"; keep: "all" }] }; -}; - function dropAnthropicFastMode(params: MessageCreateParamsStreaming): void { - delete (params as ParamsWithSpeed).speed; + delete params.speed; } function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void { - const tools = params.tools as Array<{ strict?: unknown }> | undefined; - if (!tools) return; - for (const tool of tools) { + if (!params.tools) return; + for (const tool of params.tools) { delete tool.strict; } } @@ -849,11 +846,11 @@ export interface AnthropicOptions extends StreamOptions { /** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */ isOAuth?: boolean; /** - * Pre-built Anthropic client instance. When provided, skips internal client - * construction entirely. Use this to inject alternative SDK clients such as - * `AnthropicVertex` that shares the same messaging API. + * Pre-built Anthropic Messages client. When provided, skips internal client + * construction entirely. Accepts any structurally compatible client, + * including SDK clients such as `AnthropicVertex`. */ - client?: Anthropic; + client?: AnthropicMessagesClientLike; } export type AnthropicClientOptionsArgs = { @@ -867,6 +864,7 @@ export type AnthropicClientOptionsArgs = { isOAuth?: boolean; hasTools?: boolean; thinkingEnabled?: boolean; + thinkingDisplay?: AnthropicThinkingDisplay; onSseEvent?: AnthropicOptions["onSseEvent"]; fetch?: FetchImpl; claudeCodeSessionId?: string; @@ -878,11 +876,9 @@ export type AnthropicClientOptionsResult = { authToken?: string | null; baseURL?: string; maxRetries: number; - dangerouslyAllowBrowser: boolean; defaultHeaders: Record; - logLevel: AnthropicSdkClientOptions["logLevel"]; - fetch?: AnthropicSdkClientOptions["fetch"]; - fetchOptions?: AnthropicSdkClientOptions["fetchOptions"]; + fetch?: FetchImpl; + fetchOptions?: AnthropicFetchOptions; }; const CLAUDE_CODE_TLS_CIPHERS = tls.DEFAULT_CIPHERS; @@ -999,7 +995,7 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl function buildClaudeCodeTlsFetchOptions( model: Model<"anthropic-messages">, baseUrl: string | undefined, -): AnthropicSdkClientOptions["fetchOptions"] | undefined { +): AnthropicFetchOptions | undefined { if (model.provider !== "anthropic") return undefined; if (!baseUrl) return undefined; @@ -1033,10 +1029,6 @@ function mergeHeaders(...headerSources: (Record | undefined)[]): return merged; } -// The Anthropic SDK logs malformed SSE frames directly before rethrowing them. -// We surface the resulting provider error ourselves, so keep the SDK quiet. -const ANTHROPIC_SDK_LOG_LEVEL = "off" as const; - const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ "message_start", "message_delta", @@ -1296,7 +1288,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let activeAbortTracker = createAbortSourceTracker(options?.signal); try { - let client: Anthropic; + let client: AnthropicMessagesClientLike; let isOAuthToken: boolean; if (options?.client) { @@ -1328,6 +1320,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( isOAuth: options?.isOAuth, hasTools: !!context.tools?.length, thinkingEnabled: options?.thinkingEnabled, + thinkingDisplay: options?.thinkingDisplay, onSseEvent: options?.onSseEvent, fetch: options?.fetch, claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id), @@ -1390,12 +1383,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( activeAbortTracker = createAbortSourceTracker(options?.signal); const { requestSignal } = activeAbortTracker; const requestOptions = createSdkStreamRequestOptions(requestSignal, requestTimeoutMs); - const anthropicRequest: unknown = isOAuthToken - ? client.beta.messages.create( - { ...params, stream: true } as BetaMessageCreateParamsStreaming, - requestOptions, - ) - : client.messages.create({ ...params, stream: true }, requestOptions); + const anthropicRequest: unknown = + isOAuthToken && client.beta + ? client.beta.messages.create({ ...params, stream: true }, requestOptions) + : client.messages.create({ ...params, stream: true }, requestOptions); let streamedReplayUnsafeContent = false; try { @@ -1510,7 +1501,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( name: isOAuthToken ? stripClaudeToolPrefix(event.content_block.name) : event.content_block.name, - arguments: (event.content_block.input as Record) ?? {}, + arguments: event.content_block.input ?? {}, partialJson: "", index: event.index, }; @@ -1603,7 +1594,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( } } } else if (event.type === "message_delta") { - const rawStopReason = event.delta.stop_reason as string | null | undefined; + const rawStopReason = event.delta.stop_reason; if (rawStopReason) { output.stopReason = mapStopReason(rawStopReason); sawTerminalEnvelope = true; @@ -1861,6 +1852,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A dynamicHeaders, hasTools = false, thinkingEnabled = false, + thinkingDisplay, isOAuth, onSseEvent, claudeCodeSessionId, @@ -1901,9 +1893,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: copilotApiKey, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, fetch: debugFetch, ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; @@ -1926,7 +1916,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A modelHeaders: mergeHeaders(model.headers, foundryCustomHeaders, headers, dynamicHeaders), isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway", claudeCodeSessionId, - claudeCodeBetas: oauthToken ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled) : [], + claudeCodeBetas: oauthToken + ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled, thinkingDisplay === "omitted") + : [], }); if (model.provider === "cloudflare-ai-gateway") { @@ -1936,9 +1928,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: null, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, fetch: debugFetch, }; } @@ -1952,9 +1942,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: null, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, ...(debugFetch ? { fetch: debugFetch } : {}), ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; @@ -1966,9 +1954,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A authToken: oauthToken ? apiKey : undefined, baseURL: baseUrl, maxRetries: 5, - dangerouslyAllowBrowser: true, defaultHeaders, - logLevel: ANTHROPIC_SDK_LOG_LEVEL, fetch: debugFetch, ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), }; @@ -1977,9 +1963,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A function createClient( model: Model<"anthropic-messages">, args: AnthropicClientOptionsArgs, -): { client: Anthropic; isOAuthToken: boolean } { +): { client: AnthropicMessagesClient; isOAuthToken: boolean } { const { isOAuthToken: oauthToken, ...clientOptions } = buildAnthropicClientOptions({ ...args, model }); - const client = new Anthropic(clientOptions); + const client = new AnthropicMessagesClient(clientOptions); return { client, isOAuthToken: oauthToken }; } @@ -2101,7 +2087,7 @@ function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void { const seenFiveMinute = { value: false }; if (params.tools) { - for (const tool of params.tools as Array) { + for (const tool of params.tools as Array) { normalizeCacheControlBlockTtl(tool, seenFiveMinute); } } @@ -2166,7 +2152,7 @@ function stripMessageCacheControl( function countCacheControlBreakpoints(params: MessageCreateParamsStreaming): number { let total = 0; if (params.tools) { - for (const tool of params.tools as Array) { + for (const tool of params.tools as Array) { if (tool.cache_control) total++; } } @@ -2192,7 +2178,7 @@ function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreak params.system && Array.isArray(params.system) ? (params.system as Array) : []; - const toolBlocks = (params.tools ?? []) as Array; + const toolBlocks = (params.tools ?? []) as Array; const lastSystemIndex = findLastCacheControlIndex(systemBlocks); const lastToolIndex = findLastCacheControlIndex(toolBlocks); if (systemBlocks.length > 0) { @@ -2213,37 +2199,15 @@ function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreak stripAllCacheControl(toolBlocks, excessCounter); } } -function mapEffortToClaudeCodeAdaptiveEffort( - model: Model<"anthropic-messages">, - effort: Effort, -): "low" | "medium" | "high" | "xhigh" { - // Validate against the model's supported effort range before applying Claude - // Code's unshifted wire mapping. - mapEffortToAnthropicAdaptiveEffort(model, effort); - switch (effort) { - case Effort.Minimal: - case Effort.Low: - return "low"; - case Effort.Medium: - return "medium"; - case Effort.High: - return "high"; - case Effort.XHigh: - return "xhigh"; - } -} function resolveAnthropicAdaptiveEffort( model: Model<"anthropic-messages">, options: AnthropicOptions, - isOAuthToken: boolean, ): AnthropicEffort | undefined { if (options.effort) return options.effort; const requestedEffort = options.reasoning; if (!requestedEffort) return undefined; - return isOAuthToken - ? mapEffortToClaudeCodeAdaptiveEffort(model, requestedEffort) - : mapEffortToAnthropicAdaptiveEffort(model, requestedEffort); + return mapEffortToAnthropicAdaptiveEffort(model, requestedEffort); } function startsWithAfterAsciiWhitespace(value: string, prefix: string): boolean { @@ -2279,7 +2243,7 @@ function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): st function applyClaudeCodeContextManagement(params: MessageCreateParamsStreaming, isOAuthToken: boolean): void { if (!isOAuthToken || params.thinking?.type !== "adaptive") return; - (params as ParamsWithContextManagement).context_management = { + params.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; } @@ -2293,11 +2257,9 @@ function buildParams( disableStrictTools = false, ): MessageCreateParamsStreaming { const { cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention, isOAuthToken); - const params: AnthropicSamplingParams = { + const params: MessageCreateParamsStreaming = { model: model.id, - // `system`-role params (Opus 4.8 mid-conversation system messages) are not - // yet in the SDK's `MessageParam` union; cast until it widens. - messages: convertAnthropicMessages(context.messages, model, isOAuthToken) as MessageParam[], + messages: convertAnthropicMessages(context.messages, model, isOAuthToken), max_tokens: options?.maxTokens || model.maxTokens, stream: true, }; @@ -2345,18 +2307,19 @@ function buildParams( if (model.reasoning) { if (options?.thinkingEnabled) { const mode = model.thinking?.mode; - const effort = resolveAnthropicAdaptiveEffort(model, options, isOAuthToken); + const effort = resolveAnthropicAdaptiveEffort(model, options); const compat = getAnthropicCompat(model); if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) { const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" }; - if (options.thinkingDisplay !== undefined || (!isOAuthToken && supportsAdaptiveThinkingDisplay(model.id))) { + // Starting with Claude Opus 4.7, adaptive thinking content is omitted from the + // response by default. Opt into summarized reasoning so thinking deltas keep + // streaming with human-readable content for callers that rely on it. + if (options.thinkingDisplay !== undefined || supportsAdaptiveThinkingDisplay(model.id)) { adaptive.display = options.thinkingDisplay ?? "summarized"; } - params.thinking = adaptive as typeof params.thinking; + params.thinking = adaptive; if (effort) { - // SDK's OutputConfig.effort type is not yet widened to include the new "xhigh" - // level introduced with Claude Opus 4.7. Cast until the SDK catches up. getAnthropicOutputConfig(params).effort = effort; } } else { @@ -2364,7 +2327,7 @@ function buildParams( type: "enabled", budget_tokens: options.thinkingBudgetTokens || 1024, display: options.thinkingDisplay ?? "summarized", - } as typeof params.thinking; + }; if (mode === "anthropic-budget-effort" && effort) { getAnthropicOutputConfig(params).effort = effort; } @@ -2383,7 +2346,7 @@ function buildParams( } if (resolveServiceTier(options?.serviceTier, model.provider) === "priority") { - (params as ParamsWithSpeed).speed = "fast"; + params.speed = "fast"; } if (options?.toolChoice) { @@ -2485,12 +2448,10 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul } /** - * Anthropic message param extended with the mid-conversation `system` role - * (Opus 4.8+). The SDK's `MessageParam` predates the feature and only allows - * `user`/`assistant`, so the system variant is modeled locally and cast back - * to `MessageParam[]` at the call site. + * A single Anthropic conversation turn, including the mid-conversation + * `system` role (Opus 4.8+). */ -export type AnthropicMessageParam = MessageParam | { role: "system"; content: MessageParam["content"] }; +export type AnthropicMessageParam = MessageParam; export function convertAnthropicMessages( messages: Message[], @@ -2872,8 +2833,6 @@ export function normalizeAnthropicToolSchema(schema: unknown): unknown { return result; } -type AnthropicToolInputSchema = Anthropic.Messages.Tool["input_schema"]; - type AnthropicToolSchemaPlan = { inputSchema: AnthropicToolInputSchema; strict: boolean; @@ -3093,7 +3052,7 @@ function convertTools( isOAuthToken: boolean, disableStrictTools = false, supportsEagerToolInputStreaming = true, -): Anthropic.Messages.Tool[] { +): AnthropicWireTool[] { if (!tools) return []; const schemaPlans = buildAnthropicToolSchemaPlans(tools, disableStrictTools); @@ -3104,7 +3063,6 @@ function convertTools( description: tool.description || "", input_schema: plan.inputSchema, }; - if (isOAuthToken) return baseTool; return { ...baseTool, ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), @@ -3113,7 +3071,7 @@ function convertTools( }); } -function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason { +function mapStopReason(reason: string): StopReason { switch (reason) { case "end_turn": return "stop"; diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index ed33369ca..9a04f449f 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -2,8 +2,8 @@ * Lazy provider module loading. * * Each provider module is loaded only when its stream function is first called. - * This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk, - * openai) at startup. The loaded module promise is cached so subsequent calls + * This avoids eagerly importing heavy SDK dependencies (e.g., openai) at + * startup. The loaded module promise is cached so subsequent calls * reuse the same import. * * NOTE: stream.ts currently imports providers directly, so this file is not yet diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 2aa4531e1..b49fc2868 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -163,6 +163,8 @@ export interface UsageFetchContext { export interface UsageProvider { id: Provider; fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise; + /** Parse provider rate-limit response headers (lowercased keys) into a usage report, if supported. */ + parseRateLimitHeaders?(headers: Record, now?: number): UsageReport | null; supports?(params: UsageFetchParams): boolean; } diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 28872270e..b9e54c38a 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -57,6 +57,7 @@ interface ParsedUsageBucket { utilization?: number; resetsAt?: number; } +type ClaudeUnifiedWindow = "5h" | "7d"; interface ClaudeUsageResponse { five_hour?: ClaudeUsageBucket | null; @@ -85,6 +86,20 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined { } return { utilization, resetsAt }; } +function parseUnifiedWindow( + headers: Record, + window: ClaudeUnifiedWindow, +): ParsedUsageBucket | undefined { + const prefix = `anthropic-ratelimit-unified-${window}-`; + const utilizationFraction = toNumber(headers[`${prefix}utilization`]); + const resetSeconds = toNumber(headers[`${prefix}reset`]); + const utilization = utilizationFraction === undefined ? undefined : utilizationFraction * 100; + const resetsAt = resetSeconds !== undefined && resetSeconds > 0 ? resetSeconds * 1000 : undefined; + if (utilization === undefined && resetsAt === undefined) { + return undefined; + } + return { utilization, resetsAt }; +} function getPayloadString(payload: Record, key: string): string | undefined { const value = payload[key]; @@ -325,6 +340,41 @@ function buildUsageLimit(args: { }; } +export function parseClaudeRateLimitHeaders(headers: Record, now = Date.now()): UsageReport | null { + const fiveHour = parseUnifiedWindow(headers, "5h"); + const sevenDay = parseUnifiedWindow(headers, "7d"); + const limits = [ + buildUsageLimit({ + id: "anthropic:5h", + label: "Claude 5 Hour", + windowId: "5h", + windowLabel: "5 Hour", + durationMs: FIVE_HOURS_MS, + bucket: fiveHour, + provider: "anthropic", + shared: true, + }), + buildUsageLimit({ + id: "anthropic:7d", + label: "Claude 7 Day", + windowId: "7d", + windowLabel: "7 Day", + durationMs: SEVEN_DAYS_MS, + bucket: sevenDay, + provider: "anthropic", + shared: true, + }), + ].filter((limit): limit is UsageLimit => limit !== null); + + if (limits.length === 0) return null; + return { + provider: "anthropic", + fetchedAt: now, + limits, + metadata: { source: "ratelimit-headers" }, + }; +} + async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { if (params.provider !== "anthropic") return null; const credential = params.credential; @@ -418,6 +468,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext export const claudeUsageProvider: UsageProvider = { id: "anthropic", fetchUsage: fetchClaudeUsage, + parseRateLimitHeaders: parseClaudeRateLimitHeaders, supports: params => params.provider === "anthropic" && params.credential.type === "oauth", }; diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 7f21a4d2a..7f82626ec 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -839,14 +839,19 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn if (typeof currentValue !== "string") continue; const result = tryParseJsonForTypes(currentValue, issue.expectedTypes); - if (!result.changed) continue; + const coercedValue = result.changed + ? result.value + : issue.expectedTypes.includes("array") + ? [currentValue] + : undefined; + if (coercedValue === undefined) continue; if (!owned) { nextArgs = structuredCloneJSON(nextArgs); owned = true; changed = true; } - nextArgs = setValueAtPointer(nextArgs, issue.instancePath, result.value); + nextArgs = setValueAtPointer(nextArgs, issue.instancePath, coercedValue); } return { value: changed ? nextArgs : args, changed }; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 01c8d8628..2ea2ce33e 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -154,7 +154,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(headers["X-Claude-Code-Session-Id"]).toBe(sessionId); expect(headers["x-client-request-id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); expect(headers["Anthropic-Beta"]).toBe( - "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", ); }); @@ -169,6 +169,35 @@ describe("Anthropic request fingerprint alignment", () => { }); expect(options.defaultHeaders["Anthropic-Beta"]).toBe( + "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15", + ); + }); + + it("sends redact-thinking beta only when thinking display is omitted", () => { + const baseArgs = { + model: ANTHROPIC_MODEL, + apiKey: "sk-ant-oat-test", + stream: true, + interleavedThinking: true, + hasTools: true, + thinkingEnabled: true, + } as const; + + const visible = buildAnthropicClientOptions(baseArgs); + expect(visible.defaultHeaders["Anthropic-Beta"]).not.toContain("redact-thinking-2026-02-12"); + + const hidden = buildAnthropicClientOptions({ ...baseArgs, thinkingDisplay: "omitted" }); + expect(hidden.defaultHeaders["Anthropic-Beta"]).toBe( + "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11", + ); + + const hiddenUtility = buildAnthropicClientOptions({ + ...baseArgs, + hasTools: false, + thinkingEnabled: false, + thinkingDisplay: "omitted", + }); + expect(hiddenUtility.defaultHeaders["Anthropic-Beta"]).toBe( "oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15", ); }); @@ -746,7 +775,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(block).not.toHaveProperty("minItems"); }); - it("keeps OAuth tool names behind the proxy prefix without strict or eager streaming flags", async () => { + it("keeps OAuth tool names behind the proxy prefix with eager streaming and strict flags", async () => { const tools: Tool[] = [ { name: "bash", @@ -769,8 +798,8 @@ describe("Anthropic request fingerprint alignment", () => { }; expect(payload.tools?.[0]?.name).toBe("proxy_bash"); - expect(payload.tools?.[0]?.strict).toBeUndefined(); - expect(payload.tools?.[0]?.eager_input_streaming).toBeUndefined(); + expect(payload.tools?.[0]?.strict).toBe(true); + expect(payload.tools?.[0]?.eager_input_streaming).toBe(true); expect(payload.tools?.[0]?.cache_control).toBeUndefined(); }); @@ -1293,7 +1322,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.thinking).toBeUndefined(); }); - it("drops sampling params and mirrors Claude Code adaptive thinking for OAuth Opus 4.7+", async () => { + it("drops sampling params and keeps summarized adaptive thinking for OAuth Opus 4.7+", async () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, @@ -1328,11 +1357,37 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.temperature).toBeUndefined(); expect(payload.top_p).toBeUndefined(); expect(payload.top_k).toBeUndefined(); - expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.context_management).toEqual({ edits: [{ type: "clear_thinking_20251015", keep: "all" }], }); - expect(payload.output_config).toEqual({ effort: "high" }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + + const maxPayload = (await captureAnthropicPayload( + { + ...ANTHROPIC_MODEL, + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + thinking: { + mode: "anthropic-adaptive", + minLevel: Effort.Minimal, + maxLevel: Effort.XHigh, + }, + }, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + thinkingEnabled: true, + reasoning: Effort.XHigh, + }, + )) as { + thinking?: { type?: string; display?: string }; + output_config?: { effort?: string }; + }; + expect(maxPayload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(maxPayload.output_config).toEqual({ effort: "max" }); }); it("keeps summarized adaptive thinking by default for API-key Opus 4.7+ requests", async () => { @@ -1396,7 +1451,7 @@ describe("Anthropic request fingerprint alignment", () => { }; expect(payload.output_config).toEqual({ - effort: "high", + effort: "xhigh", task_budget: { type: "tokens", total: 64_000, remaining: 48_000 }, }); }); diff --git a/packages/ai/test/anthropic-client.test.ts b/packages/ai/test/anthropic-client.test.ts new file mode 100644 index 000000000..64eb086f3 --- /dev/null +++ b/packages/ai/test/anthropic-client.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "bun:test"; +import { + AnthropicApiError, + AnthropicConnectionTimeoutError, + AnthropicMessagesClient, +} from "../src/providers/anthropic-client"; +import type { MessageCreateParamsStreaming } from "../src/providers/anthropic-wire"; + +const params: MessageCreateParamsStreaming = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + max_tokens: 64, + stream: true, +}; + +type FetchCall = { url: string; init: RequestInit }; + +function createFetchMock(responses: Array): { calls: FetchCall[]; fetch: typeof fetch } { + const calls: FetchCall[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(input), init: init ?? {} }); + const next = responses[Math.min(calls.length - 1, responses.length - 1)]; + if (next instanceof Error) throw next; + return next.clone(); + }) as typeof fetch; + return { calls, fetch: fetchImpl }; +} + +const anthropicErrorBody = JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "The compiled grammar is too large." }, +}); + +describe("AnthropicMessagesClient error mapping", () => { + it("maps non-2xx responses to AnthropicApiError with status and body in message", async () => { + const { calls, fetch } = createFetchMock([ + new Response(anthropicErrorBody, { status: 400, headers: { "request-id": "req_err" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", baseURL: "https://api.anthropic.com", fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .then( + () => undefined, + err => err, + ); + + expect(error).toBeInstanceOf(AnthropicApiError); + const apiError = error as AnthropicApiError; + // Downstream classification reads `.status` (extractHttpStatusFromError) and + // regex-matches the message body (isAnthropicStrictGrammarTooLargeError). + expect(apiError.status).toBe(400); + expect(apiError.message).toStartWith("400 "); + expect(apiError.message).toContain("invalid_request_error"); + expect(apiError.message).toContain("compiled grammar is too large"); + expect(apiError.requestId).toBe("req_err"); + // 400 is not retryable: exactly one attempt. + expect(calls.length).toBe(1); + }); + + it("does not invent a body when the error response is empty", async () => { + const { fetch } = createFetchMock([new Response(null, { status: 500 })]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 0, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect((error as AnthropicApiError).message).toBe("500 status code (no body)"); + }); +}); + +describe("AnthropicMessagesClient retries", () => { + it("retries 429 honoring retry-after-ms and succeeds", async () => { + const { calls, fetch } = createFetchMock([ + new Response("overloaded", { status: 429, headers: { "retry-after-ms": "1" } }), + new Response("{}", { status: 200 }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 2, fetch }); + + const response = await client.messages.create(params).asResponse(); + + expect(response.status).toBe(200); + expect(calls.length).toBe(2); + }); + + it("obeys x-should-retry: false over a retryable status", async () => { + const { calls, fetch } = createFetchMock([ + new Response("stop", { status: 503, headers: { "x-should-retry": "false" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 3, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect((error as AnthropicApiError).status).toBe(503); + expect(calls.length).toBe(1); + }); + + it("surfaces the final error after exhausting the retry budget", async () => { + const { calls, fetch } = createFetchMock([ + new Response("err", { status: 500, headers: { "retry-after-ms": "1" } }), + ]); + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 2, fetch }); + + const error = await client.messages + .create(params) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicApiError); + expect(calls.length).toBe(3); // initial attempt + 2 retries + }); +}); + +describe("AnthropicMessagesClient timeout and abort", () => { + it("throws AnthropicConnectionTimeoutError when no response arrives in time", async () => { + const hangingFetch = ((_input: string | URL | Request, init?: RequestInit) => { + const { promise, reject } = Promise.withResolvers(); + init?.signal?.addEventListener("abort", () => reject(new Error("aborted by signal")), { once: true }); + return promise; + }) as typeof fetch; + const client = new AnthropicMessagesClient({ apiKey: "sk-test", fetch: hangingFetch }); + + const error = await client.messages + .create(params, { timeout: 5, maxRetries: 0 }) + .asResponse() + .catch(err => err); + + expect(error).toBeInstanceOf(AnthropicConnectionTimeoutError); + // isRetryableError() keys off "timed out"/"timeout" phrasing. + expect((error as Error).message).toMatch(/timed out/i); + }); + + it("maps caller aborts to 'Request was aborted.' without retrying", async () => { + const controller = new AbortController(); + const { calls, fetch } = createFetchMock([new Error("network down")]); + const abortingFetch = ((input: string | URL | Request, init?: RequestInit) => { + controller.abort(); + return fetch(input, init); + }) as typeof fetch; + const client = new AnthropicMessagesClient({ apiKey: "sk-test", maxRetries: 5, fetch: abortingFetch }); + + const error = await client.messages + .create(params, { signal: controller.signal }) + .asResponse() + .catch(err => err); + + expect((error as Error).message).toBe("Request was aborted."); + expect(calls.length).toBe(1); + }); +}); + +describe("AnthropicMessagesClient request assembly", () => { + it("sends auth, body, and beta URL according to client options", async () => { + const { calls, fetch } = createFetchMock([new Response("{}", { status: 200 })]); + const client = new AnthropicMessagesClient({ + authToken: "oauth-token", + baseURL: "https://api.anthropic.com", + defaultHeaders: { "Anthropic-Version": "2023-06-01" }, + fetch, + }); + + await client.beta.messages.create(params).asResponse(); + + expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages?beta=true"); + expect(calls[0].init.method).toBe("POST"); + const headers = calls[0].init.headers as Record; + expect(headers.Authorization).toBe("Bearer oauth-token"); + expect(headers["Anthropic-Version"]).toBe("2023-06-01"); + expect(JSON.parse(String(calls[0].init.body))).toEqual(params); + }); + + it("never overrides auth headers already present in defaultHeaders", async () => { + const { calls, fetch } = createFetchMock([new Response("{}", { status: 200 })]); + const client = new AnthropicMessagesClient({ + apiKey: "sk-wrong", + authToken: "wrong-token", + defaultHeaders: { "X-Api-Key": "sk-right", authorization: "Bearer right-token" }, + fetch, + }); + + await client.messages.create(params).asResponse(); + + const headers = calls[0].init.headers as Record; + expect(headers["X-Api-Key"]).toBe("sk-right"); + expect(headers.authorization).toBe("Bearer right-token"); + expect(headers.Authorization).toBeUndefined(); + expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages"); + }); +}); diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f082044fb..dfe5d5c50 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; -import { Messages } from "@anthropic-ai/sdk/resources/messages/messages"; import { streamAnthropic } from "../src/providers/anthropic"; +import { AnthropicMessages } from "../src/providers/anthropic-client"; import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; const model: Model<"anthropic-messages"> = { @@ -209,7 +209,7 @@ afterEach(() => { describe("anthropic stream envelope handling", () => { it("ignores duplicate message_start envelopes without resetting streamed text", async () => { - vi.spyOn(Messages.prototype, "create").mockImplementation( + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => createMockRequest(createTextSuccessEvents("hello", { duplicateMessageStart: true })) as never, ); @@ -231,7 +231,7 @@ describe("anthropic stream envelope handling", () => { it("ignores ping before message_start and streams the response once", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest(createTextSuccessEventsWithPreamble("hello", [{ type: "ping" }])) as never; }); @@ -256,7 +256,7 @@ describe("anthropic stream envelope handling", () => { it("ignores unknown preamble events before message_start and streams the response once", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest( createTextSuccessEventsWithPreamble("hello", [{ type: "custom_preamble_event", trace_id: "trace_123" }]), @@ -283,7 +283,7 @@ describe("anthropic stream envelope handling", () => { it("retries malformed envelopes before content starts without duplicating streamed text events", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest( attempt === 1 ? createMalformedPreMessageStartEvents() : createTextSuccessEvents("recovered"), @@ -322,7 +322,7 @@ describe("anthropic stream envelope handling", () => { const providerSessionState = new Map(); const strictFlags: boolean[][] = []; let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { attempt += 1; strictFlags.push(getStrictFlags(params)); if (attempt === 1) { @@ -378,7 +378,7 @@ describe("anthropic stream envelope handling", () => { const providerSessionState = new Map(); const strictFlags: boolean[][] = []; let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { attempt += 1; strictFlags.push(getStrictFlags(params)); return createRejectedMockRequest(createOtherInvalidRequestError()) as never; @@ -405,7 +405,7 @@ describe("anthropic stream envelope handling", () => { it("does not retry malformed envelopes after partial tool-call content starts streaming", async () => { let attempt = 0; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => { attempt += 1; return createMockRequest(createMalformedToolUseEvents()) as never; }); @@ -434,7 +434,7 @@ describe("anthropic stream envelope handling", () => { expect("partialJson" in toolCall).toBe(false); }); it("parses raw SSE directly so unknown events do not fail Anthropic streams", async () => { - vi.spyOn(Messages.prototype, "create").mockImplementation( + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () => createRawSseRequest( createTextSuccessSseFrames("hello", [ @@ -460,7 +460,9 @@ describe("anthropic stream envelope handling", () => { const incompleteFrames = createTextSuccessSseFrames("partial").filter( frame => !frame.includes("event: message_stop"), ); - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(incompleteFrames) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => createRawSseRequest(incompleteFrames) as never, + ); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; @@ -488,7 +490,7 @@ describe("anthropic stream envelope handling", () => { sseFrame("message_delta", successEvents[4]), sseFrame("message_stop", { type: "message_stop" }), ]; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); for await (const _ of stream) { @@ -520,7 +522,9 @@ describe("anthropic stream envelope handling", () => { }, { type: "message_stop" }, ]; - vi.spyOn(Messages.prototype, "create").mockImplementation(() => createMockRequest(refusalEvents) as never); + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => createMockRequest(refusalEvents) as never, + ); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); const events: AssistantMessageEvent[] = []; @@ -548,7 +552,7 @@ describe("anthropic stream envelope handling", () => { ], }; const payloads: unknown[] = []; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { payloads.push(params); return createMockRequest(createTextSuccessEvents("ok")) as never; }); @@ -577,7 +581,7 @@ describe("anthropic stream envelope handling", () => { it("emits 1h cache TTL only for canonical Anthropic API with compatible long-cache support", async () => { const payloads: unknown[] = []; - vi.spyOn(Messages.prototype, "create").mockImplementation((params: unknown) => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => { payloads.push(params); return createMockRequest(createTextSuccessEvents("ok")) as never; }); diff --git a/packages/ai/test/anthropic-stream-timeout.test.ts b/packages/ai/test/anthropic-stream-timeout.test.ts index d0225ff90..0b14384b4 100644 --- a/packages/ai/test/anthropic-stream-timeout.test.ts +++ b/packages/ai/test/anthropic-stream-timeout.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import type Anthropic from "@anthropic-ai/sdk"; import { streamAnthropic } from "../src/providers/anthropic"; +import type { AnthropicMessagesClientLike } from "../src/providers/anthropic-client"; import type { Context, Model } from "../src/types"; import { waitForDelayOrAbort } from "./helpers"; @@ -155,8 +155,8 @@ describe("anthropic first-event timeout retries", () => { signal: requestOptions?.signal, events: attempt === 1 ? undefined : createSuccessfulAnthropicEvents("retry recovered"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { @@ -188,8 +188,8 @@ describe("anthropic first-event timeout retries", () => { connectDelayMs: 2, events: createSuccessfulAnthropicEvents("delayed connect"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const result = await streamAnthropic(model, context, { client, @@ -218,8 +218,8 @@ describe("anthropic first-event timeout retries", () => { connectDelayMs: 20, events: createSuccessfulAnthropicEvents("too late"), }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { @@ -240,8 +240,8 @@ describe("anthropic first-event timeout retries", () => { const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => { attempt += 1; return createAnthropicMockStream({ signal: requestOptions?.signal }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const controller = new AbortController(); setTimeout(() => controller.abort(), 1); @@ -289,8 +289,8 @@ describe("anthropic first-event timeout retries", () => { ], hangAfterEvents: true, }) as never; - }) as unknown as Anthropic["messages"]["create"]; - const client = { messages: { create } } as Anthropic; + }) as unknown as AnthropicMessagesClientLike["messages"]["create"]; + const client = { messages: { create } } as AnthropicMessagesClientLike; const providerRetryWait = vi.fn(async () => {}); const result = await streamAnthropic(model, context, { diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index 15bff0d2d..fcb2d87a6 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -17,13 +17,25 @@ import { AuthStorage, type StoredAuthCredential, } from "../src/auth-storage"; -import type { UsageReport } from "../src/usage"; +import type { UsageLimit, UsageReport } from "../src/usage"; import * as claudeUsage from "../src/usage/claude"; function anthropicReports(reports: UsageReport[] | null): UsageReport[] { return (reports ?? []).filter(r => r.provider === "anthropic"); } +function requireAnthropicReport(reports: UsageReport[] | null): UsageReport { + const report = anthropicReports(reports)[0]; + if (!report) throw new Error("expected anthropic usage report"); + return report; +} + +function requireLimit(report: UsageReport, id: string): UsageLimit { + const limit = report.limits.find(candidate => candidate.id === id); + if (!limit) throw new Error(`expected ${id} limit`); + return limit; +} + /** * Force every cache entry to look stale to AuthStorage WITHOUT dropping the * value. The cache layer is two-tier: the store-level `expiresAtSec` controls @@ -120,6 +132,53 @@ function makeReport(account: string): UsageReport { }; } +function makeTieredReport(account: string): UsageReport { + return { + provider: "anthropic", + fetchedAt: Date.now() - 10_000, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h", shared: true }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: 42, limit: 100, usedFraction: 0.42, unit: "percent" }, + status: "ok", + }, + { + id: "anthropic:7d", + label: "Claude 7 Day", + scope: { provider: "anthropic", windowId: "7d", shared: true }, + window: { id: "7d", label: "7 Day" }, + amount: { used: 84, limit: 100, usedFraction: 0.84, unit: "percent" }, + status: "ok", + }, + { + id: "anthropic:7d:opus", + label: "Claude 7 Day (Opus)", + scope: { provider: "anthropic", windowId: "7d", tier: "opus" }, + window: { id: "7d", label: "7 Day" }, + amount: { used: 12, limit: 100, usedFraction: 0.12, unit: "percent" }, + status: "ok", + }, + ], + metadata: { + email: account, + accountId: `account-${account}`, + endpoint: "https://api.anthropic.com/api/oauth/usage", + }, + }; +} + +function usageHeaders(fiveHour: string, sevenDay: string): Record { + return { + "anthropic-ratelimit-unified-5h-utilization": fiveHour, + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-7d-utilization": sevenDay, + "anthropic-ratelimit-unified-7d-reset": "1780531200", + }; +} + describe("AuthStorage usage cache: last-good failure fallback", () => { let store: ObservableStore; let storage: AuthStorage; @@ -273,6 +332,76 @@ describe("AuthStorage usage cache: jitter", () => { }); }); +describe("AuthStorage usage cache: header ingestion", () => { + let store: ObservableStore; + let storage: AuthStorage; + + beforeEach(async () => { + store = makeStore([oauthRow(1, "a@example.com")]); + storage = new AuthStorage(store, { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }); + await storage.reload(); + }); + + afterEach(() => { + storage.close(); + vi.restoreAllMocks(); + }); + + it("writes the same per-credential cache key that fetchUsageReports reads", async () => { + let calls = 0; + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { + calls += 1; + throw new Error("usage endpoint should not be probed after header ingestion"); + }); + + expect(await storage.getApiKey("anthropic", "s")).toBe("oat-1"); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.02", "0.3"), { sessionId: "s" })).toBe(true); + + const report = requireAnthropicReport(await storage.fetchUsageReports()); + expect(calls).toBe(0); + expect(report.metadata?.source).toBe("ratelimit-headers"); + expect(requireLimit(report, "anthropic:5h").amount.used).toBe(2); + expect(requireLimit(report, "anthropic:7d").amount.used).toBe(30); + }); + + it("throttles repeated header ingestion for the same credential cache key", async () => { + expect(await storage.getApiKey("anthropic", "s")).toBe("oat-1"); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.02", "0.3"), { sessionId: "s" })).toBe(true); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.05", "0.6"), { sessionId: "s" })).toBe(false); + }); + + it("merges header umbrella windows onto the last real report and preserves tier limits", async () => { + const realReport = makeTieredReport("a@example.com"); + let calls = 0; + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { + calls += 1; + return realReport; + }); + + const initialReport = requireAnthropicReport(await storage.fetchUsageReports()); + expect(requireLimit(initialReport, "anthropic:7d:opus").amount.used).toBe(12); + expect(calls).toBe(1); + + expect(await storage.getApiKey("anthropic", "merge-session")).toBe("oat-1"); + const beforeIngest = Date.now(); + expect(storage.ingestUsageHeaders("anthropic", usageHeaders("0.05", "0.9"), { sessionId: "merge-session" })).toBe( + true, + ); + + const mergedReport = requireAnthropicReport(await storage.fetchUsageReports()); + expect(calls).toBe(1); + expect(mergedReport.fetchedAt).toBeGreaterThan(realReport.fetchedAt); + expect(mergedReport.metadata?.email).toBe("a@example.com"); + expect(mergedReport.metadata?.accountId).toBe("account-a@example.com"); + expect(mergedReport.metadata?.headersUpdatedAt).toBeGreaterThanOrEqual(beforeIngest); + expect(requireLimit(mergedReport, "anthropic:5h").amount.used).toBe(5); + expect(requireLimit(mergedReport, "anthropic:7d").amount.used).toBe(90); + expect(requireLimit(mergedReport, "anthropic:7d:opus").amount.used).toBe(12); + }); +}); + describe("AuthStorage usage cache: terminal refresh failure", () => { // Regression: a revoked refresh token used to fail the in-line OAuth refresh // inside the usage probe, get silently swallowed, then trigger the upstream diff --git a/packages/ai/test/claude-ratelimit-headers.test.ts b/packages/ai/test/claude-ratelimit-headers.test.ts new file mode 100644 index 000000000..e9d2188dc --- /dev/null +++ b/packages/ai/test/claude-ratelimit-headers.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import type { UsageLimit, UsageReport } from "../src/usage"; +import { parseClaudeRateLimitHeaders } from "../src/usage/claude"; + +const NOW = 1_780_400_000_000; + +function requireReport(report: UsageReport | null): UsageReport { + if (!report) throw new Error("expected Claude rate-limit headers to parse"); + return report; +} + +function requireLimit(report: UsageReport, id: string): UsageLimit { + const limit = report.limits.find(candidate => candidate.id === id); + if (!limit) throw new Error(`expected ${id} limit`); + return limit; +} + +describe("Claude rate-limit response headers", () => { + it("parses unified 5h and 7d header windows with percent scaling and epoch-ms resets", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-utilization": "0.0", + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-5h-status": "allowed", + "anthropic-ratelimit-unified-7d-utilization": "0.1", + "anthropic-ratelimit-unified-7d-reset": "1780531200", + "anthropic-ratelimit-unified-7d-status": "allowed", + }, + NOW, + ), + ); + + expect(report.provider).toBe("anthropic"); + expect(report.fetchedAt).toBe(NOW); + expect(report.metadata?.source).toBe("ratelimit-headers"); + expect(report.limits).toHaveLength(2); + + const fiveHour = requireLimit(report, "anthropic:5h"); + expect(fiveHour.label).toBe("Claude 5 Hour"); + expect(fiveHour.scope.provider).toBe("anthropic"); + expect(fiveHour.scope.windowId).toBe("5h"); + expect(fiveHour.scope.shared).toBe(true); + expect(fiveHour.window?.label).toBe("5 Hour"); + expect(fiveHour.window?.durationMs).toBe(5 * 60 * 60 * 1000); + expect(fiveHour.amount.used).toBe(0); + expect(fiveHour.amount.usedFraction).toBe(0); + + const sevenDay = requireLimit(report, "anthropic:7d"); + expect(sevenDay.label).toBe("Claude 7 Day"); + expect(sevenDay.scope.provider).toBe("anthropic"); + expect(sevenDay.scope.windowId).toBe("7d"); + expect(sevenDay.scope.shared).toBe(true); + expect(sevenDay.scope.tier).toBeUndefined(); + expect(sevenDay.window?.label).toBe("7 Day"); + expect(sevenDay.window?.durationMs).toBe(7 * 24 * 60 * 60 * 1000); + expect(sevenDay.window?.resetsAt).toBe(1780531200 * 1000); + expect(sevenDay.amount.used).toBe(10); + expect(sevenDay.amount.usedFraction).toBe(0.1); + }); + + it("parses a single available unified window", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-utilization": "0.25", + "anthropic-ratelimit-unified-5h-reset": "1780405800", + }, + NOW, + ), + ); + + expect(report.limits.map(limit => limit.id)).toEqual(["anthropic:5h"]); + expect(report.limits[0]?.amount.used).toBe(25); + }); + + it("returns null when no unified utilization headers are present", () => { + expect(parseClaudeRateLimitHeaders({ "anthropic-ratelimit-unified-status": "allowed" }, NOW)).toBeNull(); + }); + + it("omits a window that has reset metadata without utilization", () => { + const report = requireReport( + parseClaudeRateLimitHeaders( + { + "anthropic-ratelimit-unified-5h-reset": "1780405800", + "anthropic-ratelimit-unified-7d-utilization": "0.4", + "anthropic-ratelimit-unified-7d-reset": "1780531200", + }, + NOW, + ), + ); + + expect(report.limits.map(limit => limit.id)).toEqual(["anthropic:7d"]); + expect(report.limits[0]?.amount.used).toBe(40); + }); +}); diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 9429f7025..b01bf56b8 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -60,6 +60,24 @@ describe("Tool argument coercion", () => { expect(result.items).toEqual([1, 2, 3]); }); + it("wraps a plain string in a singleton array when schema expects string array", () => { + const tool: Tool = { + name: "t3b", + description: "", + parameters: z.object({ paths: z.array(z.string()) }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-3b", + name: "t3b", + arguments: { paths: "src/**/*.ts" }, + }; + + const result = validateToolArguments(tool, toolCall) as { paths: string[] }; + expect(result.paths).toEqual(["src/**/*.ts"]); + }); + it("parses JSON objects in string values when schema expects object", () => { const tool: Tool = { name: "t4", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index de7d315c6..f7467258f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,10 +2,13 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + ### Added - Added an all-projects scope to the session picker (`pi --resume` / `/resume`). Press `Tab` to toggle between the current folder's sessions and every session across all projects; the all-projects list is loaded lazily and shows each session's directory. When the current folder has no sessions the picker now opens straight into all-projects scope instead of printing "No sessions found". - Migrated the Kagi web search provider to Kagi's V1 Search API (`POST /api/v1/search`), replacing the sunset V0 endpoint while keeping the `kagi` provider id, `KAGI_API_KEY` credential, and `/login kagi` flow unchanged ([#1272](https://github.com/can1357/oh-my-pi/pull/1272) by [@thismat](https://github.com/thismat)) +- Added Anthropic `anthropic-ratelimit-unified-*` response-header warming for `/usage` and the status-line usage segment, throttled to reduce direct OAuth `/usage` probes during active use. ### Changed @@ -19,6 +22,7 @@ ### Fixed +- Fixed `read`, `search`, `find`, `ast_grep`, and `ast_edit` recovering when a model flattens multiple existing paths into one comma-, semicolon-, or space-delimited string while preserving real paths that contain delimiters. - Fixed Exa web search reporting available without Exa credentials, which could route searches into the unauthenticated public MCP fallback and stall before trying the next provider. Availability and `searchExa()` now resolve through the standard `AuthStorage` cascade (`EXA_API_KEY` env or stored credential) ([#1695](https://github.com/can1357/oh-my-pi/issues/1695)). - Fixed Anthropic web search ignoring `ANTHROPIC_SEARCH_BASE_URL` when credentials came from stored Anthropic auth or generic Anthropic env fallback rather than `ANTHROPIC_SEARCH_API_KEY` ([#1694](https://github.com/can1357/oh-my-pi/issues/1694)). - Fixed `web_search` returning 401 from corporate Anthropic API gateways. `ANTHROPIC_CUSTOM_HEADERS` is now forwarded to web-search requests whenever `ANTHROPIC_BASE_URL` points to a non-Anthropic host, not only in Foundry mode ([#1693](https://github.com/can1357/oh-my-pi/issues/1693)). diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 116d4ca4d..20788fbff 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "15.7.6", + "version": "15.8.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index b6b0a0f6e..d3b738e91 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -2,7 +2,7 @@ Finds files and directories using fast pattern matching that works with any code - `paths` is required and accepts an array of globs, files, or directories -- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected) +- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`). - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored - `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index b011fbea1..52cb4b82b 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -3,7 +3,7 @@ Searches files using powerful regex matching. - Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(? diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 00f655d8d..738985f4f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -62,6 +62,7 @@ import type { Message, MessageAttribution, Model, + ProviderResponseMetadata, ProviderSessionState, ServiceTier, SimpleStreamOptions, @@ -1104,10 +1105,12 @@ export class AgentSession { this.#onResponse = configuredOnResponse ? async (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); + this.#ingestProviderUsageHeaders(response, model); await configuredOnResponse(response, model); } : (response, model) => { this.rawSseDebugBuffer.recordResponse(response, model); + this.#ingestProviderUsageHeaders(response, model); }; const configuredOnSseEvent = config.onSseEvent; this.#onSseEvent = configuredOnSseEvent @@ -9253,6 +9256,14 @@ export class AgentSession { }; } + #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void { + if (model?.provider !== "anthropic") return; + this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, { + sessionId: this.agent.sessionId, + baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"), + }); + } + async fetchUsageReports(signal?: AbortSignal): Promise { const authStorage = this.#modelRegistry.authStorage; if (!authStorage.fetchUsageReports) return null; diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 5c0678da6..ec938b39a 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -16,6 +16,7 @@ import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { + expandDelimitedPathEntries, formatPathRelativeToCwd, hasGlobPathChars, normalizePathLikeInput, @@ -52,33 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000; const MIN_GLOB_TIMEOUT_MS = 500; const MAX_GLOB_TIMEOUT_MS = 60_000; -/** - * Reject comma-separated path lists packed into a single array element - * (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a - * single comma-joined element get silent no-matches otherwise. - * - * Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and - * must pass through. - */ -export function validateFindPathInputs(paths: readonly string[]): void { - for (const entry of paths) { - let braceDepth = 0; - for (let i = 0; i < entry.length; i++) { - const ch = entry.charCodeAt(i); - if (ch === 0x5c /* \ */ && i + 1 < entry.length) { - i++; - continue; - } - if (ch === 0x7b /* { */) braceDepth++; - else if (ch === 0x7d /* } */) { - if (braceDepth > 0) braceDepth--; - } else if (ch === 0x2c /* , */ && braceDepth === 0) { - throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`); - } - } - } -} - /** * Group find matches by their directory so the model doesn't pay repeated * tokens for shared path prefixes. Preserves the input order: groups appear in @@ -180,8 +154,10 @@ export class FindTool implements AgentTool { return untilAborted(signal, async () => { const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd); - validateFindPathInputs(paths); - const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/")); + const rawPatternInputs = this.#customOps + ? paths + : await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern }); + const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/")); const internalRouter = InternalUrlRouter.instance(); const normalizedPatterns: string[] = []; for (const rawPattern of rawPatterns) { diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 749ced72b..645f1390c 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -379,6 +379,145 @@ export function hasGlobPathChars(filePath: string): boolean { return GLOB_PATH_CHARS.some(char => filePath.includes(char)); } +type PathEntrySplitter = (item: string) => { basePath: string }; + +const TOP_LEVEL_WHITESPACE_RE = /\s/; + +type DelimitedPathSplitMode = "comma" | "semicolon" | "whitespace" | "mixed"; + +function isDelimitedPathSeparator(ch: string, mode: DelimitedPathSplitMode): boolean { + if (mode === "comma") return ch === ","; + if (mode === "semicolon") return ch === ";"; + if (mode === "whitespace") return TOP_LEVEL_WHITESPACE_RE.test(ch); + return ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch); +} + +function hasTopLevelPathDelimiter(entry: string): boolean { + let braceDepth = 0; + for (let i = 0; i < entry.length; i++) { + const ch = entry[i]; + if (ch === "\\" && i + 1 < entry.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth === 0 && (ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch))) { + return true; + } + } + return false; +} + +function splitTopLevelDelimitedPath(entry: string, mode: DelimitedPathSplitMode): string[] { + const parts: string[] = []; + let braceDepth = 0; + let start = 0; + for (let i = 0; i < entry.length; i++) { + const ch = entry[i]; + if (ch === "\\" && i + 1 < entry.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth !== 0 || !isDelimitedPathSeparator(ch, mode)) continue; + parts.push(entry.slice(start, i)); + start = i + 1; + } + parts.push(entry.slice(start)); + return parts; +} + +async function delimitedPathPartResolves(entry: string, cwd: string, splitter: PathEntrySplitter): Promise { + if (isInternalUrlPath(entry)) return true; + const peeled = splitPathAndSel(entry).path; + const { basePath } = splitter(peeled); + const absoluteBasePath = resolveToCwd(basePath, cwd); + try { + await fs.promises.stat(absoluteBasePath); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } +} + +async function tryDelimitedPathSplit( + entry: string, + cwd: string, + splitter: PathEntrySplitter, + mode: DelimitedPathSplitMode, + requireAllParts: boolean, +): Promise { + const rawParts = splitTopLevelDelimitedPath(entry, mode); + if (rawParts.length < 2) return null; + + const parts = rawParts.map(normalizePathLikeInput).filter(part => part.length > 0); + if (parts.length === 0) return null; + if (parts.length < 2 && rawParts.length === parts.length) return null; + + const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter))); + const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean); + return valid ? parts : null; +} + +/** + * Split one path-like entry whose multiple targets were flattened into one + * string. Existing paths are kept intact, so real filenames containing spaces, + * commas, or semicolons win over delimiter recovery. + */ +export async function splitDelimitedPathEntry( + entry: string, + cwd: string, + options: { splitter?: PathEntrySplitter } = {}, +): Promise { + const normalizedEntry = normalizePathLikeInput(entry); + if (!hasTopLevelPathDelimiter(normalizedEntry)) return null; + if (isInternalUrlPath(normalizedEntry)) return null; + + const splitter = options.splitter ?? parseSearchPath; + const peeledEntry = splitPathAndSel(normalizedEntry).path; + if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) { + return null; + } + + return ( + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ?? + (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true)) + ); +} + +/** Expand delimited entries in-place while preserving unsplit entries. */ +export async function expandDelimitedPathEntries( + entries: readonly string[], + cwd: string, + options: { splitter?: PathEntrySplitter } = {}, +): Promise { + const expanded: string[] = []; + for (const entry of entries) { + const normalizedEntry = normalizePathLikeInput(entry); + const split = await splitDelimitedPathEntry(normalizedEntry, cwd, options); + if (split) expanded.push(...split); + else expanded.push(normalizedEntry); + } + return expanded; +} + export interface ParsedSearchPath { basePath: string; glob?: string; @@ -769,7 +908,11 @@ export interface ToolScopeResolution { */ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise { const { rawPaths: inputs, cwd, internalUrlAction } = opts; - const rawPaths = inputs.map(normalizePathLikeInput); + const normalizedRawPaths = inputs.map(normalizePathLikeInput); + if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) { + throw new ToolError("`paths` must contain non-empty paths or globs"); + } + const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd); if (rawPaths.some(rawPath => rawPath.length === 0)) { throw new ToolError("`paths` must contain non-empty paths or globs"); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 8ad9a54f9..559b53fe1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -69,6 +69,7 @@ import { type LineRange, parseLineRanges, resolveReadPath, + splitDelimitedPathEntry, splitInternalUrlSel, splitPathAndSel, } from "./path-utils"; @@ -693,6 +694,50 @@ export class ReadTool implements AgentTool { }); } + async #tryReadDelimitedPaths( + readPath: string, + signal?: AbortSignal, + ): Promise | null> { + const parts = await splitDelimitedPathEntry(readPath, this.session.cwd); + if (!parts) return null; + + const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`; + const notes = [notice]; + const content: Array = []; + let pendingText = notice; + const flushText = () => { + if (pendingText.length === 0) return; + content.push({ type: "text", text: pendingText }); + pendingText = ""; + }; + const appendText = (text: string) => { + pendingText = pendingText.length > 0 ? `${pendingText}\n\n${text}` : text; + }; + + for (const part of parts) { + try { + const result = await this.execute("read-delimited-part", { path: part }, signal); + for (const block of result.content) { + if (block.type === "text") { + appendText(block.text); + continue; + } + flushText(); + content.push(block); + } + } catch (error) { + if (error instanceof ToolAbortError || signal?.aborted) throw error; + const message = error instanceof Error ? error.message : String(error); + const errorNote = `Could not read ${part}: ${message}`; + notes.push(errorNote); + appendText(`[${errorNote}]`); + } + } + flushText(); + + return toolResult({ notes }).content(content).done(); + } + async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise { const candidates = parseArchivePathCandidates(readPath); for (const candidate of candidates) { @@ -1596,6 +1641,8 @@ export class ReadTool implements AgentTool { } if (!suffixResolution) { + const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal); + if (delimitedResult) return delimitedResult; throw new ToolError(`Path '${localReadPath}' not found`); } } else { diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index 897902886..84f2040e0 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -30,6 +30,7 @@ import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { + expandDelimitedPathEntries, hasGlobPathChars, isLineInRanges, type LineRange, @@ -93,29 +94,6 @@ export const SINGLE_FILE_MATCHES = 200; * pagination headroom so the caller can see total file count. */ const INTERNAL_TOTAL_CAP = 2000; -/** - * Detect a `,` that is not inside a `{…}` brace expansion. Used to catch - * `paths: ["a,b"]` mistakes where the caller flattened multiple entries - * into a single string instead of passing a JSON array of strings. - */ -function containsTopLevelComma(entry: string): boolean { - let depth = 0; - for (let i = 0; i < entry.length; i++) { - const ch = entry[i]; - if (ch === "\\" && i + 1 < entry.length) { - i++; - continue; - } - if (ch === "{") depth++; - else if (ch === "}") { - if (depth > 0) depth--; - } else if (ch === "," && depth === 0) { - return true; - } - } - return false; -} - /** * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional * line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …) @@ -146,9 +124,6 @@ function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] { clean = split.path; ranges = parsed; } - if (containsTopLevelComma(clean)) { - throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]'); - } specs.push({ original: entry, clean, ranges }); } return specs; @@ -663,7 +638,7 @@ export class SearchTool implements AgentTool spec.clean); const { diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index 0f7e23c24..9c065ecc8 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -350,7 +350,7 @@ describe("listClaudePluginRoots", () => { const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings).toEqual([]); expect(result.all.length).toBeGreaterThan(0); - const found = result.all.find(skill => skill.name === "manifest-skills:manifest-skill"); + const found = result.all.find(skill => skill.name === "manifest-skill"); expect(found).toBeDefined(); expect(found?.path).toContain(path.join(".claude", "skills", "manifest-skill", "SKILL.md")); @@ -507,7 +507,7 @@ describe("listClaudePluginRoots", () => { const result = await loadCapability("skills", { cwd: tempDir }); expect(result.warnings[0]).toContain("Ignoring skills path outside plugin root"); - const found = result.all.find(skill => skill.name === "manifest-skills-outside:outside-skill"); + const found = result.all.find(skill => skill.name === "outside-skill"); expect(found).toBeUndefined(); }); diff --git a/packages/coding-agent/test/tools/find-validate-paths.test.ts b/packages/coding-agent/test/tools/find-validate-paths.test.ts index ed2167101..2ea467e53 100644 --- a/packages/coding-agent/test/tools/find-validate-paths.test.ts +++ b/packages/coding-agent/test/tools/find-validate-paths.test.ts @@ -1,8 +1,12 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Component } from "@oh-my-pi/pi-tui"; import type { RenderResultOptions } from "../../src/extensibility/custom-tools/types"; import { getThemeByName, initTheme, type Theme } from "../../src/modes/theme/theme"; -import { findToolRenderer, validateFindPathInputs } from "../../src/tools/find"; +import { findToolRenderer } from "../../src/tools/find"; +import { expandDelimitedPathEntries, parseFindPattern, splitDelimitedPathEntry } from "../../src/tools/path-utils"; let uiTheme: Theme; @@ -21,43 +25,79 @@ function renderText(component: Component): string { return Bun.stripANSI(component.render(160).join("\n")); } -describe("validateFindPathInputs", () => { - it("accepts a normal array of glob entries", () => { - expect(() => validateFindPathInputs(["src/**/*.ts", "test/**/*.ts"])).not.toThrow(); +describe("delimited path expansion", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delimited-paths-")); + await fs.mkdir(path.join(tempDir, "apps"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "packages"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "src"), { recursive: true }); + await fs.mkdir(path.join(tempDir, "folder with spaces"), { recursive: true }); + await Bun.write(path.join(tempDir, "apps", "a.txt"), "apps\n"); + await Bun.write(path.join(tempDir, "packages", "b.txt"), "packages\n"); + await Bun.write(path.join(tempDir, "folder with spaces", "file.txt"), "spaces\n"); }); - it('rejects comma-joined entries (the `["a,b"]` shape)', () => { - expect(() => validateFindPathInputs(["a.py,b.py"])).toThrow(/paths is an array/); + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); }); - it("allows commas inside brace expansion", () => { - expect(() => validateFindPathInputs(["src/{a,b}/*.ts"])).not.toThrow(); - expect(() => validateFindPathInputs(["{foo,bar,baz}.md"])).not.toThrow(); + it("splits comma, semicolon, and space delimited entries when parts resolve", async () => { + expect(await splitDelimitedPathEntry("apps/a.txt, packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("apps/a.txt;packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("apps/a.txt packages/b.txt", tempDir)).toEqual([ + "apps/a.txt", + "packages/b.txt", + ]); }); - it("allows backslash-escaped commas at top level (matches search.ts:containsTopLevelComma)", () => { - // Backslash-escapes a literal comma in a filename — must not trip the - // array-vs-string heuristic. - expect(() => validateFindPathInputs(["weird\\,name.txt"])).not.toThrow(); - expect(() => validateFindPathInputs(["a\\,b\\,c"])).not.toThrow(); + it("keeps an existing path with spaces intact", async () => { + expect(await splitDelimitedPathEntry("folder with spaces/file.txt", tempDir)).toBeNull(); + expect(await expandDelimitedPathEntries(["folder with spaces/file.txt"], tempDir)).toEqual([ + "folder with spaces/file.txt", + ]); }); - it("still rejects unescaped top-level commas mixed with escaped ones", () => { - // `a\,b,c` — the second comma is unescaped, so the heuristic should fire. - expect(() => validateFindPathInputs(["a\\,b,c"])).toThrow(/paths is an array/); + it("does not split commas inside brace globs", async () => { + expect(await splitDelimitedPathEntry("src/{a,b}.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("src/{a,b}.txt, packages/b.txt", tempDir)).toEqual([ + "src/{a,b}.txt", + "packages/b.txt", + ]); }); - it("allows a trailing backslash without crashing", () => { - // `foo\\` is a backslash at end-of-string; the i+1 validateFindPathInputs(["foo\\"])).not.toThrow(); + it("does not split backslash-escaped delimiters", async () => { + expect(await splitDelimitedPathEntry("apps/a.txt\\,packages/b.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("apps/a.txt\\;packages/b.txt", tempDir)).toBeNull(); + expect(await splitDelimitedPathEntry("folder\\ with\\ spaces/file.txt packages/b.txt", tempDir)).toBeNull(); }); - it("treats `\\{a,b}` as an escaped brace, so the inner comma is still top-level", () => { - // Skip-next semantics: the backslash consumes the `{`, so braceDepth stays 0 - // and the unescaped `,` between `a` and `b` rejects. This pins the literal - // behavior of the new escape-skip, which intentionally does NOT model glob - // brace semantics — it only mirrors search.ts's containsTopLevelComma. - expect(() => validateFindPathInputs(["\\{a,b}"])).toThrow(/paths is an array/); + it("uses strong delimiters leniently and whitespace delimiters conservatively", async () => { + expect(await splitDelimitedPathEntry("missing.txt, packages/b.txt", tempDir)).toEqual([ + "missing.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("missing.txt;packages/b.txt", tempDir)).toEqual([ + "missing.txt", + "packages/b.txt", + ]); + expect(await splitDelimitedPathEntry("missing.txt packages/b.txt", tempDir)).toBeNull(); + }); + + it("cleans trailing strong delimiters and expands glob entries", async () => { + expect(await expandDelimitedPathEntries(["apps/a.txt,"], tempDir)).toEqual(["apps/a.txt"]); + expect( + await expandDelimitedPathEntries(["apps/**/*.txt, packages/**/*.txt"], tempDir, { + splitter: parseFindPattern, + }), + ).toEqual(["apps/**/*.txt", "packages/**/*.txt"]); }); }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index a3b16761b..d23a66c4e 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -154,6 +154,53 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/, packages/, phases/"); }); + it("search expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "search"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing search tool"); + + for (const [name, entry] of [ + ["comma", "apps/grep.txt, packages/grep.txt"], + ["semicolon", "apps/grep.txt;packages/grep.txt"], + ["space", "apps/grep.txt packages/grep.txt"], + ] as const) { + const result = await tool.execute(`search-delimited-${name}`, { + pattern: "shared-needle", + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toMatch(/^# apps\/\n## grep\.txt#[0-9A-F]{4}/m); + expect(text).toMatch(/^# packages\/\n## grep\.txt#[0-9A-F]{4}/m); + expect(text).not.toContain("phases"); + expect(text).not.toContain("other"); + expect(details?.fileCount).toBe(2); + expect(details?.scopePath).toBe("apps/grep.txt, packages/grep.txt"); + } + }); + + it("search keeps comma-delimited surviving entries when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "search"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing search tool"); + + const result = await tool.execute("search-delimited-missing", { + pattern: "shared-needle", + paths: ["missing.txt, packages/grep.txt"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined; + + expect(text).toMatch(/^¶packages\/grep\.txt#[0-9A-F]{4}/m); + expect(text).toContain("Skipped missing paths: missing.txt"); + expect(text).not.toContain("apps"); + expect(details?.fileCount).toBe(1); + expect(details?.missingPaths).toEqual(["missing.txt"]); + }); + it("records hashline snapshots for matched files", async () => { const session = createTestSession(tempDir); const tools = await createTools(session); @@ -404,6 +451,45 @@ describe("tool path arrays", () => { expect(await Bun.file(absoluteTarget).text()).toBe("written\n"); }); + it("read expands comma-delimited paths", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "read"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing read tool"); + + const result = await tool.execute("read-delimited", { + path: "apps/grep.txt, packages/grep.txt", + }); + const text = getText(result); + const details = result.details as { notes?: string[] } | undefined; + + expect(text).toContain("Note: interpreted as 2 paths: apps/grep.txt, packages/grep.txt"); + expect(text).toContain("shared-needle apps"); + expect(text).toContain("shared-needle packages"); + expect(details?.notes).toEqual(["Note: interpreted as 2 paths: apps/grep.txt, packages/grep.txt"]); + }); + + it("read keeps readable delimited paths when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "read"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing read tool"); + + const result = await tool.execute("read-delimited-missing", { + path: "missing.txt, packages/grep.txt", + }); + const text = getText(result); + const details = result.details as { notes?: string[] } | undefined; + + expect(text).toContain("Note: interpreted as 2 paths: missing.txt, packages/grep.txt"); + expect(text).toContain("shared-needle packages"); + expect(text).toContain("[Could not read missing.txt: Path 'missing.txt' not found]"); + expect(details?.notes).toEqual([ + "Note: interpreted as 2 paths: missing.txt, packages/grep.txt", + "Could not read missing.txt: Path 'missing.txt' not found", + ]); + }); + it("ast_grep accepts quoted path and glob filters", async () => { const tools = await createTools(createTestSession(tempDir)); const tool = tools.find(entry => entry.name === "ast_grep"); @@ -444,6 +530,33 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); }); + it("ast_grep expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "ast_grep"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing ast_grep tool"); + + for (const [name, entry] of [ + ["comma", "apps/**/*.ts, packages/**/*.ts"], + ["semicolon", "apps/**/*.ts;packages/**/*.ts"], + ["space", "apps/**/*.ts packages/**/*.ts"], + ] as const) { + const result = await tool.execute(`ast-grep-delimited-${name}`, { + pat: "providerOptions", + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string } | undefined; + + expect(text).toMatch(/^# apps\/\n## ast\.ts#[0-9A-F]{4}/m); + expect(text).toMatch(/^# packages\/\n## ast\.ts#[0-9A-F]{4}/m); + expect(text).not.toContain("# phases"); + expect(text).not.toContain("# other"); + expect(details?.fileCount).toBe(2); + expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts"); + } + }); + it("ast_edit applies across an explicit path array", async () => { const queue = new ToolChoiceQueue(); const tools = await createTools( @@ -518,6 +631,71 @@ describe("tool path arrays", () => { expect(details?.scopePath).toBe("apps/, packages/, phases/"); }); + it("find expands delimited path entries", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + for (const [name, entry] of [ + ["comma", "apps/grep.txt, packages/grep.txt"], + ["semicolon", "apps/grep.txt;packages/grep.txt"], + ["space", "apps/grep.txt packages/grep.txt"], + ] as const) { + const result = await tool.execute(`find-delimited-${name}`, { + paths: [entry], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined; + + expect(text).toMatch(/^# apps\/\ngrep\.txt$/m); + expect(text).toMatch(/^# packages\/\ngrep\.txt$/m); + expect(text).not.toContain("phases"); + expect(text).not.toContain("other"); + expect(details?.fileCount).toBe(2); + expect(details?.files).toEqual(expect.arrayContaining(["apps/grep.txt", "packages/grep.txt"])); + expect(details?.scopePath).toBe("apps/grep.txt, packages/grep.txt"); + } + }); + + it("find keeps comma-delimited surviving entries when peers are missing", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + const result = await tool.execute("find-delimited-missing", { + paths: ["missing.txt, packages/grep.txt"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; missingPaths?: string[]; files?: string[] } | undefined; + + expect(text).toMatch(/^# packages\/\ngrep\.txt$/m); + expect(text).toContain("Skipped missing paths: missing.txt"); + expect(text).not.toContain("apps"); + expect(details?.fileCount).toBe(1); + expect(details?.files).toEqual(["packages/grep.txt"]); + expect(details?.missingPaths).toEqual(["missing.txt"]); + }); + + it("find keeps a single path that contains spaces", async () => { + const tools = await createTools(createTestSession(tempDir)); + const tool = tools.find(entry => entry.name === "find"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("Missing find tool"); + + const result = await tool.execute("find-space-directory", { + paths: ["folder with spaces/"], + }); + const text = getText(result); + const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined; + + expect(text).toMatch(/^# folder with spaces\/\nnote\.txt$/m); + expect(details?.fileCount).toBe(1); + expect(details?.files).toEqual(["folder with spaces/note.txt"]); + expect(details?.scopePath).toBe("folder with spaces"); + }); + it("find accepts quoted directory patterns", async () => { const tools = await createTools(createTestSession(tempDir)); const tool = tools.find(entry => entry.name === "find"); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index cd4f02d5b..3d3c1c0ac 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [15.8.0] - 2026-06-02 + ### Fixed - Fixed hashline replacements that accidentally restated unchanged lines above and below the selected range so they no longer duplicate both boundary lines ([#1664](https://github.com/can1357/oh-my-pi/issues/1664)). diff --git a/packages/hashline/package.json b/packages/hashline/package.json index e22f4e14d..c915eaa60 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "15.7.6", + "version": "15.8.0", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 8f16aac3d..70fa7e5be 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "15.7.6", + "version": "15.8.0", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 7721e767f..f98ceca24 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -136,7 +136,7 @@ export declare class Shell { * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV15_7_6(): void +export declare function __piNativesV15_8_0(): void /** * Apply conservative pre-execution rewrites to a bash command. diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 349620f9c..ce76d3a4f 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -23,7 +23,7 @@ export const PtySession = nativeBindings.PtySession; export const Shell = nativeBindings.Shell; // functions -export const __piNativesV15_7_6 = nativeBindings.__piNativesV15_7_6; +export const __piNativesV15_8_0 = nativeBindings.__piNativesV15_8_0; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; diff --git a/packages/natives/package.json b/packages/natives/package.json index 50c327d97..9d7f07a04 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "15.7.6", + "version": "15.8.0", "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/stats/package.json b/packages/stats/package.json index c788c91c8..f62cfbcd8 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "15.7.6", + "version": "15.8.0", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json index 31875cbb7..c7ab1c2a1 100644 --- a/packages/swarm-extension/package.json +++ b/packages/swarm-extension/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/swarm-extension", - "version": "15.7.6", + "version": "15.8.0", "description": "Swarm orchestration extension for omp", "homepage": "https://omp.sh", "author": "Derek Rynd", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5badf2b23..053be2f15 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `CMUX_SURFACE_ID` environment variable support to `getTerminalId()`, so cmux terminal surfaces get a stable identifier alongside kitty, tmux, macOS Terminal.app, and Windows Terminal — enabling per-surface session breadcrumbs for `omp -c` in cmux. +## [15.8.0] - 2026-06-02 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 5b65e6244..f45e2cc1b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "15.7.6", + "version": "15.8.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/package.json b/packages/utils/package.json index e17b7af42..cba65bccf 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "15.7.6", + "version": "15.8.0", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/scripts/release.ts b/scripts/release.ts index bae15a4b3..95fb46b6a 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -10,6 +10,7 @@ */ import { $, Glob } from "bun"; +import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); @@ -305,6 +306,14 @@ async function cmdRelease(version: string): Promise { // 5. Update changelogs console.log("Updating CHANGELOGs..."); + const fixResult = await runChangelogFixer({ since: latestTag }); + for (const fixed of fixResult.changedFiles) { + console.log( + ` Fixed ${fixed.path}: ${fixed.promotedItems} promoted, ` + + `${fixed.mergedDuplicateHeadings} duplicate heading(s) merged, ` + + `${fixed.removedEmptyHeadings} empty heading(s) removed`, + ); + } await updateChangelogsForRelease(version); console.log();