From 338746596b405122a807186eb63a200b01705e93 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 01:02:55 +0200 Subject: [PATCH 01/33] fix(ai): isolate service-tier pricing test --- .../test/openai-responses-copilot-provider.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index 3a62ce58..39bc7ca6 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -258,6 +258,8 @@ describe("openai-responses provider defaults", () => { ["gpt-5.5", "flex", 0.5], ] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => { const model = getModel("openai", modelId); + const tokenCount = 100_000; + const tokenScale = tokenCount / 1_000_000; const sse = `${[ `data: ${JSON.stringify({ type: "response.completed", @@ -265,9 +267,9 @@ describe("openai-responses provider defaults", () => { status: "completed", service_tier: serviceTier, usage: { - input_tokens: 1000000, - output_tokens: 1000000, - total_tokens: 2000000, + input_tokens: tokenCount, + output_tokens: tokenCount, + total_tokens: tokenCount * 2, input_tokens_details: { cached_tokens: 0 }, }, }, @@ -292,8 +294,8 @@ describe("openai-responses provider defaults", () => { const result = await stream.result(); - expect(result.usage.cost.input).toBe(model.cost.input * multiplier); - expect(result.usage.cost.output).toBe(model.cost.output * multiplier); - expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier); + expect(result.usage.cost.input).toBe(model.cost.input * multiplier * tokenScale); + expect(result.usage.cost.output).toBe(model.cost.output * multiplier * tokenScale); + expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier * tokenScale); }); }); From 1775fe4c5277c54c9271c9cbe5e5f5afe4d61fae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 01:13:38 +0200 Subject: [PATCH 02/33] docs: audit unreleased changelogs --- packages/ai/CHANGELOG.md | 3 ++- packages/coding-agent/CHANGELOG.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 124f0f53..fdf95957 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,7 +11,8 @@ - Fixed post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)). - Fixed GPT-5.4 and GPT-5.5 long-context cost accounting while retaining the intentional 272K default context limit for models that require an explicit override. -- Fixed GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing. +- Fixed GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing, and removed the nonexistent bare `gpt-5.6` alias from the OpenAI and Azure OpenAI Responses catalogs. +- Fixed Anthropic message conversion to preserve thinking blocks with empty thinking text but a valid signature instead of dropping them, avoiding thinking-block errors on newer Claude models ([#6457](https://github.com/earendil-works/pi/pull/6457) by [@davidbrai](https://github.com/davidbrai)). ## [0.80.5] - 2026-07-09 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2d810f29..9aa7b4d3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,10 +2,23 @@ ## [Unreleased] +### New Features + +- **`max` thinking level** - New opt-in thinking level above `xhigh`, natively supported on GPT-5.6 and adaptive Claude models, available across CLI (`--thinking max`), SDK, RPC, and model selection. Custom themes can define `thinkingMax`. See [CLI Reference](docs/usage.md#cli-reference). +- **Input-based pricing tiers** - Request-wide input-token pricing tiers for accurate long-context cost accounting (e.g. GPT-5.4/5.5/5.6 long-context rates), also configurable for custom models in `models.json` and `modelOverrides`. See [Model Configuration](docs/models.md#model-configuration). + ### Added - Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`. - Added request-wide input-token pricing tiers to custom model costs in `models.json`, `modelOverrides`, and extension-registered providers. +- Added `~` (home directory) expansion for the `shellPath` setting ([#6470](https://github.com/earendil-works/pi/pull/6470) by [@aaronkyriesenbach](https://github.com/aaronkyriesenbach)). + +### Fixed + +- Fixed inherited post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)). +- Fixed inherited GPT-5.4 and GPT-5.5 long-context cost accounting while retaining the intentional 272K default context limit for models that require an explicit override. +- Fixed inherited GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing, and removed the nonexistent bare `gpt-5.6` alias. +- Fixed inherited Anthropic message conversion to preserve thinking blocks with empty thinking text but a valid signature instead of dropping them, avoiding thinking-block errors on newer Claude models ([#6457](https://github.com/earendil-works/pi/pull/6457) by [@davidbrai](https://github.com/davidbrai)). ## [0.80.5] - 2026-07-09 From 5b4bda305ef6b6962d121989795276a064f9b68f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 01:16:27 +0200 Subject: [PATCH 03/33] fix(ai): refresh generated model catalogs --- packages/ai/src/providers/openrouter.models.ts | 16 ++++++++-------- .../ai/src/providers/vercel-ai-gateway.models.ts | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index f2b0eebb..b28ebd0c 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -1878,9 +1878,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.65, + input: 0.66, output: 3.41, - cacheRead: 0.14, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, @@ -4687,13 +4687,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.54, - output: 1.76, - cacheRead: 0.1, + input: 0.532, + output: 1.672, + cacheRead: 0.0988, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 101376, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -4831,9 +4831,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.65, + input: 0.66, output: 3.41, - cacheRead: 0.14, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index 48027249..c0a901c8 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -945,7 +945,7 @@ export const VERCEL_AI_GATEWAY_MODELS = { cost: { input: 0.14, output: 0.28, - cacheRead: 0.0028, + cacheRead: 0.028, cacheWrite: 0, }, contextWindow: 1000000, @@ -1400,7 +1400,7 @@ export const VERCEL_AI_GATEWAY_MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 1.25, output: 4.25, From 2b3fda9921b5590f285165287bd442a25817f17b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 01:16:55 +0200 Subject: [PATCH 04/33] Release v0.80.6 --- package-lock.json | 30 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +-- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +-- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +-- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 30 +++++++++---------- .../coding-agent/install-lock/package.json | 4 +-- packages/coding-agent/npm-shrinkwrap.json | 24 +++++++-------- packages/coding-agent/package.json | 8 ++--- packages/orchestrator/CHANGELOG.md | 2 +- packages/orchestrator/package.json | 4 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 23 files changed, 72 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28b0ec85..1b9b01b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5115,10 +5115,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5467,7 +5467,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5773,12 +5773,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.5", - "@earendil-works/pi-ai": "^0.80.5", - "@earendil-works/pi-tui": "^0.80.5", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5819,32 +5819,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.80.5" + "version": "0.80.6" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.10.5", + "version": "1.10.6", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "ms": "2.1.3" }, @@ -6140,10 +6140,10 @@ }, "packages/orchestrator": { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "^0.80.5" + "@earendil-works/pi-coding-agent": "^0.80.6" }, "devDependencies": { "shx": "0.4.0" @@ -6154,7 +6154,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index fd0fffd0..2dd4c18a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 19e64831..d0f9d5af 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.80.5", + "version": "0.80.6", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fdf95957..025a35b6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 922cf19e..ce4613c1 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.80.5", + "version": "0.80.6", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9aa7b4d3..b9dae7f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.6] - 2026-07-09 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index d6fe2d6a..7c01a92d 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.80.5", + "version": "0.80.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index caefd5bd..7e6c7873 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.80.5", + "version": "0.80.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 835f915f..8d62dd7f 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.80.5", + "version": "0.80.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 9e3561ac..454fa63d 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.80.5", + "version": "0.80.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 6d576cb1..fe329ba6 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.80.5", + "version": "0.80.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index c304030a..e0c46cdd 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.10.5", + "version": "1.10.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.10.5", + "version": "1.10.6", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 83d158f1..4402a001 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.10.5", + "version": "1.10.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index dcda9e4e..54eaccd3 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.80.5", + "version": "0.80.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 1290b051..87bb49f3 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.80.5", + "version": "0.80.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 78783b65..4ddf19bb 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.5", + "version": "0.80.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.5", + "version": "0.80.6", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.5" + "@earendil-works/pi-coding-agent": "0.80.6" }, "engines": { "node": ">=22.19.0" @@ -450,11 +450,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -464,8 +464,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -488,13 +488,13 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.5", - "@earendil-works/pi-ai": "^0.80.5", - "@earendil-works/pi-tui": "^0.80.5", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -522,8 +522,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 1966da12..ba122ec4 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.5", + "version": "0.80.6", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.5" + "@earendil-works/pi-coding-agent": "0.80.6" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index a2198d59..6674bbbd 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.5", + "version": "0.80.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.5", + "version": "0.80.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.5", - "@earendil-works/pi-ai": "^0.80.5", - "@earendil-works/pi-tui": "^0.80.5", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.5", + "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -512,8 +512,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.5.tgz", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 00461aac..bce2edab 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.5", + "version": "0.80.6", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.5", - "@earendil-works/pi-ai": "^0.80.5", - "@earendil-works/pi-tui": "^0.80.5", + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index a1e5eca8..9be35cb1 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.6] - 2026-07-09 ## [0.80.5] - 2026-07-09 diff --git a/packages/orchestrator/package.json b/packages/orchestrator/package.json index 867086a4..6e9aa1dd 100644 --- a/packages/orchestrator/package.json +++ b/packages/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.5", + "version": "0.80.6", "description": "experimental orchestrator package for pi", "type": "module", "main": "./dist/index.js", @@ -37,7 +37,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-coding-agent": "^0.80.5" + "@earendil-works/pi-coding-agent": "^0.80.6" }, "devDependencies": { "shx": "0.4.0" diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ea9e4501..caf2dc3c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.6] - 2026-07-09 ## [0.80.5] - 2026-07-09 diff --git a/packages/tui/package.json b/packages/tui/package.json index e2a47ac9..27d790aa 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.80.5", + "version": "0.80.6", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 34582ef34beec868b0df4fb969385b8af5960c45 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 01:16:58 +0200 Subject: [PATCH 05/33] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/orchestrator/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 5 files changed, 10 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 2dd4c18a..f300d151 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 025a35b6..5bb1c42f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b9dae7f0..702f262a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.6] - 2026-07-09 ### New Features diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index 9be35cb1..f4bcedff 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.6] - 2026-07-09 ## [0.80.5] - 2026-07-09 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index caf2dc3c..c88cb18b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.6] - 2026-07-09 ## [0.80.5] - 2026-07-09 From 46145bef07a492a4b96e81ef24c0b5d0bececbf8 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 10 Jul 2026 09:51:53 +0200 Subject: [PATCH 06/33] fix openrouter models: use context length from top provider (#6481) fixes #6378 --- packages/ai/scripts/generate-models.ts | 4 +- .../ai/src/providers/openrouter.models.ts | 80 +++++++++---------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 64469182..22be7ecb 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -700,6 +700,8 @@ async function fetchOpenRouterModels(): Promise[]> { const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000); const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000); + const contextWindow = model.top_provider?.context_length || model.context_length || 4096; + const normalizedModel: Model = { id: modelKey, name: model.name, @@ -714,7 +716,7 @@ async function fetchOpenRouterModels(): Promise[]> { cacheRead: cacheReadCost, cacheWrite: cacheWriteCost, }, - contextWindow: model.context_length || 4096, + contextWindow, maxTokens: model.top_provider?.max_completion_tokens || 4096, }; models.push(normalizedModel); diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index b28ebd0c..5a1ed1ec 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -656,7 +656,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 128000, maxTokens: 16000, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3-0324": { @@ -710,7 +710,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, + contextWindow: 64000, maxTokens: 16000, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-0528": { @@ -759,12 +759,12 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.2288, - output: 0.3432, - cacheRead: 0.02288, + input: 0.2145, + output: 0.32175, + cacheRead: 0.02145, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { @@ -1018,7 +1018,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.2, cacheWrite: 0.375, }, - contextWindow: 1048756, + contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"openai-completions">, "google/gemini-3.5-flash": { @@ -1108,7 +1108,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it": { @@ -1343,7 +1343,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 65536, maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-4-maverick": { @@ -1379,7 +1379,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 10000000, + contextWindow: 327680, maxTokens: 16384, } satisfies Model<"openai-completions">, "minimax/minimax-m1": { @@ -1451,7 +1451,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.05, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 196608, maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { @@ -1469,7 +1469,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 196608, maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m3": { @@ -1487,7 +1487,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { @@ -1865,7 +1865,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.07, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.6": { @@ -2027,7 +2027,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1000000, + contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-super-120b-a12b:free": { @@ -2045,7 +2045,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1000000, + contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b": { @@ -2063,7 +2063,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 1000000, + contextWindow: 262144, maxTokens: 16384, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b:free": { @@ -3359,7 +3359,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 32768, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen-2.5-7b-instruct": { @@ -3377,7 +3377,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 32768, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen-plus": { @@ -3449,7 +3449,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131702, + contextWindow: 40960, maxTokens: 40960, } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b": { @@ -3503,7 +3503,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { @@ -3521,7 +3521,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 40960, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { @@ -3539,7 +3539,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 128000, maxTokens: 32000, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-thinking-2507": { @@ -3557,7 +3557,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 81920, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-32b": { @@ -3575,7 +3575,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 40960, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-8b": { @@ -3611,7 +3611,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3-coder-30b-a3b-instruct": { @@ -3701,7 +3701,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 262000, maxTokens: 262000, } satisfies Model<"openai-completions">, "qwen/qwen3-max": { @@ -3791,7 +3791,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-instruct": { @@ -3845,7 +3845,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-30b-a3b-thinking": { @@ -3881,7 +3881,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-instruct": { @@ -3899,7 +3899,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-thinking": { @@ -3917,7 +3917,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3.5-122b-a10b": { @@ -3989,7 +3989,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.111, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3.5-9b": { @@ -4079,7 +4079,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.15, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 262140, maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { @@ -4475,7 +4475,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.028, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 32000, maxTokens: 4096, } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5-pro": { @@ -4673,7 +4673,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.1794, cacheWrite: 0, }, - contextWindow: 202752, + contextWindow: 200000, maxTokens: 128000, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { @@ -4687,13 +4687,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.532, - output: 1.672, - cacheRead: 0.0988, + input: 0.92, + output: 3, + cacheRead: 0.18, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 131072, + maxTokens: 1048576, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", From 81de5702c6816538ea97d05d9060fe435b80bf35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 07:54:01 +0000 Subject: [PATCH 07/33] chore: approve contributor farid-fari --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f58d1fe7..8ecfbc43 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -265,3 +265,5 @@ DeviosLang pr HarrodRen pr aaronkyriesenbach pr + +farid-fari pr From bc469b03389135edf5d179ab7718c2085cdfd3a9 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 10 Jul 2026 11:42:53 +0200 Subject: [PATCH 08/33] add xhigh and max to all fable-5 providers (#6490) related to #6374 --- packages/ai/scripts/generate-models.ts | 5 +---- packages/ai/src/providers/github-copilot.models.ts | 1 + packages/ai/src/providers/openrouter.models.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 22be7ecb..2053b392 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -545,10 +545,7 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { xhigh: "xhigh", max: "max" }); } - if ( - (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") && - model.id.includes("fable-5") - ) { + if (model.id.includes("fable-5")) { mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh", max: "max" }); } if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { diff --git a/packages/ai/src/providers/github-copilot.models.ts b/packages/ai/src/providers/github-copilot.models.ts index 47a2f3d9..913974ee 100644 --- a/packages/ai/src/providers/github-copilot.models.ts +++ b/packages/ai/src/providers/github-copilot.models.ts @@ -13,6 +13,7 @@ export const GITHUB_COPILOT_MODELS = { headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { input: 10, diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 5a1ed1ec..d157f6fb 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -192,6 +192,7 @@ export const OPENROUTER_MODELS = { baseUrl: "https://openrouter.ai/api/v1", compat: {"thinkingFormat":"openrouter","cacheControlFormat":"anthropic"}, reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { input: 10, @@ -4687,13 +4688,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.92, - output: 3, - cacheRead: 0.18, + input: 0.84, + output: 2.64, + cacheRead: 0.156, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 1048576, + contextWindow: 1024000, + maxTokens: 128000, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", From 91585d9a3829831b07560901c4b3e9bbe3b4e35a Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 10 Jul 2026 16:34:43 +0200 Subject: [PATCH 09/33] bump bun to 1.3.14 (#6503) this supports BUN_CONFIG_HTTP_IDLE_TIMEOUT related to #6476 --- .github/workflows/build-binaries.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 9bab5e08..e77ab650 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -41,7 +41,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.10 + bun-version: 1.3.14 - name: Setup Node.js uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 From 3ea064ea2a0f01965923ce32e1bd17466c502b23 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 19:34:11 +0200 Subject: [PATCH 10/33] fix: support Bedrock API key login --- packages/ai/CHANGELOG.md | 4 ++ .../ai/src/api/bedrock-converse-stream.ts | 5 ++- packages/ai/src/providers/amazon-bedrock.ts | 6 ++- .../test/bedrock-endpoint-resolution.test.ts | 9 +++++ packages/ai/test/providers.test.ts | 13 ++++++ packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/providers.md | 3 ++ .../interactive/components/login-dialog.ts | 12 +++++- .../src/modes/interactive/interactive-mode.ts | 40 ++++--------------- .../5433-extension-oauth-prompt-input.test.ts | 12 ++++++ 10 files changed, 72 insertions(+), 36 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5bb1c42f..19dd4f22 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. + ## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 33494a9e..7a9e776e 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -152,7 +152,10 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = // Resolve bearer token for Bedrock API key auth. const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1"; const bearerToken = - options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined; + options.bearerToken || + options.apiKey || + getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || + undefined; const useBearerToken = bearerToken !== undefined && !skipAuth; // in Node.js/Bun environment only diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index d839ab6a..9d2e5918 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -9,7 +9,11 @@ import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.models.ts"; * configured. A stored credential key is surfaced as the bearer token. */ const bedrockAuth: ApiKeyAuth = { - name: "AWS credentials", + name: "Bedrock API key or AWS credentials", + login: async (callbacks) => ({ + type: "api_key", + key: await callbacks.prompt({ type: "secret", message: "Enter Bedrock API key" }), + }), resolve: async ({ ctx, credential }) => { if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" }; if (await ctx.env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" }; diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 168cf4d1..0fef3662 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -181,4 +181,13 @@ describe("bedrock endpoint resolution", () => { expect(config.region).toBe("us-gov-west-1"); }); + + it("uses the generic API key option as a Bedrock bearer token", async () => { + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + + const config = await captureClientConfig(model, { apiKey: "bedrock-api-key" }); + + expect(config.token).toEqual({ token: "bedrock-api-key" }); + expect(config.authSchemePreference).toEqual(["httpBearerAuth"]); + }); }); diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index 0f6e25bd..a22c8abd 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -54,6 +54,19 @@ describe("builtin providers", () => { expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN"); }); + it("prompts for and stores a Bedrock API key", async () => { + const provider = amazonBedrockProvider(); + const credential = await provider.auth.apiKey?.login?.({ + prompt: async (prompt) => { + expect(prompt).toEqual({ type: "secret", message: "Enter Bedrock API key" }); + return "bedrock-api-key"; + }, + notify: () => {}, + }); + + expect(credential).toEqual({ type: "api_key", key: "bedrock-api-key" }); + }); + it("reports bedrock as configured from ambient AWS credentials without an api key", async () => { const models = createModels({ authContext: fakeAuthContext({ AWS_PROFILE: "dev" }) }); models.setProvider(amazonBedrockProvider()); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 702f262a..933c6f5c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. + ## [0.80.6] - 2026-07-09 ### New Features diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 46163abf..74d228a0 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -55,6 +55,7 @@ pi | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | | NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` | | Google Gemini | `GEMINI_API_KEY` | `google` | +| Amazon Bedrock | `AWS_BEARER_TOKEN_BEDROCK` | `amazon-bedrock` | | Mistral | `MISTRAL_API_KEY` | `mistral` | | Groq | `GROQ_API_KEY` | `groq` | | Cerebras | `CEREBRAS_API_KEY` | `cerebras` | @@ -170,6 +171,8 @@ export AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-4=my-gpt4,gpt-4o=my-gpt4o ### Amazon Bedrock +Use `/login amazon-bedrock` to store a Bedrock API key, or configure one of the ambient AWS credential sources below: + ```bash # Option 1: AWS Profile export AWS_PROFILE=your-profile diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 3fc4b516..44ae7498 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -177,14 +177,22 @@ export class LoginDialogComponent extends Container implements Focusable { } /** - * Show informational text without prompting for input. + * Show informational text before another login step. */ - showInfo(lines: string[]): void { + showDetails(lines: string[]): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); for (const line of lines) { this.contentContainer.addChild(new Text(line, 1, 0)); } + this.tui.requestRender(); + } + + /** + * Show informational text without prompting for input. + */ + showInfo(lines: string[]): void { + this.showDetails(lines); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0)); this.tui.requestRender(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8f492a18..605bfbf1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -248,8 +248,6 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } -const BEDROCK_PROVIDER_ID = "amazon-bedrock"; - const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); export function isApiKeyLoginProvider( @@ -4873,8 +4871,6 @@ export class InteractiveMode { private async startProviderLogin(providerOption: AuthSelectorProvider): Promise { if (providerOption.authType === "oauth") { await this.showLoginDialog(providerOption.id, providerOption.name); - } else if (providerOption.id === BEDROCK_PROVIDER_ID) { - this.showBedrockSetupDialog(providerOption.id, providerOption.name); } else { await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); } @@ -5082,34 +5078,6 @@ export class InteractiveMode { } } - private showBedrockSetupDialog(providerId: string, providerName: string): void { - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - - const dialog = new LoginDialogComponent( - this.ui, - providerId, - () => restoreEditor(), - providerName, - "Amazon Bedrock setup", - ); - dialog.showInfo([ - theme.fg("text", "Amazon Bedrock uses AWS credentials instead of a single API key."), - theme.fg("text", "Configure an AWS profile, IAM keys, bearer token, or role-based credentials."), - theme.fg("muted", "See:"), - theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`), - ]); - - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - } - private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise { const previousModel = this.session.model; @@ -5122,6 +5090,14 @@ export class InteractiveMode { providerName, ); + if (providerId === "amazon-bedrock") { + dialog.showDetails([ + theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."), + theme.fg("muted", "See:"), + theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`), + ]); + } + this.editorContainer.clear(); this.editorContainer.addChild(dialog); this.ui.setFocus(dialog); diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts index 06562b3d..54dcdd3b 100644 --- a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -70,6 +70,18 @@ describe("LoginDialogComponent OAuth prompts", () => { expect(output).toContain("First prompt:"); }); + test("preserves setup details when showing a prompt", () => { + const dialog = createDialog(); + + dialog.showDetails(["AWS credential setup:", "providers.md"]); + dialog.showPrompt("Enter API key:"); + + const output = renderDialog(dialog).join("\n"); + expect(output).toContain("AWS credential setup:"); + expect(output).toContain("providers.md"); + expect(output).toContain("Enter API key:"); + }); + test("keeps previous manual input stable when a later prompt is active", async () => { const dialog = createDialog(); From 3b686ac224db0eb24cadb6fd0149db94c6aa1854 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 20:36:30 +0200 Subject: [PATCH 11/33] feat(coding-agent): add message copy shortcut --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/README.md | 2 + packages/coding-agent/docs/keybindings.md | 1 + packages/coding-agent/docs/usage.md | 1 + .../src/core/extensions/runner.ts | 1 + packages/coding-agent/src/core/keybindings.ts | 9 ++- .../interactive/components/tree-selector.ts | 63 +++++++++++++++---- .../src/modes/interactive/interactive-mode.ts | 15 +++++ .../coding-agent/test/tree-selector.test.ts | 23 +++++++ 9 files changed, 106 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 933c6f5c..82b38058 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`. + ### Fixed - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index dc69b4d2..ac176dfc 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -212,6 +212,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`. | Shift+Tab | Cycle thinking level | | Ctrl+O | Collapse/expand tool output | | Ctrl+T | Collapse/expand thinking blocks | +| Ctrl+X | Copy the last assistant message | ### Message Queue @@ -255,6 +256,7 @@ Use `/session` in interactive mode to see the current session ID before reusing - Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all +- Press Ctrl+X to copy the selected message - Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps **`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification. diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 0bb0f492..08a58150 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -119,6 +119,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | Keybinding id | Default | Description | |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | +| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` | | `app.message.followUp` | `alt+enter` | Queue follow-up message | | `app.message.dequeue` | `alt+up` | Restore queued messages to editor | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 87fc0545..49b41927 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -22,6 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by | File reference | Type `@` to fuzzy-search project files | | Path completion | Press Tab to complete paths | | Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal | +| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message | | Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal | | Shell command | `!command` runs and sends output to the model | | Hidden shell command | `!!command` runs without sending output to the model | diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 2a699a7d..8dcddef3 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -78,6 +78,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.tools.expand", "app.thinking.toggle", "app.editor.external", + "app.message.copy", "app.message.followUp", "tui.input.submit", "tui.select.confirm", diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 8231d9ef..0e0bd443 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -23,6 +23,7 @@ export interface AppKeybindings { "app.thinking.toggle": true; "app.session.toggleNamedFilter": true; "app.editor.external": true; + "app.message.copy": true; "app.message.followUp": true; "app.message.dequeue": true; "app.clipboard.pasteImage": true; @@ -95,6 +96,10 @@ export const KEYBINDINGS = { defaultKeys: "ctrl+g", description: "Open external editor", }, + "app.message.copy": { + defaultKeys: "ctrl+x", + description: "Copy message to clipboard", + }, "app.message.followUp": { defaultKeys: "alt+enter", description: "Queue follow-up message", @@ -112,11 +117,11 @@ export const KEYBINDINGS = { "app.session.fork": { defaultKeys: [], description: "Fork current session" }, "app.session.resume": { defaultKeys: [], description: "Resume a session" }, "app.tree.foldOrUp": { - defaultKeys: ["ctrl+left", "alt+left"], + defaultKeys: process.platform === "darwin" ? ["alt+left", "ctrl+left"] : ["ctrl+left", "alt+left"], description: "Fold tree branch or move up", }, "app.tree.unfoldOrDown": { - defaultKeys: ["ctrl+right", "alt+right"], + defaultKeys: process.platform === "darwin" ? ["alt+right", "ctrl+right"] : ["ctrl+right", "alt+right"], description: "Unfold tree branch or move down", }, "app.tree.editLabel": { diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 8bcdc2fb..f70e2060 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -122,6 +122,7 @@ class TreeList implements Component { public onSelect?: (entryId: string) => void; public onCancel?: () => void; + public onCopy?: (text: string | undefined) => void; public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; constructor( @@ -623,6 +624,11 @@ class TreeList implements Component { return this.filteredNodes[this.selectedIndex]?.node; } + copySelected(): void { + const node = this.getSelectedNode(); + this.onCopy?.(node ? this.getEntryCopyText(node) : undefined); + } + updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void { for (const flatNode of this.flatNodes) { if (flatNode.node.entry.id === entryId) { @@ -871,19 +877,49 @@ class TreeList implements Component { } private extractContent(content: unknown): string { - const maxLen = 200; - if (typeof content === "string") return content.slice(0, maxLen); - if (Array.isArray(content)) { - let result = ""; - for (const c of content) { - if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { - result += (c as { text: string }).text; - if (result.length >= maxLen) return result.slice(0, maxLen); - } + return this.extractFullContent(content).slice(0, 200); + } + + private extractFullContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + let result = ""; + for (const block of content) { + if (typeof block === "object" && block !== null && "type" in block && block.type === "text") { + result += (block as { text: string }).text; } - return result; } - return ""; + return result; + } + + private getEntryCopyText(node: SessionTreeNode): string | undefined { + const entry = node.entry; + let text: string | undefined; + + switch (entry.type) { + case "message": + if (entry.message.role === "bashExecution") { + text = entry.message.command; + } else if ("content" in entry.message) { + text = this.extractFullContent(entry.message.content); + if (!text && entry.message.role === "assistant") { + text = entry.message.errorMessage; + } + } + break; + case "custom_message": + text = this.extractFullContent(entry.content); + break; + case "compaction": + text = entry.summary; + break; + case "branch_summary": + text = entry.summary; + break; + } + + return text?.trim() ? text : undefined; } private hasTextContent(content: unknown): boolean { @@ -990,6 +1026,8 @@ class TreeList implements Component { if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } + } else if (kb.matches(keyData, "app.message.copy")) { + this.copySelected(); } else if (kb.matches(keyData, "tui.select.cancel")) { if (this.searchQuery) { this.searchQuery = ""; @@ -1180,6 +1218,7 @@ const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: b { keys: ["tui.select.up", "tui.select.down"], label: "move" }, { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" }, { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" }, + { keys: ["app.message.copy"], label: "copy" }, { keys: ["app.tree.editLabel"], label: "label" }, { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" }, { @@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable { private labelInputContainer: Container; private treeContainer: Container; private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void; + public onCopy?: (text: string | undefined) => void; // Focusable implementation - propagate to labelInput when active for IME cursor positioning private _focused = false; @@ -1324,6 +1364,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode); this.treeList.onSelect = onSelect; this.treeList.onCancel = onCancel; + this.treeList.onCopy = (text) => this.onCopy?.(text); this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel); this.treeContainer = new Container(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 605bfbf1..1d6864e1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2583,6 +2583,7 @@ export class InteractiveMode { this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); + this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); @@ -4708,6 +4709,18 @@ export class InteractiveMode { initialSelectedId, initialFilterMode, ); + selector.onCopy = async (text) => { + if (!text) { + this.showError("Selected entry has no text to copy"); + return; + } + try { + await copyToClipboard(text); + this.showStatus("Copied selected message to clipboard"); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }; return { component: selector, focus: selector }; }); } @@ -5735,6 +5748,7 @@ export class InteractiveMode { const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); + const copyMessage = this.getAppKeyDisplay("app.message.copy"); const followUp = this.getAppKeyDisplay("app.message.followUp"); const dequeue = this.getAppKeyDisplay("app.message.dequeue"); const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); @@ -5778,6 +5792,7 @@ export class InteractiveMode { | \`${expandTools}\` | Toggle tool output expansion | | \`${toggleThinking}\` | Toggle thinking block visibility | | \`${externalEditor}\` | Edit message in external editor | +| \`${copyMessage}\` | Copy last assistant message | | \`${followUp}\` | Queue follow-up message | | \`${dequeue}\` | Restore queued messages | | \`${pasteImage}\` | Paste image from clipboard | diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 4281b986..9dc750d5 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -264,6 +264,7 @@ describe("TreeSelectorComponent", () => { const plainLines = selector.render(30).map(stripVTControlCharacters); const plain = plainLines.join("\n"); expect(plain).toContain("branch"); + expect(plain).toContain("copy"); expect(plain).toContain("filters"); expect(plain).toContain("cycle"); expect(plain).toContain("label time"); @@ -272,6 +273,28 @@ describe("TreeSelectorComponent", () => { }); }); + describe("copy", () => { + test("copies the full selected message with ctrl+x", () => { + const message = `${"long message ".repeat(30)}\nsecond line`; + const tree = buildTree([userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", message)]); + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + let copied: string | undefined; + selector.onCopy = (text) => { + copied = text; + }; + + selector.handleInput("\x18"); + + expect(copied).toBe(message); + }); + }); + describe("label timestamps", () => { test("toggles label timestamps for labeled nodes", () => { const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; From d7a48d30a031f9c26eb304ead89a0dd1fb424b8f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 21:46:15 +0200 Subject: [PATCH 12/33] fix(coding-agent): fall back to text clipboard paste --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/quickstart.md | 2 +- packages/coding-agent/src/core/keybindings.ts | 2 +- .../interactive/components/custom-editor.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 38 ++++++++++--------- .../src/utils/clipboard-native.ts | 1 + packages/coding-agent/src/utils/clipboard.ts | 14 +++++++ .../test/clipboard-native.test.ts | 1 + packages/coding-agent/test/clipboard.test.ts | 20 +++++++++- 10 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82b38058..0010ff44 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. ## [0.80.6] - 2026-07-09 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ac176dfc..b9a7911a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -162,7 +162,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or | Path completion | Tab to complete paths | | Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) | | External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere | -| Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal | +| Clipboard | Ctrl+V to paste an image or text (Alt+V on Windows), or drag images onto terminal | | Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending | Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md). diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 59026738..89314336 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -113,7 +113,7 @@ pi @README.md "Summarize this" pi @src/app.ts @src/app.test.ts "Review these together" ``` -Images can be pasted with Ctrl+V (Alt+V on Windows) or dragged into supported terminals. +Images or text can be pasted with Ctrl+V (Alt+V on Windows); images can also be dragged into supported terminals. ### Run shell commands diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 0e0bd443..ec5b135d 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -110,7 +110,7 @@ export const KEYBINDINGS = { }, "app.clipboard.pasteImage": { defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", - description: "Paste image from clipboard", + description: "Paste image from clipboard (text fallback)", }, "app.session.new": { defaultKeys: [], description: "Start a new session" }, "app.session.tree": { defaultKeys: [], description: "Open session tree" }, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index a9d25622..92c20b5c 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -33,7 +33,7 @@ export class CustomEditor extends Editor { return; } - // Check for paste image keybinding + // Check for clipboard paste keybinding if (this.keybindings.matches(data, "app.clipboard.pasteImage")) { this.onPasteImage?.(); return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1d6864e1..b745c454 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -95,7 +95,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; -import { copyToClipboard } from "../../utils/clipboard.ts"; +import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; @@ -770,7 +770,7 @@ export class InteractiveMode { rawKeyHint("!!", "to run bash (no context)"), hint("app.message.followUp", "to queue follow-up"), hint("app.message.dequeue", "to edit all queued messages"), - hint("app.clipboard.pasteImage", "to paste image"), + hint("app.clipboard.pasteImage", "to paste image (with text fallback)"), rawKeyHint("drop files", "to attach"), ].join("\n"); const compactInstructions = [ @@ -2599,29 +2599,33 @@ export class InteractiveMode { } }; - // Handle clipboard image paste (triggered on Ctrl+V) + // Handle clipboard paste (triggered on Ctrl+V). Images are attached by path; + // otherwise, paste plain text from the system clipboard. this.defaultEditor.onPasteImage = () => { - this.handleClipboardImagePaste(); + void this.handleClipboardPaste(); }; } - private async handleClipboardImagePaste(): Promise { + private async handleClipboardPaste(): Promise { try { const image = await readClipboardImage(); - if (!image) { + if (image) { + const tmpDir = os.tmpdir(); + const ext = extensionForImageMimeType(image.mimeType) ?? "png"; + const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; + const filePath = path.join(tmpDir, fileName); + fs.writeFileSync(filePath, Buffer.from(image.bytes)); + + this.editor.insertTextAtCursor?.(filePath); + this.ui.requestRender(); return; } - // Write to temp file - const tmpDir = os.tmpdir(); - const ext = extensionForImageMimeType(image.mimeType) ?? "png"; - const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; - const filePath = path.join(tmpDir, fileName); - fs.writeFileSync(filePath, Buffer.from(image.bytes)); - - // Insert file path directly - this.editor.insertTextAtCursor?.(filePath); - this.ui.requestRender(); + const text = await readClipboardText(); + if (text) { + this.editor.insertTextAtCursor?.(text); + this.ui.requestRender(); + } } catch { // Silently ignore clipboard errors (may not have permission, etc.) } @@ -5795,7 +5799,7 @@ export class InteractiveMode { | \`${copyMessage}\` | Copy last assistant message | | \`${followUp}\` | Queue follow-up message | | \`${dequeue}\` | Restore queued messages | -| \`${pasteImage}\` | Paste image from clipboard | +| \`${pasteImage}\` | Paste image or text from clipboard | | \`/\` | Slash commands | | \`!\` | Run bash command | | \`!!\` | Run bash command (excluded from context) | diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts index f8aeb547..7ea921ad 100644 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ b/packages/coding-agent/src/utils/clipboard-native.ts @@ -3,6 +3,7 @@ import { dirname, join } from "path"; import { pathToFileURL } from "url"; export type ClipboardModule = { + getText: () => Promise; setText: (text: string) => Promise; hasImage: () => boolean; getImageBinary: () => Promise>; diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 26e1d018..9bb10d5c 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -32,6 +32,20 @@ function emitOsc52(text: string): boolean { return true; } +/** Read plain text from the system clipboard, if native clipboard access is available. */ +export async function readClipboardText(): Promise { + if (!clipboard) { + return null; + } + + try { + const text = await clipboard.getText(); + return text || null; + } catch { + return null; + } +} + export async function copyToClipboard(text: string): Promise { let copied = false; diff --git a/packages/coding-agent/test/clipboard-native.test.ts b/packages/coding-agent/test/clipboard-native.test.ts index a9c0c201..d3ec209d 100644 --- a/packages/coding-agent/test/clipboard-native.test.ts +++ b/packages/coding-agent/test/clipboard-native.test.ts @@ -4,6 +4,7 @@ import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboar type ClipboardRequire = (id: string) => unknown; const fakeClipboard: ClipboardModule = { + getText: async () => "", setText: async () => {}, hasImage: () => true, getImageBinary: async () => [1, 2, 3], diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index 6ea3a685..c3089a39 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -1,11 +1,12 @@ import { execSync, spawn } from "child_process"; import { platform } from "os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { copyToClipboard } from "../src/utils/clipboard.ts"; +import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts"; const mocks = vi.hoisted(() => { return { clipboard: { + getText: vi.fn<() => Promise>(), setText: vi.fn<(text: string) => Promise>(), }, execSync: vi.fn(), @@ -59,6 +60,7 @@ beforeEach(() => { vi.stubEnv("MOSH_CONNECTION", ""); stdoutWrites = []; nativeResolved = false; + mocks.clipboard.getText.mockReset(); mocks.clipboard.setText.mockReset(); mocks.execSync.mockReset(); mocks.spawn.mockReset(); @@ -66,6 +68,7 @@ beforeEach(() => { mocks.isWaylandSession.mockReset(); mockedPlatform.mockReturnValue("darwin"); mocks.isWaylandSession.mockReturnValue(false); + mocks.clipboard.getText.mockResolvedValue(""); mocks.clipboard.setText.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 1)); nativeResolved = true; @@ -86,6 +89,21 @@ afterEach(() => { vi.unstubAllEnvs(); }); +describe("readClipboardText", () => { + test("returns native clipboard text", async () => { + mocks.clipboard.getText.mockResolvedValue("clipboard text"); + + await expect(readClipboardText()).resolves.toBe("clipboard text"); + }); + + test("returns null for empty or unavailable clipboard text", async () => { + await expect(readClipboardText()).resolves.toBeNull(); + + mocks.clipboard.getText.mockRejectedValue(new Error("clipboard unavailable")); + await expect(readClipboardText()).resolves.toBeNull(); + }); +}); + describe("copyToClipboard", () => { test("local native success skips OSC 52 and shell fallbacks", async () => { await copyToClipboard("hello"); From 3d8f74357c169d24f996a1611ecc4be72b7744bd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 10 Jul 2026 23:52:54 +0200 Subject: [PATCH 13/33] feat(ai): support message-anchored tool loading (#6474) This adds cache-friendly dynamic tool loading anchored to tool results. Purely additive active-tool changes are recorded with `addedToolNames`, allowing supported Anthropic and OpenAI Responses models to load tool definitions at the point they become available instead of placing them in the cached prompt prefix. It retains safe fallback behavior for unsupported models and non-additive changes but it will wipe caches. --- packages/agent/src/agent-loop.ts | 2 + packages/agent/src/types.ts | 2 + packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 29 +- packages/ai/src/api/anthropic-messages.ts | 139 +++++-- packages/ai/src/api/openai-codex-responses.ts | 7 +- .../ai/src/api/openai-responses-shared.ts | 52 ++- packages/ai/src/api/openai-responses.ts | 13 +- .../ai/src/providers/openai-codex.models.ts | 6 + packages/ai/src/providers/openai.models.ts | 7 + packages/ai/src/types.ts | 16 +- packages/ai/src/utils/deferred-tools.ts | 39 ++ packages/ai/src/utils/estimate.ts | 29 +- packages/ai/test/deferred-tools.test.ts | 387 ++++++++++++++++++ packages/coding-agent/docs/extensions.md | 138 +++++++ .../src/core/extensions/runner.ts | 5 + .../src/core/extensions/wrapper.ts | 27 +- .../coding-agent/src/core/model-registry.ts | 2 + ...2-extension-active-tools-next-turn.test.ts | 56 +++ 19 files changed, 894 insertions(+), 63 deletions(-) create mode 100644 packages/ai/src/utils/deferred-tools.ts create mode 100644 packages/ai/test/deferred-tools.test.ts diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 795a8d59..14eb9f6e 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -734,6 +734,7 @@ async function finalizeExecutedToolCall( ); if (afterResult) { result = { + ...result, content: afterResult.content ?? result.content, details: afterResult.details ?? result.details, terminate: afterResult.terminate ?? result.terminate, @@ -779,6 +780,7 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul // so the null never enters session history or provider payloads. content: finalized.result.content ?? [], details: finalized.result.details, + ...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}), isError: finalized.isError, timestamp: Date.now(), }; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 37ad9790..2de50144 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -352,6 +352,8 @@ export interface AgentToolResult { content: (TextContent | ImageContent)[]; /** Arbitrary structured details for logs or UI rendering. */ details: T; + /** Names of tools introduced by this result and available from this transcript point onward. */ + addedToolNames?: string[]; /** * Hint that the agent should stop after the current tool batch. * Early termination only happens when every finalized tool result in the batch sets this to true. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 19dd4f22..f6dc17b9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added +- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally. - Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only. - Added request-wide input-token pricing tiers to model cost metadata and usage cost calculation. diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 2053b392..e6e5e199 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -9,7 +9,14 @@ import { CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL, CLOUDFLARE_WORKERS_AI_BASE_URL, } from "../src/api/cloudflare.ts"; -import type { AnthropicMessagesCompat, Api, KnownProvider, Model, OpenAICompletionsCompat } from "../src/types.ts"; +import type { + AnthropicMessagesCompat, + Api, + KnownProvider, + Model, + OpenAICompletionsCompat, + OpenAIResponsesCompat, +} from "../src/types.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -194,6 +201,15 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = { } as const; const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]); +const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-pro", + "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", +]); const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([ "gpt-5.4", @@ -482,6 +498,16 @@ function applyOpenAICompletionsCompatMetadata(model: Model): void { } } +function applyOpenAIToolSearchMetadata(model: Model): void { + const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; + const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; + if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return; + model.compat = { + ...(model.compat as OpenAIResponsesCompat | undefined), + supportsToolSearch: true, + }; +} + function isGemini3ProModel(modelId: string): boolean { return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); } @@ -2178,6 +2204,7 @@ async function generateModels() { for (const model of allModels) { applyThinkingLevelMetadata(model); applyOpenAICompletionsCompatMetadata(model); + applyOpenAIToolSearchMetadata(model); } // Group by provider and deduplicate by model ID diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b2c6a220..e5dc8bc6 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -29,6 +29,7 @@ import type { ToolCall, ToolResultMessage, } from "../types.ts"; +import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; @@ -177,9 +178,24 @@ function getAnthropicCompat( supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true, supportsTemperature: model.compat?.supportsTemperature ?? true, allowEmptySignature: model.compat?.allowEmptySignature ?? false, + supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model), }; } +/** + * Default for `supportsToolReferences`: first-party Anthropic models except + * Haiku (rejects client-side tool_reference blocks) and models that predate + * tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1). + */ +function defaultSupportsToolReferences(model: Model<"anthropic-messages">): boolean { + if (model.provider !== "anthropic" || model.id.includes("haiku")) return false; + const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/); + if (!version) return false; + const major = Number(version[1]); + const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0; + return major > 4 || (major === 4 && minor >= 5); +} + export interface AnthropicOptions extends StreamOptions { /** * Enable extended thinking. @@ -907,9 +923,30 @@ function buildParams( ): MessageCreateParamsStreaming { const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); const compat = getAnthropicCompat(model); + const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); + const normalizeToolName = isOAuthToken ? toClaudeCodeName : (name: string) => name; + const toolPlacement = splitDeferredTools( + { ...context, messages: transformedMessages }, + compat.supportsToolReferences, + normalizeToolName, + ); + let immediateTools = toolPlacement.immediate; + let deferredTools = [...toolPlacement.deferred.values()]; + if (immediateTools.length === 0 && deferredTools.length > 0) { + immediateTools = deferredTools; + deferredTools = []; + } + const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name))); const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertMessages(context.messages, model, isOAuthToken, cacheControl, compat.allowEmptySignature), + messages: convertMessages( + transformedMessages, + isOAuthToken, + cacheControl, + compat.allowEmptySignature, + deferredToolNames, + normalizeToolName, + ), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -946,13 +983,16 @@ function buildParams( params.temperature = options.temperature; } - if (context.tools && context.tools.length > 0) { - params.tools = convertTools( - context.tools, - isOAuthToken, - compat.supportsEagerToolInputStreaming, - compat.supportsCacheControlOnTools ? cacheControl : undefined, - ); + if (immediateTools.length > 0 || deferredTools.length > 0) { + params.tools = [ + ...convertTools( + immediateTools, + isOAuthToken, + compat.supportsEagerToolInputStreaming, + compat.supportsCacheControlOnTools ? cacheControl : undefined, + ), + ...convertTools(deferredTools, isOAuthToken, compat.supportsEagerToolInputStreaming, undefined, true), + ]; } // Configure thinking mode: adaptive, budget-based, or explicitly disabled. @@ -1009,17 +1049,51 @@ function normalizeToolCallId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); } +function convertToolResult( + msg: ToolResultMessage, + isOAuthToken: boolean, + deferredToolNames: ReadonlySet, + loadedToolNames: Set, + normalizeToolName: (name: string) => string, +): { toolResult: ContentBlockParam; siblingContent: ContentBlockParam[] } { + const references: Array<{ type: "tool_reference"; tool_name: string }> = []; + for (const name of msg.addedToolNames ?? []) { + const normalizedName = normalizeToolName(name); + if (!deferredToolNames.has(normalizedName) || loadedToolNames.has(normalizedName)) continue; + loadedToolNames.add(normalizedName); + references.push({ + type: "tool_reference", + tool_name: isOAuthToken ? toClaudeCodeName(name) : name, + }); + } + const convertedContent = convertContentBlocks(msg.content); + // Anthropic rejects tool references mixed with ordinary tool-result content. + return { + toolResult: { + type: "tool_result", + tool_use_id: msg.toolCallId, + content: references.length > 0 ? references : convertedContent, + is_error: msg.isError, + }, + siblingContent: + references.length === 0 + ? [] + : typeof convertedContent === "string" + ? [{ type: "text", text: convertedContent }] + : convertedContent, + }; +} + function convertMessages( - messages: Message[], - model: Model<"anthropic-messages">, + transformedMessages: Message[], isOAuthToken: boolean, cacheControl?: CacheControlEphemeral, allowEmptySignature = false, + deferredToolNames: ReadonlySet = new Set(), + normalizeToolName: (name: string) => string = (name) => name, ): MessageParam[] { const params: MessageParam[] = []; - - // Transform messages for cross-provider compatibility - const transformedMessages = transformMessages(messages, model, normalizeToolCallId); + const loadedToolNames = new Set(); for (let i = 0; i < transformedMessages.length; i++) { const msg = transformedMessages[i]; @@ -1122,37 +1196,30 @@ function convertMessages( content: blocks, }); } else if (msg.role === "toolResult") { - // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint + // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint. const toolResults: ContentBlockParam[] = []; - - // Add the current tool result - toolResults.push({ - type: "tool_result", - tool_use_id: msg.toolCallId, - content: convertContentBlocks(msg.content), - is_error: msg.isError, - }); - - // Look ahead for consecutive toolResult messages - let j = i + 1; + const siblingContent: ContentBlockParam[] = []; + let j = i; while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { - const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult - toolResults.push({ - type: "tool_result", - tool_use_id: nextMsg.toolCallId, - content: convertContentBlocks(nextMsg.content), - is_error: nextMsg.isError, - }); + const converted = convertToolResult( + transformedMessages[j] as ToolResultMessage, + isOAuthToken, + deferredToolNames, + loadedToolNames, + normalizeToolName, + ); + toolResults.push(converted.toolResult); + siblingContent.push(...converted.siblingContent); j++; } - // Skip the messages we've already processed + // Skip the messages we've already processed. i = j - 1; - // Add a single user message with all tool results + // Displaced reference-bearing results must follow every tool_result block. params.push({ role: "user", - content: toolResults, + content: [...toolResults, ...siblingContent], }); } } @@ -1193,6 +1260,7 @@ function convertTools( isOAuthToken: boolean, supportsEagerToolInputStreaming: boolean, cacheControl?: CacheControlEphemeral, + deferLoading = false, ): Anthropic.Messages.Tool[] { if (!tools) return []; @@ -1208,6 +1276,7 @@ function convertTools( properties: schema.properties ?? {}, required: schema.required ?? [], }, + ...(deferLoading ? { defer_loading: true } : {}), ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}), }; }); diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 772d1ae8..3d1b27f7 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -36,6 +36,7 @@ import type { Usage, } from "../types.ts"; import { combineAbortSignals } from "../utils/abort-signals.ts"; +import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic, @@ -481,8 +482,10 @@ function buildRequestBody( context: Context, options?: OpenAICodexResponsesOptions, ): RequestBody { + const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false); const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { includeSystemPrompt: false, + deferredTools: toolPlacement.deferred, }); const body: RequestBody = { @@ -506,8 +509,8 @@ function buildRequestBody( body.service_tier = options.serviceTier; } - if (context.tools && context.tools.length > 0) { - body.tools = convertResponsesTools(context.tools, { strict: null }); + if (toolPlacement.immediate.length > 0) { + body.tools = convertResponsesTools(toolPlacement.immediate, { strict: null }); } if (options?.reasoningEffort !== undefined) { diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index f8e4df7c..246ae22a 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -6,11 +6,13 @@ import type { ResponseInput, ResponseInputContent, ResponseInputImage, + ResponseInputItem, ResponseInputText, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseStreamEvent, + ResponseToolSearchOutputItemParam, } from "openai/resources/responses/responses.js"; import { calculateCost } from "../models.ts"; import type { @@ -77,12 +79,16 @@ export interface OpenAIResponsesStreamOptions { export interface ConvertResponsesMessagesOptions { includeSystemPrompt?: boolean; + deferredTools?: ReadonlyMap; } export interface ConvertResponsesToolsOptions { strict?: boolean | null; + deferLoading?: boolean; } +type OpenAIFunctionTool = Extract; + // ============================================================================= // Message conversion // ============================================================================= @@ -94,6 +100,7 @@ export function convertResponsesMessages( options?: ConvertResponsesMessagesOptions, ): ResponseInput { const messages: ResponseInput = []; + const loadedToolNames = new Set(); const normalizeIdPart = (part: string): string => { const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_"); @@ -259,6 +266,32 @@ export function convertResponsesMessages( call_id: callId, output, }); + + const deferredTools: Tool[] = []; + for (const name of msg.addedToolNames ?? []) { + const tool = options?.deferredTools?.get(name); + if (!tool || loadedToolNames.has(name)) continue; + loadedToolNames.add(name); + deferredTools.push(tool); + } + if (deferredTools.length > 0) { + const names = deferredTools.map((tool) => tool.name); + const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`; + messages.push({ + type: "tool_search_call", + call_id: searchCallId, + execution: "client", + status: "completed", + arguments: { query: names.join(" "), limit: names.length }, + } satisfies ResponseInputItem); + messages.push({ + type: "tool_search_output", + call_id: searchCallId, + execution: "client", + status: "completed", + tools: convertResponsesTools(deferredTools, { deferLoading: true }), + } satisfies ResponseToolSearchOutputItemParam); + } } msgIndex++; } @@ -270,15 +303,18 @@ export function convertResponsesMessages( // Tool conversion // ============================================================================= -export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] { +export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] { const strict = options?.strict === undefined ? false : options.strict; - return tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - parameters: tool.parameters as any, // TypeBox already generates JSON Schema - strict, - })); + return tools.map( + (tool): OpenAIFunctionTool => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + strict, + ...(options?.deferLoading ? { defer_loading: true } : {}), + }), + ); } // ============================================================================= diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index ca0f845b..00d7a43b 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -15,6 +15,7 @@ import type { StreamOptions, Usage, } from "../types.ts"; +import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; @@ -62,6 +63,7 @@ function getCompat(model: Model<"openai-responses">): Required, context: Context, options?: OpenAIResponsesOptions) { - const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS); + const compat = getCompat(model); + const toolPlacement = splitDeferredTools(context, compat.supportsToolSearch); + const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, { + deferredTools: toolPlacement.deferred, + }); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); - const compat = getCompat(model); const params: ResponseCreateParamsStreaming = { model: model.id, input: messages, @@ -245,8 +250,8 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.service_tier = options.serviceTier; } - if (context.tools && context.tools.length > 0) { - params.tools = convertResponsesTools(context.tools); + if (toolPlacement.immediate.length > 0) { + params.tools = convertResponsesTools(toolPlacement.immediate); } if (model.reasoning) { diff --git a/packages/ai/src/providers/openai-codex.models.ts b/packages/ai/src/providers/openai-codex.models.ts index 1c0ae6fe..61b67b51 100644 --- a/packages/ai/src/providers/openai-codex.models.ts +++ b/packages/ai/src/providers/openai-codex.models.ts @@ -28,6 +28,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], @@ -47,6 +48,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], @@ -65,6 +67,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], @@ -84,6 +87,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, input: ["text", "image"], @@ -103,6 +107,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, input: ["text", "image"], @@ -122,6 +127,7 @@ export const OPENAI_CODEX_MODELS = { api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, input: ["text", "image"], diff --git a/packages/ai/src/providers/openai.models.ts b/packages/ai/src/providers/openai.models.ts index 1ea1d15b..0e51826c 100644 --- a/packages/ai/src/providers/openai.models.ts +++ b/packages/ai/src/providers/openai.models.ts @@ -504,6 +504,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, input: ["text", "image"], @@ -523,6 +524,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, input: ["text", "image"], @@ -559,6 +561,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -578,6 +581,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh","minimal":null}, input: ["text", "image"], @@ -616,6 +620,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh","max":"max"}, input: ["text", "image"], @@ -635,6 +640,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh","max":"max"}, input: ["text", "image"], @@ -654,6 +660,7 @@ export const OPENAI_MODELS = { api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", + compat: {"supportsToolSearch":true}, reasoning: true, thinkingLevelMap: {"off":"none","xhigh":"xhigh","max":"max"}, input: ["text", "image"], diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 9e2afb29..211b522c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -401,6 +401,12 @@ export interface ToolResultMessage { toolName: string; content: (TextContent | ImageContent)[]; // Supports text and images details?: TDetails; + /** + * Names from `Context.tools` that became available after this result. + * Providers with native deferred tool loading use this as the load point; + * other providers ignore it and use `Context.tools` normally. + */ + addedToolNames?: string[]; isError: boolean; timestamp: number; // Unix timestamp in milliseconds } @@ -525,6 +531,8 @@ export interface OpenAIResponsesCompat { sendSessionIdHeader?: boolean; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ supportsLongCacheRetention?: boolean; + /** Whether the model supports client-executed tool search for deferred tools. Default: false. */ + supportsToolSearch?: boolean; } /** Compatibility settings for Anthropic Messages-compatible APIs. */ @@ -573,6 +581,12 @@ export interface AnthropicMessagesCompat { forceAdaptiveThinking?: boolean; /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ allowEmptySignature?: boolean; + /** + * Whether the provider supports deferred tools loaded by `tool_reference` + * blocks in tool results. Default: true for first-party Anthropic models + * except Haiku and models older than Claude 4.5; false for other providers. + */ + supportsToolReferences?: boolean; } /** @@ -700,7 +714,7 @@ export interface Model { /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ compat?: TApi extends "openai-completions" ? OpenAICompletionsCompat - : TApi extends "openai-responses" + : TApi extends "openai-responses" | "openai-codex-responses" ? OpenAIResponsesCompat : TApi extends "anthropic-messages" ? AnthropicMessagesCompat diff --git a/packages/ai/src/utils/deferred-tools.ts b/packages/ai/src/utils/deferred-tools.ts new file mode 100644 index 00000000..edcf5204 --- /dev/null +++ b/packages/ai/src/utils/deferred-tools.ts @@ -0,0 +1,39 @@ +import type { Context, Tool } from "../types.ts"; + +type ToolNameNormalizer = (name: string) => string; + +const identityToolName: ToolNameNormalizer = (name) => name; + +/** Split current tools into prefix and transcript-loaded definitions. */ +export function splitDeferredTools( + context: Context, + enabled: boolean, + normalizeName: ToolNameNormalizer = identityToolName, +): { immediate: Tool[]; deferred: Map } { + const uniqueTools = new Map(); + for (const tool of context.tools ?? []) uniqueTools.set(normalizeName(tool.name), tool); + if (!enabled) return { immediate: [...uniqueTools.values()], deferred: new Map() }; + + const deferredNames = new Set(); + const usedNames = new Set(); + for (const message of context.messages) { + if (message.role === "assistant") { + for (const block of message.content) { + if (block.type === "toolCall") usedNames.add(normalizeName(block.name)); + } + } else if (message.role === "toolResult") { + for (const name of message.addedToolNames ?? []) { + const normalizedName = normalizeName(name); + if (!usedNames.has(normalizedName)) deferredNames.add(normalizedName); + } + } + } + + const immediate: Tool[] = []; + const deferred = new Map(); + for (const [name, tool] of uniqueTools) { + if (deferredNames.has(name)) deferred.set(name, tool); + else immediate.push(tool); + } + return { immediate, deferred }; +} diff --git a/packages/ai/src/utils/estimate.ts b/packages/ai/src/utils/estimate.ts index 4fe5010f..b434969e 100644 --- a/packages/ai/src/utils/estimate.ts +++ b/packages/ai/src/utils/estimate.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Context, ImageContent, Message, TextContent, Usage } from "../types.ts"; +import type { AssistantMessage, Context, ImageContent, Message, TextContent, Tool, Usage } from "../types.ts"; export interface ContextUsageEstimate { /** Estimated total context tokens. */ @@ -102,6 +102,11 @@ function estimateMessages(messages: readonly Message[]): ContextUsageEstimate { return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null }; } +function estimateToolsTokens(tools: readonly Tool[] | undefined): number { + if (!tools || tools.length === 0) return 0; + return estimateTextTokens(safeJsonStringify(tools)); +} + function isMessageArray(value: Context | readonly Message[]): value is readonly Message[] { return Array.isArray(value); } @@ -110,13 +115,25 @@ export function estimateContextTokens(context: Context | readonly Message[]): Co if (isMessageArray(context)) return estimateMessages(context); const estimate = estimateMessages(context.messages); - if (estimate.lastUsageIndex !== null) return estimate; - - let prefixTokens = context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0; - if (context.tools && context.tools.length > 0) { - prefixTokens += estimateTextTokens(safeJsonStringify(context.tools)); + if (estimate.lastUsageIndex !== null) { + const addedNames = new Set( + context.messages + .slice(estimate.lastUsageIndex + 1) + .filter((message) => message.role === "toolResult") + .flatMap((message) => message.addedToolNames ?? []), + ); + const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name))); + return { + tokens: estimate.tokens + addedToolTokens, + usageTokens: estimate.usageTokens, + trailingTokens: estimate.trailingTokens + addedToolTokens, + lastUsageIndex: estimate.lastUsageIndex, + }; } + const prefixTokens = + (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools); + return { tokens: estimate.tokens + prefixTokens, usageTokens: estimate.usageTokens, diff --git a/packages/ai/test/deferred-tools.test.ts b/packages/ai/test/deferred-tools.test.ts new file mode 100644 index 00000000..f2567032 --- /dev/null +++ b/packages/ai/test/deferred-tools.test.ts @@ -0,0 +1,387 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { getModel, streamSimple } from "../src/compat.ts"; +import type { Api, AssistantMessage, Context, Model, Tool, ToolResultMessage, UserMessage } from "../src/types.ts"; +import { estimateContextTokens } from "../src/utils/estimate.ts"; + +interface AnthropicToolPayload { + name: string; + description?: string; + defer_loading?: boolean; +} + +interface AnthropicContentBlock { + type: string; + text?: string; + tool_use_id?: string; + content?: string | Array<{ type: string; tool_name?: string }>; + source?: { + type: string; + media_type: string; + data: string; + }; +} + +interface AnthropicPayload { + tools?: AnthropicToolPayload[]; + messages: Array<{ + content: string | AnthropicContentBlock[]; + }>; +} + +interface OpenAIToolSearchCall { + type: "tool_search_call"; + call_id?: string | null; + execution?: string; + status?: string | null; +} + +interface OpenAIToolSearchOutput { + type: "tool_search_output"; + call_id?: string | null; + execution?: string; + status?: string | null; + tools: Array<{ type: string; name: string; defer_loading?: boolean }>; +} + +interface OpenAIPayload { + tools?: Array<{ name?: string; function?: { name: string } }>; + input?: Array; +} + +class PayloadCaptured extends Error {} + +function makeTool(name: string): Tool { + return { + name, + description: `The ${name} tool`, + parameters: Type.Object({ value: Type.String() }), + }; +} + +function makeUserMessage(timestamp: number): UserMessage { + return { role: "user", content: "Hello", timestamp }; +} + +function makeAssistantToolCall(): AssistantMessage { + return { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-6", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 2, + }; +} + +function makeToolResult(addedToolNames: string[]): ToolResultMessage { + return { + role: "toolResult", + toolCallId: "call_1", + toolName: "base_tool", + content: [{ type: "text", text: "done" }], + addedToolNames, + isError: false, + timestamp: 3, + }; +} + +function makeContext(tools: Tool[], addedToolNames = ["late_tool"]): Context { + return { + messages: [makeUserMessage(1), makeAssistantToolCall(), makeToolResult(addedToolNames), makeUserMessage(4)], + tools, + }; +} + +async function capturePayload(model: Model, context: Context, apiKey = "fake-key"): Promise { + let captured: T | undefined; + const stream = streamSimple({ ...model, baseUrl: "http://127.0.0.1:9" }, context, { + apiKey, + onPayload: (payload) => { + captured = payload as T; + throw new PayloadCaptured(); + }, + }); + await stream.result(); + if (!captured) throw new Error("Expected payload capture"); + return captured; +} + +function findAnthropicToolResultContent(payload: AnthropicPayload): AnthropicContentBlock[] { + for (const message of payload.messages) { + if (typeof message.content !== "string" && message.content.some((block) => block.type === "tool_result")) { + return message.content; + } + } + throw new Error("No tool result in payload"); +} + +function findAnthropicToolResult(payload: AnthropicPayload): AnthropicContentBlock { + const result = findAnthropicToolResultContent(payload).find((block) => block.type === "tool_result"); + if (!result) throw new Error("No tool result in payload"); + return result; +} + +function openAIToolNames(payload: OpenAIPayload): string[] { + return (payload.tools ?? []).map((tool) => tool.name ?? tool.function?.name ?? ""); +} + +function makeCodexToken(): string { + return `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`; +} + +describe("deferred tools", () => { + it("loads an Anthropic tool at its tool-result marker", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), context); + + expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]); + expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]); + }); + + it("preserves tool output as sibling content after emitting references", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const assistant = context.messages[1] as AssistantMessage; + assistant.content = [ + { type: "toolCall", id: "call_1", name: "base_tool", arguments: {} }, + { type: "toolCall", id: "call_2", name: "base_tool", arguments: {} }, + ]; + const firstResult = context.messages[2] as ToolResultMessage; + firstResult.content = [ + { type: "text", text: "work completed" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2U=" }, + ]; + context.messages.splice(3, 0, { + ...makeToolResult([]), + toolCallId: "call_2", + content: [{ type: "text", text: "second result" }], + }); + + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), context); + + expect(findAnthropicToolResultContent(payload)).toMatchObject([ + { + type: "tool_result", + tool_use_id: "call_1", + content: [{ type: "tool_reference", tool_name: "late_tool" }], + }, + { type: "tool_result", tool_use_id: "call_2", content: "second result" }, + { type: "text", text: "work completed" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + }, + ]); + }); + + it("loads a tool introduced by OpenAI history after switching to Anthropic", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const assistant = context.messages[1] as AssistantMessage; + assistant.api = "openai-responses"; + assistant.provider = "openai"; + assistant.model = "gpt-5.4"; + + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), context); + + expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]); + expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]); + }); + + it("does not resurrect a marked tool missing from Context.tools", async () => { + const context = makeContext([makeTool("base_tool")]); + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), context); + + expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool"]); + const content = findAnthropicToolResult(payload).content; + expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false); + }); + + it("keeps a tool immediate when it was used before its marker", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const assistant = context.messages[1] as AssistantMessage; + assistant.content = [{ type: "toolCall", id: "call_1", name: "late_tool", arguments: {} }]; + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), context); + + expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]); + expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true); + }); + + it("normalizes OAuth names before checking prior tool usage", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("read")], ["read"]); + const assistant = context.messages[1] as AssistantMessage; + assistant.content = [{ type: "toolCall", id: "call_1", name: "Read", arguments: {} }]; + const payload = await capturePayload( + getModel("anthropic", "claude-opus-4-6"), + context, + "sk-ant-oat-fake", + ); + + expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "Read"]); + expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true); + const content = findAnthropicToolResult(payload).content; + expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false); + }); + + it("matches OAuth-canonicalized markers to active tools", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("read")], ["Read"]); + const payload = await capturePayload( + getModel("anthropic", "claude-opus-4-6"), + context, + "sk-ant-oat-fake", + ); + + expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "Read", defer_loading: true }]); + const content = findAnthropicToolResult(payload).content; + expect( + Array.isArray(content) && + content.some((block) => block.type === "tool_reference" && block.tool_name === "Read"), + ).toBe(true); + }); + + it("deduplicates active tools after OAuth canonicalization", async () => { + const context: Context = { + messages: [makeUserMessage(1)], + tools: [makeTool("read"), { ...makeTool("Read"), description: "Canonical definition" }], + }; + const payload = await capturePayload( + getModel("anthropic", "claude-opus-4-6"), + context, + "sk-ant-oat-fake", + ); + + expect(payload.tools).toMatchObject([{ name: "Read", description: "Canonical definition" }]); + }); + + it("uses the normal tool list when Anthropic tool references are unsupported", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const models: Model<"anthropic-messages">[] = [ + getModel("anthropic", "claude-haiku-4-5"), + { ...getModel("anthropic", "claude-opus-4-6"), id: "claude-sonnet-4-20250514" }, + ]; + + for (const model of models) { + const payload = await capturePayload(model, context); + expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]); + expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true); + } + }); + + it("keeps one immediate Anthropic tool when every current tool is marked", async () => { + const context = makeContext([makeTool("late_tool")]); + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), context); + + expect(payload.tools).toMatchObject([{ name: "late_tool" }]); + expect(payload.tools?.[0]?.defer_loading).toBeUndefined(); + const content = findAnthropicToolResult(payload).content; + expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false); + }); + + it("supports explicit Anthropic compatibility overrides", async () => { + const model: Model<"anthropic-messages"> = { + ...getModel("anthropic", "claude-opus-4-6"), + provider: "anthropic-proxy", + compat: { supportsToolReferences: true }, + }; + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(model, context); + + expect(payload.tools?.find((tool) => tool.name === "late_tool")?.defer_loading).toBe(true); + }); + + it("loads an OpenAI Responses tool through client tool search", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call"); + const searchOutput = payload.input?.find( + (item): item is OpenAIToolSearchOutput => item.type === "tool_search_output", + ); + + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + expect(searchCall).toMatchObject({ execution: "client", status: "completed" }); + expect(searchOutput?.call_id).toBe(searchCall?.call_id); + expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]); + }); + + it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)( + "uses the normal tool list for unsupported OpenAI model %s", + async (modelId) => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(getModel("openai", modelId), context); + + expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]); + expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }, + ); + + it("uses the normal tool list when OpenAI tool search is explicitly disabled", async () => { + const model: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openai-proxy", + compat: { supportsToolSearch: false }, + }; + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(model, context); + + expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]); + expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); + + it("uses tool search only for supported Codex models", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const supported = await capturePayload( + getModel("openai-codex", "gpt-5.4"), + context, + makeCodexToken(), + ); + const unsupported = await capturePayload( + getModel("openai-codex", "gpt-5.3-codex-spark"), + context, + makeCodexToken(), + ); + + expect(openAIToolNames(supported)).toEqual(["base_tool"]); + expect(supported.input?.some((item) => item.type === "tool_search_output")).toBe(true); + expect(openAIToolNames(unsupported)).toEqual(["base_tool", "late_tool"]); + expect(unsupported.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); + + it("leaves providers without deferred loading unchanged", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(getModel("groq", "llama-3.3-70b-versatile"), context); + expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]); + }); + + it("counts definitions marked after the latest usage checkpoint", () => { + const assistant: AssistantMessage = { + ...makeAssistantToolCall(), + content: [{ type: "text", text: "done" }], + usage: { + input: 50, + output: 50, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; + const plain = estimateContextTokens({ messages: [assistant, makeUserMessage(4)], tools: [] }); + const lateTool = { ...makeTool("late_tool"), description: "x".repeat(4000) }; + const marked = estimateContextTokens({ + messages: [assistant, makeToolResult(["late_tool"])], + tools: [lateTool], + }); + + expect(marked.tokens).toBeGreaterThan(plain.tokens + 500); + expect(marked.trailingTokens).toBeGreaterThan(plain.trailingTokens + 500); + }); +}); diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index e0648b81..c74eecbc 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -47,6 +47,7 @@ See [examples/extensions/](../examples/extensions/) for working implementations. - [ExtensionAPI Methods](#extensionapi-methods) - [State Management](#state-management) - [Custom Tools](#custom-tools) + - [Dynamic Tool Loading](#dynamic-tool-loading) - [Custom UI](#custom-ui) - [Error Handling](#error-handling) - [Mode Behavior](#mode-behavior) @@ -2229,6 +2230,143 @@ If a slot renderer is not defined or throws: - `renderCall`: Shows the tool name - `renderResult`: Shows raw text from `content` +### Dynamic Tool Loading + +Extensions can register many tools while keeping only a small initial set active. A tool can then add more tools with `pi.setActiveTools()` during execution. Pi detects purely additive changes, records the newly available tool names on that tool result, and applies the updated active set before the next model request. + +This works with every model. Models with native deferred-loading support preserve the stable prompt prefix and load the new definitions at the tool-result position. Other models use the fallback described below. + +The lifecycle is: + +1. Register every tool with `pi.registerTool()` so it appears in `pi.getAllTools()`. +2. Keep loader tools, such as `search_tools`, active and leave searchable tools inactive. +3. During loader execution, call `pi.setActiveTools([...currentTools, ...matchingTools])`. The change must be additive: do not remove currently active tools in the same call. +4. Pi records which tools were added on the loader's tool result. +5. Before the next model response, Pi exposes the added definitions using native deferred loading when supported, or the normal active tool list otherwise. + +You do not need to return provider-specific tool references or mark the loader as a special search tool. The active-tool change is the signal. Names passed to `pi.setActiveTools()` must already be registered; unknown names are ignored. + +#### Models with native deferred loading + +- **Anthropic** + - **Models:** Sonnet, Opus, Fable version 4.5 or newer (without Haiku) + - **Native representation:** Deferred definitions use `defer_loading`; the load point uses `tool_reference` content. +- **OpenAI** + - **Models:** `gpt-5.4` and newer family + - **Native representation:** Pi adds completed client `tool_search_call` and `tool_search_output` items at the load point. + +For a verified custom model or proxy, native handling can be enabled with `compat.supportsToolReferences: true` for `anthropic-messages`, or `compat.supportsToolSearch: true` for `openai-responses` and `openai-codex-responses`. Leave these disabled unless the endpoint and model accept the corresponding native protocol. + +#### Fallback behavior + +For all other models and providers, dynamic activation still works: Pi sends the complete current active tool list normally on the next request. The model can call the newly activated tools, but adding their definitions may invalidate the provider's cached prompt prefix. + +Pi also uses this safe fallback when the active set is not purely additive, such as replacing one group of tools with another. Tool removals therefore work, but they do not use deferred loading. + +For the best cache behavior, keep the loader tool active for the whole session and add tools instead of replacing the active set. Also note that activating a tool with `promptSnippet` or `promptGuidelines` rebuilds the system prompt; that system-prompt change can invalidate the prefix even when the provider supports deferred schemas. Lazily loaded tools should usually rely on their tool `description` and omit active-only prompt metadata. + +#### Search tool example + +The following extension registers two searchable tools, removes them from the initial active set, and keeps only `search_tools` as their loader. The example uses simple keyword matching, but the search implementation could use BM25, embeddings, a remote catalog, or project-specific routing. + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const SEARCHABLE_TOOL_NAMES = new Set(["lookup_weather", "search_issues"]); + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "lookup_weather", + label: "Lookup Weather", + description: "Look up the current weather for a city", + parameters: Type.Object({ city: Type.String() }), + async execute(_toolCallId, params) { + return { + content: [{ type: "text", text: `Weather for ${params.city}: sunny` }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "search_issues", + label: "Search Issues", + description: "Search project issues by keyword", + parameters: Type.Object({ query: Type.String() }), + async execute(_toolCallId, params) { + return { + content: [{ type: "text", text: `No open issues matching ${params.query}` }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "search_tools", + label: "Search Tools", + description: "Search for and enable tools relevant to a task", + promptSnippet: "Search for additional tools when the active tools cannot perform the task", + promptGuidelines: [ + "Use search_tools when a task requires a capability that is not currently available.", + ], + parameters: Type.Object({ + query: Type.String({ description: "Capability or task to search for" }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), + }), + async execute(_toolCallId, params) { + const terms = params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + const matches = pi.getAllTools() + .filter((tool) => SEARCHABLE_TOOL_NAMES.has(tool.name)) + .map((tool) => ({ + tool, + score: terms.reduce( + (score, term) => + score + (`${tool.name} ${tool.description}`.toLowerCase().includes(term) ? 1 : 0), + 0, + ), + })) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, params.limit ?? 3) + .map((match) => match.tool.name); + + if (matches.length === 0) { + return { + content: [{ type: "text", text: `No tools found for: ${params.query}` }], + details: { matches: [] }, + }; + } + + const active = pi.getActiveTools(); + const added = matches.filter((name) => !active.includes(name)); + pi.setActiveTools([...new Set([...active, ...added])]); + + return { + content: [{ + type: "text", + text: added.length > 0 + ? `Loaded tools: ${added.join(", ")}` + : `Matching tools already active: ${matches.join(", ")}`, + }], + details: { matches, added }, + }; + }, + }); + + pi.on("session_start", () => { + // Keep searchable tools registered but initially inactive. Preserve built-ins + // and tools owned by other extensions, and keep the loader itself active. + const initialTools = pi.getActiveTools().filter( + (name) => !SEARCHABLE_TOOL_NAMES.has(name), + ); + pi.setActiveTools([...new Set([...initialTools, "search_tools"])]); + }); +} +``` + +When `search_tools` adds a match, the model receives that definition on the immediately following request. On a native-capable model the definition is anchored after the search result without changing the initial tool-schema prefix. On other models it appears in the normal tool list on that same following request. + ## Custom UI Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 8dcddef3..393e900c 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -624,6 +624,11 @@ export class ExtensionRunner { this.shutdownHandler(); } + getActiveTools(): string[] { + this.assertActive(); + return this.runtime.getActiveTools(); + } + /** * Create an ExtensionContext for use in event handlers and tool execution. * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index 6a84038c..9084a1da 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -6,7 +6,7 @@ */ import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts"; +import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts"; import type { ExtensionRunner } from "./runner.ts"; import type { RegisteredTool } from "./types.ts"; @@ -15,7 +15,25 @@ import type { RegisteredTool } from "./types.ts"; * Uses the runner's createContext() for consistent context across tools and event handlers. */ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool { - return wrapToolDefinition(registeredTool.definition, () => runner.createContext()); + const tool = wrapToolDefinition(registeredTool.definition, () => runner.createContext()); + const execute = tool.execute; + return { + ...tool, + execute: async (toolCallId, params, signal, onUpdate) => { + const activeBefore = runner.getActiveTools(); + const result = await execute(toolCallId, params, signal, onUpdate); + const activeAfter = runner.getActiveTools(); + if (!activeBefore.every((name) => activeAfter.includes(name))) return result; + + const beforeNames = new Set(activeBefore); + const addedToolNames = activeAfter.filter((name) => !beforeNames.has(name)); + if (addedToolNames.length === 0) return result; + return { + ...result, + addedToolNames: [...new Set([...(result.addedToolNames ?? []), ...addedToolNames])], + }; + }, + }; } /** @@ -23,8 +41,5 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: Exten * Uses the runner's createContext() for consistent context across tools and event handlers. */ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] { - return wrapToolDefinitions( - registeredTools.map((registeredTool) => registeredTool.definition), - () => runner.createContext(), - ); + return registeredTools.map((tool) => wrapRegisteredTool(tool, runner)); } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index ccf15430..d74bbde5 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -140,6 +140,7 @@ const OpenAIResponsesCompatSchema = Type.Object({ supportsDeveloperRole: Type.Optional(Type.Boolean()), sendSessionIdHeader: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), + supportsToolSearch: Type.Optional(Type.Boolean()), }); const AnthropicMessagesCompatSchema = Type.Object({ @@ -148,6 +149,7 @@ const AnthropicMessagesCompatSchema = Type.Object({ sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), supportsCacheControlOnTools: Type.Optional(Type.Boolean()), forceAdaptiveThinking: Type.Optional(Type.Boolean()), + supportsToolReferences: Type.Optional(Type.Boolean()), }); const ProviderCompatSchema = Type.Union([ diff --git a/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts b/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts index 7abe4a5e..cbe86a39 100644 --- a/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts +++ b/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts @@ -66,6 +66,62 @@ describe("extension active tools next-turn refresh", () => { } }); + it("records additive active tool changes on the current tool result", async () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.registerTool({ + name: "load_more_tools", + label: "Load More Tools", + description: "Load more tools", + parameters: Type.Object({}), + execute: async () => { + pi.setActiveTools([...pi.getActiveTools(), "after_load"]); + return { + content: [{ type: "text", text: "loaded" }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "after_load", + label: "After Load", + description: "Tool available after loading", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "after" }], + details: {}, + }), + }); + }, + ]; + const harness = await createHarness({ extensionFactories }); + + try { + harness.session.setActiveToolsByName(["load_more_tools"]); + + const addedToolNames: string[][] = []; + harness.setResponses([ + () => fauxAssistantMessage(fauxToolCall("load_more_tools", {}), { stopReason: "toolUse" }), + (context) => { + addedToolNames.push( + context.messages + .filter((message) => message.role === "toolResult") + .flatMap((message) => message.addedToolNames ?? []), + ); + return fauxAssistantMessage("done"); + }, + ]); + + await harness.session.prompt("start"); + + expect(harness.session.getActiveToolNames()).toEqual(["load_more_tools", "after_load"]); + expect(addedToolNames).toEqual([["after_load"]]); + } finally { + harness.cleanup(); + } + }); + it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => { const extensionFactories: ExtensionFactory[] = [ (pi) => { From 4c1861033b63a04563547ccdb5ed2bf31d4fdcd3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 11 Jul 2026 00:01:31 +0200 Subject: [PATCH 14/33] docs: audit unreleased changelogs --- packages/agent/CHANGELOG.md | 4 ++++ packages/ai/CHANGELOG.md | 8 ++++++-- packages/coding-agent/CHANGELOG.md | 9 +++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f300d151..391ec8fc 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `AgentToolResult.addedToolNames` propagation to `ToolResultMessage` so tools introduced by a result can be loaded from that transcript point onward ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). + ## [0.80.6] - 2026-07-09 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f6dc17b9..ba5ed670 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,15 +2,19 @@ ## [Unreleased] +### Added + +- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). +- Added native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)). + ### Fixed +- Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. ## [0.80.6] - 2026-07-09 ### Added - -- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally. - Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only. - Added request-wide input-token pricing tiers to model cost metadata and usage cost calculation. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0010ff44..0f070e6c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,12 +2,21 @@ ## [Unreleased] +### New Features + +- **Cache-friendly dynamic tool loading** - Extensions can add tools during execution while supported Anthropic and OpenAI Responses models preserve prompt-cache prefixes. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading). +- **Message copy shortcut** - `Ctrl+X` copies the last assistant message in the transcript or the selected message in `/tree`, making older and branched messages directly copyable. See [Display and Message Queue](docs/keybindings.md#display-and-message-queue). +- **Fable 5 `xhigh` and `max` thinking** - Native `xhigh` and `max` thinking levels are available across generated provider catalogs. See [Model Options](docs/usage.md#model-options). + ### Added +- Added cache-friendly dynamic tool loading for extension tools activated by tool results. Supported Anthropic and OpenAI Responses models load definitions where they become available, preserving the cached prompt prefix. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading) ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). +- Added inherited native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)). - Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`. ### Fixed +- Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). - Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. From 19fe0e01c5eca791c9da0372b49256845555a783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Krzywa=C5=BCnia?= <1815898+ribelo@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:12:23 +0200 Subject: [PATCH 15/33] fix(ai): preserve ambient AWS auth for Bedrock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not treat Pi’s internal ambient-auth marker as a Bedrock bearer token. This preserves SigV4 signing for AWS profiles, IAM credentials, and roles while retaining bearer authentication for real Bedrock API keys.\n\nFixes #6531 --- packages/ai/src/api/bedrock-converse-stream.ts | 3 ++- packages/ai/test/bedrock-endpoint-resolution.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 7a9e776e..d615d4ca 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -101,6 +101,7 @@ export interface BedrockOptions extends StreamOptions { type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; const EMPTY_TEXT_PLACEHOLDER = ""; +const AMBIENT_AUTH_MARKER = ""; export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, @@ -153,7 +154,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1"; const bearerToken = options.bearerToken || - options.apiKey || + (options.apiKey !== AMBIENT_AUTH_MARKER ? options.apiKey : undefined) || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined; const useBearerToken = bearerToken !== undefined && !skipAuth; diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 0fef3662..a7c1fee2 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -190,4 +190,13 @@ describe("bedrock endpoint resolution", () => { expect(config.token).toEqual({ token: "bedrock-api-key" }); expect(config.authSchemePreference).toEqual(["httpBearerAuth"]); }); + + it("does not use the ambient AWS auth marker as a bearer token", async () => { + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + + const config = await captureClientConfig(model, { apiKey: "" }); + + expect(config.token).toBeUndefined(); + expect(config.authSchemePreference).toBeUndefined(); + }); }); From 850c210b77acadaf8ee11dab058953877b6c8ffd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 11 Jul 2026 13:38:48 +0200 Subject: [PATCH 16/33] fix(ai): filter ambient auth markers in compat dispatch --- .../ai/src/api/bedrock-converse-stream.ts | 3 +- packages/ai/src/compat.ts | 3 +- .../test/bedrock-endpoint-resolution.test.ts | 28 +++++++++++-------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index d615d4ca..7a9e776e 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -101,7 +101,6 @@ export interface BedrockOptions extends StreamOptions { type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; const EMPTY_TEXT_PLACEHOLDER = ""; -const AMBIENT_AUTH_MARKER = ""; export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, @@ -154,7 +153,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1"; const bearerToken = options.bearerToken || - (options.apiKey !== AMBIENT_AUTH_MARKER ? options.apiKey : undefined) || + options.apiKey || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined; const useBearerToken = bearerToken !== undefined && !skipAuth; diff --git a/packages/ai/src/compat.ts b/packages/ai/src/compat.ts index 91ecda4f..eb1151fd 100644 --- a/packages/ai/src/compat.ts +++ b/packages/ai/src/compat.ts @@ -206,6 +206,7 @@ export function resetApiProviders(): void { registerBuiltInApiProviders(); const compatModels = builtinModels(); +const AMBIENT_AUTH_MARKER = ""; function hasExplicitApiKey(apiKey: string | undefined): apiKey is string { return typeof apiKey === "string" && apiKey.trim().length > 0; @@ -217,7 +218,7 @@ function withEnvApiKey( ): TOptions | undefined { if (hasExplicitApiKey(options?.apiKey)) return options; const apiKey = getEnvApiKey(model.provider, options?.env); - if (!apiKey) return options; + if (!apiKey || apiKey === AMBIENT_AUTH_MARKER) return options; return { ...options, apiKey } as TOptions; } diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index a7c1fee2..62797d15 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -44,8 +44,8 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }; }); -import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; -import { getModel } from "../src/compat.ts"; +import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts"; +import { getModel, stream as streamBedrock } from "../src/compat.ts"; import type { Context, Model } from "../src/types.ts"; const context: Context = { @@ -182,6 +182,21 @@ describe("bedrock endpoint resolution", () => { expect(config.region).toBe("us-gov-west-1"); }); + it("preserves ambient AWS auth for custom model IDs through compat dispatch", async () => { + process.env.AWS_PROFILE = "bedrock-profile"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example", + }; + + const config = await captureClientConfig(model); + + expect(config.profile).toBe("bedrock-profile"); + expect(config.token).toBeUndefined(); + expect(config.authSchemePreference).toBeUndefined(); + }); + it("uses the generic API key option as a Bedrock bearer token", async () => { const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); @@ -190,13 +205,4 @@ describe("bedrock endpoint resolution", () => { expect(config.token).toEqual({ token: "bedrock-api-key" }); expect(config.authSchemePreference).toEqual(["httpBearerAuth"]); }); - - it("does not use the ambient AWS auth marker as a bearer token", async () => { - const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); - - const config = await captureClientConfig(model, { apiKey: "" }); - - expect(config.token).toBeUndefined(); - expect(config.authSchemePreference).toBeUndefined(); - }); }); From bdd5c53bcdf57186125968b8e082e84c6a34742b Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:30:40 -0400 Subject: [PATCH 17/33] fix(ai): fall back to ambient Cloudflare account id for key-only credentials (#6292) Cloudflare Workers AI / AI Gateway resolved provider config from the credential only, never consulting ambient env for a field the credential omitted. The coding-agent /login flow stores just the API key, so CLOUDFLARE_ACCOUNT_ID lives only in the environment; the key-only credential short-circuited the env lookup, the account id stayed unresolved, and requests hit the literal {CLOUDFLARE_ACCOUNT_ID} base URL -> 404. resolveValue now merges per field: prefer the credential value, fall back to ctx.env(name). closes #6021 Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: Mario Zechner --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cloudflare-auth.ts | 14 +++++++++----- packages/ai/test/providers.test.ts | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ba5ed670..19f9cebb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -42,6 +42,7 @@ - Fixed Amazon Bedrock Claude 5 prompt-cache pricing metadata by removing stale fallback overrides. - Fixed DS4 server context overflow detection for `Prompt has ... tokens, but the configured context size is ... tokens` errors ([#6262](https://github.com/earendil-works/pi/issues/6262)). - Fixed OpenAI Codex WebSocket sessions to rotate cached connections before the backend's 60-minute limit, avoiding connection-limit failures on long sessions ([#6268](https://github.com/earendil-works/pi/issues/6268)). +- Fixed Cloudflare Workers AI / AI Gateway auth to fall back to the ambient `CLOUDFLARE_ACCOUNT_ID` (and `CLOUDFLARE_GATEWAY_ID`) when the stored credential carries only the API key, so `/login`-style key-only credentials no longer leave the `{CLOUDFLARE_ACCOUNT_ID}` placeholder unresolved and return 404 ([#6021](https://github.com/earendil-works/pi/issues/6021)). - Fixed OpenAI Completions and Responses providers to send `(no tool output)` instead of `(see attached image)` when a tool result has empty text and no image content, preventing the model from hallucinating image attachments. - Fixed OpenAI Responses and Azure OpenAI Responses requests to avoid sending `max_output_tokens` values below the provider minimum ([#6265](https://github.com/earendil-works/pi/issues/6265)). - Fixed retry classification for Cloudflare 524 timeout responses ([#6239](https://github.com/earendil-works/pi/issues/6239)). diff --git a/packages/ai/src/providers/cloudflare-auth.ts b/packages/ai/src/providers/cloudflare-auth.ts index 511e8d73..7542ef98 100644 --- a/packages/ai/src/providers/cloudflare-auth.ts +++ b/packages/ai/src/providers/cloudflare-auth.ts @@ -12,11 +12,15 @@ async function resolveValue( ctx: AuthContext, credential: ApiKeyCredential | undefined, ): Promise { - if (credential) { - if (name === CLOUDFLARE_API_KEY) return credential.key; - return credential.env?.[name]; - } - return ctx.env(name); + // Per-field merge: prefer the credential value, fall back to ambient env. + // A credential carrying only the API key must still pick up the account / + // gateway id from the environment. + const fromCredential = credential + ? name === CLOUDFLARE_API_KEY + ? credential.key + : credential.env?.[name] + : undefined; + return fromCredential ?? (await ctx.env(name)); } function resolveCloudflareBaseUrl( diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index a22c8abd..3b662996 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -99,6 +99,26 @@ describe("builtin providers", () => { expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" }); }); + // Regression for #6021: a credential carrying only the API key (as stored + // by `/login`) must still resolve CLOUDFLARE_ACCOUNT_ID from ambient env. + it("falls back to ambient CLOUDFLARE_ACCOUNT_ID when the credential carries only the API key", async () => { + const provider = cloudflareWorkersAIProvider(); + const model = builtinModels().getModels("cloudflare-workers-ai")[0]; + const auth = provider.auth.apiKey; + if (!auth) throw new Error("expected api-key auth"); + + const result = await auth.resolve({ + model, + ctx: fakeAuthContext({ CLOUDFLARE_ACCOUNT_ID: "account-id" }), + credential: { type: "api_key", key: "cf-key" }, + }); + expect(result?.auth).toEqual({ + apiKey: "cf-key", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1", + }); + expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" }); + }); + it("requires Cloudflare AI Gateway account and gateway config and returns scoped env headers", async () => { const missingGateway = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }), From 5416b1834a28b79800393b7bf792ab36065c49b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 12:41:54 +0000 Subject: [PATCH 18/33] chore: approve contributor petrroll --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 8ecfbc43..7115943f 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -267,3 +267,5 @@ HarrodRen pr aaronkyriesenbach pr farid-fari pr + +petrroll pr From 8479bd84743e8889f728acb21a62794102db0529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Krzywa=C5=BCnia?= <1815898+ribelo@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:20:08 +0200 Subject: [PATCH 19/33] fix(tui): parse legacy alt-prefixed symbols (#6523) --- packages/tui/src/keys.ts | 11 ++++++----- packages/tui/test/keys.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 0d3c7085..8b6f8a0a 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -1159,8 +1159,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (data === `\x1b${rawCtrl}`) return true; } - if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) { - // Legacy: alt+letter/digit is ESC followed by the key + if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit || SYMBOL_KEYS.has(key))) { + // Legacy: alt+printable key is ESC followed by the key if (data === `\x1b${key}`) return true; } @@ -1296,9 +1296,10 @@ export function parseKey(data: string): string | undefined { if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; } - // Legacy alt+letter/digit (ESC followed by the key) - if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { - return `alt+${String.fromCharCode(code)}`; + // Legacy alt+letter/digit/symbol (ESC followed by the key) + const key = String.fromCharCode(code); + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57) || SYMBOL_KEYS.has(key)) { + return `alt+${key}`; } } if (data === "\x1b[A") return "up"; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index e06844e4..a5b893f2 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -431,6 +431,10 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1ba"), "alt+a"); assert.strictEqual(matchesKey("\x1b1", "alt+1"), true); assert.strictEqual(parseKey("\x1b1"), "alt+1"); + assert.strictEqual(matchesKey("\x1b,", "alt+,"), true); + assert.strictEqual(parseKey("\x1b,"), "alt+,"); + assert.strictEqual(matchesKey("\x1b.", "alt+."), true); + assert.strictEqual(parseKey("\x1b."), "alt+."); assert.strictEqual(matchesKey("\x1by", "alt+y"), true); assert.strictEqual(parseKey("\x1by"), "alt+y"); assert.strictEqual(matchesKey("\x1bz", "alt+z"), true); @@ -451,6 +455,10 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1ba"), undefined); assert.strictEqual(matchesKey("\x1b1", "alt+1"), false); assert.strictEqual(parseKey("\x1b1"), undefined); + assert.strictEqual(matchesKey("\x1b,", "alt+,"), false); + assert.strictEqual(parseKey("\x1b,"), undefined); + assert.strictEqual(matchesKey("\x1b.", "alt+."), false); + assert.strictEqual(parseKey("\x1b."), undefined); assert.strictEqual(matchesKey("\x1by", "alt+y"), false); assert.strictEqual(parseKey("\x1by"), undefined); setKittyProtocolActive(false); From 16a3d420ff1cfc52c875870188aa151ca62e1a99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 07:46:38 +0000 Subject: [PATCH 20/33] chore: approve contributor vibeinging --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 7115943f..94f5712c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -269,3 +269,5 @@ aaronkyriesenbach pr farid-fari pr petrroll pr + +vibeinging pr From eacaa130abaee03132ae6502f37a603e2cd067f1 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Mon, 13 Jul 2026 04:48:33 -0300 Subject: [PATCH 21/33] ai: OpenAI and Codex forced tool calls (#6588) * feat(ai): support forced OpenAI Codex tool calls * feat(ai): support OpenAI Responses tool choice --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/api/openai-codex-responses.ts | 5 +- packages/ai/src/api/openai-responses.ts | 5 ++ packages/ai/test/openai-codex-stream.test.ts | 56 +++++++++++++++++++ .../openai-responses-copilot-provider.test.ts | 48 ++++++++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 19f9cebb..b13ff430 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,8 @@ - Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). - Added native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)). +- Added `toolChoice` support to OpenAI Codex Responses, including `"required"` to force a tool call. +- Added `toolChoice` support to OpenAI Responses, including required and named tool selection. ### Fixed diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 3d1b27f7..fc57ec33 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -85,6 +85,7 @@ export interface OpenAICodexResponsesOptions extends StreamOptions { reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; textVerbosity?: "low" | "medium" | "high"; + toolChoice?: "auto" | "none" | "required"; } type CodexResponseStatus = "completed" | "incomplete" | "failed" | "cancelled" | "queued" | "in_progress"; @@ -97,7 +98,7 @@ interface RequestBody { previous_response_id?: string; input?: ResponseInput; tools?: OpenAITool[]; - tool_choice?: "auto"; + tool_choice?: OpenAICodexResponsesOptions["toolChoice"]; parallel_tool_calls?: boolean; temperature?: number; reasoning?: { effort?: string; summary?: string }; @@ -497,7 +498,7 @@ function buildRequestBody( text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), - tool_choice: "auto", + tool_choice: options?.toolChoice ?? "auto", parallel_tool_calls: true, }; diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 00d7a43b..287cc4df 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -83,6 +83,7 @@ export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; } /** @@ -254,6 +255,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.tools = convertResponsesTools(toolPlacement.immediate); } + if (options?.toolChoice !== undefined) { + params.tool_choice = options.toolChoice; + } + if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { const effort = options?.reasoningEffort diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 016acc82..fc47572a 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenAICodexWebSocketSessions, @@ -700,6 +701,61 @@ describe("openai-codex streaming", () => { expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" }); }); + it("forwards required tool choice", async () => { + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + let requestedToolChoice: unknown; + + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL, init?: RequestInit) => { + requestedToolChoice = decodeCodexRequestBody(init?.body)?.tool_choice; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + + await streamOpenAICodexResponses( + model, + { + messages: [ + { role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() }, + ], + tools: [ + { + name: "ping", + description: "Ping", + parameters: Type.Object({ value: Type.String() }), + }, + ], + }, + { apiKey: token, transport: "sse", toolChoice: "required" }, + ).result(); + + expect(requestedToolChoice).toBe("required"); + }); + it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index 39bc7ca6..1f1f945f 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -1,3 +1,4 @@ +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getModel } from "../src/compat.ts"; @@ -91,6 +92,53 @@ describe("openai-responses provider defaults", () => { }); }); + it("forwards required tool choice", async () => { + let capturedPayload: unknown; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + getModel("openai", "gpt-5.4"), + { + messages: [ + { + role: "user", + content: "Do not call ping. Respond with text instead.", + timestamp: Date.now(), + }, + ], + tools: [ + { + name: "ping", + description: "Ping", + parameters: Type.Object({ value: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "required", + onPayload: (payload) => { + capturedPayload = payload; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload).toMatchObject({ + tool_choice: "required", + tools: [expect.objectContaining({ name: "ping" })], + }); + }); + it.each([ "gpt-5.1", "gpt-5.2", From f7b78e2a42b3b3985e5e3e3bf5487fdf0a001d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Mon, 13 Jul 2026 09:56:40 +0200 Subject: [PATCH 22/33] fix(ai): route GitHub Copilot MAI-Code models through /responses endpoint (#6544) mai-code-1-flash-picker is not accessible via /chat/completions and must use the Copilot /responses endpoint. Update generate-models.ts to send mai-* models to the responses API and regenerate the affected model entry. --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 6 ++++-- packages/ai/src/providers/github-copilot.models.ts | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b13ff430..baa399fd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed - Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). +- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint. - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. ## [0.80.6] - 2026-07-09 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e6e5e199..4fd84d86 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1463,8 +1463,10 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // gpt-5 models require responses API, others use completions - const needsResponsesApi = modelId.startsWith("gpt-5") || modelId.startsWith("oswe"); + // gpt-5, oswe, and MAI-Code models are only served through the + // Copilot /responses endpoint. + const needsResponsesApi = + modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); const api: Api = isCopilotClaude ? "anthropic-messages" diff --git a/packages/ai/src/providers/github-copilot.models.ts b/packages/ai/src/providers/github-copilot.models.ts index 913974ee..214812fd 100644 --- a/packages/ai/src/providers/github-copilot.models.ts +++ b/packages/ai/src/providers/github-copilot.models.ts @@ -468,11 +468,10 @@ export const GITHUB_COPILOT_MODELS = { "mai-code-1-flash-picker": { id: "mai-code-1-flash-picker", name: "MAI-Code-1-Flash", - api: "openai-completions", + api: "openai-responses", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text"], cost: { @@ -483,5 +482,5 @@ export const GITHUB_COPILOT_MODELS = { }, contextWindow: 256000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"openai-responses">, } as const; From 298665cfb9b92def9011a6762a3e7dfbcb6cee13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Mon, 13 Jul 2026 09:57:53 +0200 Subject: [PATCH 23/33] fix(ai): support OpenRouter session affinity (#6496) Fixes #6366 --- packages/ai/CHANGELOG.md | 5 + packages/ai/README.md | 5 +- packages/ai/src/api/openai-completions.ts | 14 +- packages/ai/src/api/openai-responses.ts | 16 ++- packages/ai/src/types.ts | 9 +- .../openai-completions-prompt-cache.test.ts | 58 ++++++++ ...penai-completions-thinking-as-text.test.ts | 1 + .../openai-completions-tool-choice.test.ts | 1 + ...nai-completions-tool-result-images.test.ts | 1 + .../openai-responses-copilot-provider.test.ts | 127 ++++++++++++++++-- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/custom-provider.md | 2 + packages/coding-agent/docs/models.md | 2 + .../coding-agent/src/core/model-registry.ts | 8 +- 14 files changed, 229 insertions(+), 25 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index baa399fd..5219441f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)). + ### Added - Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). @@ -14,6 +18,7 @@ - Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). - Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint. - Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token. +- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6366](https://github.com/earendil-works/pi/issues/6366)). ## [0.80.6] - 2026-07-09 diff --git a/packages/ai/README.md b/packages/ai/README.md index c9db20ac..0216cdfa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1070,7 +1070,8 @@ interface OpenAICompletionsCompat { supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true) supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true) - sendSessionAffinityHeaders?: boolean; // Whether to send `session_id`, `x-client-request-id`, and `x-session-affinity` from `sessionId` when caching is enabled (default: false) + sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false) + sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected) maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false) requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) @@ -1085,7 +1086,7 @@ interface OpenAICompletionsCompat { interface OpenAIResponsesCompat { supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) - sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true) + sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Session-affinity header format: 'openai' sends `session_id` and `x-client-request-id`; 'openai-nosession' sends `x-client-request-id`; 'openrouter' sends `x-session-id`. Does not affect the `prompt_cache_key` body param (default: auto-detected) supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true) } ``` diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 32947db4..88df3143 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -519,9 +519,15 @@ function createClient( } if (sessionId && compat.sendSessionAffinityHeaders) { - headers.session_id = sessionId; - headers["x-client-request-id"] = sessionId; - headers["x-session-affinity"] = sessionId; + if (compat.sessionAffinityFormat === "openrouter") { + headers["x-session-id"] = sessionId; + } else { + if (compat.sessionAffinityFormat === "openai") { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; + headers["x-session-affinity"] = sessionId; + } } // Merge options headers last so they can override defaults @@ -1250,6 +1256,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, + sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai", supportsLongCacheRetention: !( isTogether || isCloudflareWorkersAI || @@ -1289,6 +1296,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, + sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat, supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, }; } diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 287cc4df..cb3a9c93 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -44,6 +44,10 @@ function getClientApiKey(provider: string, apiKey: string | undefined, headers: throw new Error(`No API key for provider: ${provider}`); } +function detectSessionAffinityFormat(model: Pick, "provider" | "baseUrl">) { + return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai"; +} + /** * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. @@ -61,7 +65,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn function getCompat(model: Model<"openai-responses">): Required { return { supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, - sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, + sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model), supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, supportsToolSearch: model.compat?.supportsToolSearch ?? false, }; @@ -203,10 +207,14 @@ function createClient( } if (sessionId) { - if (compat.sendSessionIdHeader) { - headers.session_id = sessionId; + if (compat.sessionAffinityFormat === "openrouter") { + headers["x-session-id"] = sessionId; + } else { + if (compat.sessionAffinityFormat === "openai") { + headers.session_id = sessionId; + } + headers["x-client-request-id"] = sessionId; } - headers["x-client-request-id"] = sessionId; } // Merge options headers last so they can override defaults diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 211b522c..10530fae 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -100,6 +100,7 @@ export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; /** Provider-scoped environment overrides. Values take precedence over process.env. */ export type ProviderEnv = Record; export type ProviderHeaders = Record; +export type SessionAffinityFormat = "openai" | "openai-nosession" | "openrouter"; export interface ProviderResponse { status: number; @@ -517,8 +518,10 @@ export interface OpenAICompletionsCompat { supportsStrictMode?: boolean; /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ cacheControlFormat?: "anthropic"; - /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ + /** Whether to send session-affinity data from `options.sessionId`. Default: false. */ sendSessionAffinityHeaders?: boolean; + /** Session-affinity header format: `openai` sends `session_id`, `x-client-request-id`, and `x-session-affinity`; `openai-nosession` sends `x-client-request-id` and `x-session-affinity`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ + sessionAffinityFormat?: SessionAffinityFormat; /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ supportsLongCacheRetention?: boolean; } @@ -527,8 +530,8 @@ export interface OpenAICompletionsCompat { export interface OpenAIResponsesCompat { /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ supportsDeveloperRole?: boolean; - /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ - sendSessionIdHeader?: boolean; + /** Session-affinity header format: `openai` sends `session_id` and `x-client-request-id`; `openai-nosession` sends `x-client-request-id`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */ + sessionAffinityFormat?: SessionAffinityFormat; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ supportsLongCacheRetention?: boolean; /** Whether the model supports client-executed tool search for deferred tools. Default: false. */ diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 5e905712..b697ff0f 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -13,6 +13,7 @@ interface FakeOpenAIClientOptions { interface CapturedCompletionsPayload { prompt_cache_key?: string; prompt_cache_retention?: "24h" | "in-memory" | null; + session_id?: string; } const mockState = vi.hoisted(() => ({ @@ -170,6 +171,63 @@ describe("openai-completions prompt caching", () => { expect(headers["x-session-affinity"]).toBe("session-affinity"); }); + it("uses OpenAI no-session format when configured", async () => { + const model = createModel({ + compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-nosession" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBe("session-nosession"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBe("session-nosession"); + expect(headers["x-session-affinity"]).toBe("session-nosession"); + expect(headers["x-session-id"]).toBeUndefined(); + }); + + it("uses OpenRouter session-affinity header when configured", async () => { + const model = createModel({ + baseUrl: "https://proxy.example.com/v1", + compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter" }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-proxy" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBe("session-proxy"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBeUndefined(); + expect(headers["x-session-affinity"]).toBeUndefined(); + }); + + it("auto-detects OpenRouter session-affinity header for OpenRouter endpoints", async () => { + const model = createModel({ + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: { sendSessionAffinityHeaders: true }, + }); + const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBe("session-openrouter"); + expect(headers.session_id).toBeUndefined(); + expect(headers["x-client-request-id"]).toBeUndefined(); + expect(headers["x-session-affinity"]).toBeUndefined(); + }); + + it("omits OpenRouter session-affinity data when disabled", async () => { + const model = createModel({ + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + }); + const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model); + + expect(payload?.session_id).toBeUndefined(); + expect(payload?.prompt_cache_key).toBeUndefined(); + expect(headers["x-session-id"]).toBeUndefined(); + }); + it("omits session-affinity headers when cacheRetention is none", async () => { const model = createModel({ baseUrl: "https://proxy.example.com/v1", diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index d1cbe14a..88af01ea 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -39,6 +39,7 @@ const compat = { supportsStrictMode: true, cacheControlFormat: undefined, sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, } satisfies Required> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 1dd49c79..b1f07773 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1247,6 +1247,7 @@ describe("openai-completions tool_choice", () => { zaiToolStream: false, supportsStrictMode: true, sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, }, ); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 31be1667..f9ed9d30 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -37,6 +37,7 @@ const compat: Required = { supportsStrictMode: true, cacheControlFormat: "anthropic", sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", supportsLongCacheRetention: true, }; diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index 1f1f945f..8eeedea7 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -6,6 +6,11 @@ import type { Model } from "../src/types.ts"; type CapturedHeaders = Headers | string[][] | Record | undefined; +interface CapturedResponsesPayload { + prompt_cache_key?: string; + session_id?: string; +} + function getHeader(headers: CapturedHeaders, name: string): string | null { if (!headers) return null; if (headers instanceof Headers) return headers.get(name); @@ -25,11 +30,20 @@ function getHeader(headers: CapturedHeaders, name: string): string | null { async function captureOpenAIResponseHeaders( options: Parameters[2], model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"), -): Promise<{ sessionId: string | null; clientRequestId: string | null }> { - const captured = { sessionId: null as string | null, clientRequestId: null as string | null }; +): Promise<{ + sessionId: string | null; + clientRequestId: string | null; + xSessionId: string | null; +}> { + const captured = { + sessionId: null as string | null, + clientRequestId: null as string | null, + xSessionId: null as string | null, + }; vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { captured.sessionId = getHeader(init?.headers, "session_id"); captured.clientRequestId = getHeader(init?.headers, "x-client-request-id"); + captured.xSessionId = getHeader(init?.headers, "x-session-id"); return new Response("data: [DONE]\n\n", { status: 200, headers: { "content-type": "text/event-stream" }, @@ -224,12 +238,13 @@ describe("openai-responses provider defaults", () => { it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); }); it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { const sessionId = "x".repeat(67); - let capturedPayload: { prompt_cache_key?: string } | undefined; + let capturedPayload: Pick | undefined; vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("data: [DONE]\n\n", { status: 200, @@ -247,7 +262,7 @@ describe("openai-responses provider defaults", () => { apiKey: "test-key", sessionId, onPayload: (payload) => { - capturedPayload = payload as { prompt_cache_key?: string }; + capturedPayload = payload as Pick; }, }, ); @@ -267,19 +282,105 @@ describe("openai-responses provider defaults", () => { }; const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel); - expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); }); - it("can omit the session_id header while preserving other cache-affinity headers", async () => { + it("uses OpenRouter session-affinity header when configured", async () => { + const proxyModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "proxy", + baseUrl: "https://proxy.example.com/v1", + compat: { sessionAffinityFormat: "openrouter" }, + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-proxy", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + expect(captured.xSessionId).toBe("session-proxy"); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-proxy"); + }); + + it("auto-detects OpenRouter session-affinity header for OpenRouter Responses endpoints", async () => { + const openRouterModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-openrouter", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + openRouterModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + expect(captured.xSessionId).toBe("session-openrouter"); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-openrouter"); + }); + + it("uses OpenAI no-session format when configured", async () => { + const proxyModel: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "proxy", + baseUrl: "https://proxy.example.com/v1", + compat: { sessionAffinityFormat: "openai-nosession" }, + }; + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-proxy", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); + + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBe("session-proxy"); + expect(captured.xSessionId).toBeNull(); + expect(capturedPayload?.session_id).toBeUndefined(); + expect(capturedPayload?.prompt_cache_key).toBe("session-proxy"); + }); + + it("can omit OpenAI session_id header while preserving other affinity data", async () => { const proxyModel: Model<"openai-responses"> = { ...getModel("openai", "gpt-5.4"), provider: "opencode", baseUrl: "https://proxy.example.com/v1", - compat: { sendSessionIdHeader: false }, + compat: { sessionAffinityFormat: "openai-nosession" }, }; - const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel); + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-123", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + proxyModel, + ); - expect(captured).toEqual({ sessionId: null, clientRequestId: "session-123" }); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBe("session-123"); + expect(capturedPayload?.prompt_cache_key).toBe("session-123"); }); it("lets explicit headers override the default OpenAI cache-affinity headers", async () => { @@ -291,13 +392,15 @@ describe("openai-responses provider defaults", () => { }, }); - expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" }); + expect(captured.sessionId).toBe("override-session"); + expect(captured.clientRequestId).toBe("override-request"); }); it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => { const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: null, clientRequestId: null }); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); }); it.each([ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0f070e6c..58e53e3a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)). + ### New Features - **Cache-friendly dynamic tool loading** - Extensions can add tools during execution while supported Anthropic and OpenAI Responses models preserve prompt-cache prefixes. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading). @@ -17,6 +21,7 @@ ### Fixed - Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)). +- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6366](https://github.com/earendil-works/pi/issues/6366)). - Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image. - Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions. diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 6a8a38ed..43576faa 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -722,6 +722,8 @@ interface ProviderModelConfig { thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; chatTemplateKwargs?: Record; cacheControlFormat?: "anthropic"; + sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"; + sendSessionAffinityHeaders?: boolean; // anthropic-messages supportsEagerToolInputStreaming?: boolean; diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 9e204b1a..94c776b3 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -445,6 +445,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | | `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | +| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. | +| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | | `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). | diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index d74bbde5..cb84fe5d 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -133,12 +133,18 @@ const OpenAICompletionsCompatSchema = Type.Object({ openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), supportsStrictMode: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + sessionAffinityFormat: Type.Optional( + Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); const OpenAIResponsesCompatSchema = Type.Object({ supportsDeveloperRole: Type.Optional(Type.Boolean()), - sendSessionIdHeader: Type.Optional(Type.Boolean()), + sessionAffinityFormat: Type.Optional( + Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]), + ), supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), }); From 7303cbac5d777fdb72f486c90c44f91f2e48807d Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 10:49:10 +0200 Subject: [PATCH 24/33] fix branch summary when using ambient auth (#6595) allow null apiKey. uses the same auth flow like compaction fixes #6324 --- .../coding-agent/src/core/agent-session.ts | 8 +-- .../core/compaction/branch-summarization.ts | 2 +- .../6324-branch-summary-ambient-auth.test.ts | 61 +++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c4c2d366..5083e60f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -399,7 +399,7 @@ export class AgentSession { throw new Error(formatNoApiKeyFoundMessage(model.provider)); } - private async _getCompactionRequestAuth(model: Model): Promise<{ + private async _getSummarizationRequestAuth(model: Model): Promise<{ apiKey?: string; headers?: Record; env?: Record; @@ -1744,7 +1744,7 @@ export class AgentSession { throw new Error(formatNoModelSelectedMessage()); } - const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model); + const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model); const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); @@ -2012,7 +2012,7 @@ export class AgentSession { headers = authResult.headers; env = authResult.env; } else { - ({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model)); + ({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model)); } const pathEntries = this.sessionManager.getBranch(); @@ -2881,7 +2881,7 @@ export class AgentSession { let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; - const { apiKey, headers, env } = await this._getRequiredRequestAuth(model); + const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model); const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 3f557c01..a96bb202 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -66,7 +66,7 @@ export interface GenerateBranchSummaryOptions { /** Model to use for summarization */ model: Model; /** API key for the model */ - apiKey: string; + apiKey?: string; /** Request headers for the model */ headers?: Record; /** Provider-scoped environment values for the model */ diff --git a/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts new file mode 100644 index 00000000..121cc006 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts @@ -0,0 +1,61 @@ +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { assistantMsg, userMsg } from "../../utilities.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("issue #6324 branch summary ambient auth", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("summarizes tree branches when request auth has no API key", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + + let streamCallCount = 0; + harness.session.agent.streamFn = (model, _context, options) => { + streamCallCount++; + expect(options?.apiKey).toBeUndefined(); + + const stream = createAssistantMessageEventStream(); + stream.push({ + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "text", text: "branch summary text" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + }); + return stream; + }; + + const targetId = harness.sessionManager.appendMessage(userMsg("first branch")); + harness.sessionManager.appendMessage(assistantMsg("first reply")); + harness.sessionManager.appendMessage(userMsg("abandoned branch work")); + harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); + + const result = await harness.session.navigateTree(targetId, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(streamCallCount).toBe(1); + expect(result.summaryEntry?.type).toBe("branch_summary"); + expect(result.summaryEntry?.summary).toContain("branch summary text"); + }); +}); From f8f75544b63c5910568b2a0f667da858e4a11147 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 11:39:28 +0200 Subject: [PATCH 25/33] pass bedrock unhandled stop reasons to error message (#6598) fixes #6485 --- packages/ai/src/api/bedrock-converse-stream.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 7a9e776e..d0af8131 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -260,7 +260,11 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } else if (item.contentBlockStop) { handleContentBlockStop(item.contentBlockStop, blocks, output, stream); } else if (item.messageStop) { - output.stopReason = mapStopReason(item.messageStop.stopReason); + const { stopReason, errorMessage } = mapStopReason(item.messageStop.stopReason); + output.stopReason = stopReason; + if (errorMessage) { + output.errorMessage = errorMessage; + } } else if (item.metadata) { handleMetadata(item.metadata, model, output); } else if (item.internalServerException) { @@ -281,7 +285,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = } if (output.stopReason === "error" || output.stopReason === "aborted") { - throw new Error("An unknown error occurred"); + throw new Error(output.errorMessage || "An unknown error occurred"); } stream.push({ type: "done", reason: output.stopReason, message: output }); @@ -932,18 +936,18 @@ function convertToolConfig( return { tools: bedrockTools, toolChoice: bedrockToolChoice }; } -function mapStopReason(reason: string | undefined): StopReason { +function mapStopReason(reason: string | undefined): { stopReason: StopReason; errorMessage?: string } { switch (reason) { case BedrockStopReason.END_TURN: case BedrockStopReason.STOP_SEQUENCE: - return "stop"; + return { stopReason: "stop" }; case BedrockStopReason.MAX_TOKENS: case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED: - return "length"; + return { stopReason: "length" }; case BedrockStopReason.TOOL_USE: - return "toolUse"; + return { stopReason: "toolUse" }; default: - return "error"; + return reason ? { stopReason: "error", errorMessage: reason } : { stopReason: "error" }; } } From b084d2fb395f0f1aa924cb07b14e5d0edab115e2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 12:50:46 +0200 Subject: [PATCH 26/33] add legacy-peer-deps flag on pi uninstall when using npm (#6604) otherwise it would fail when there's conflicting peer dependencies in installed packages fixes: #6486 --- packages/coding-agent/src/core/package-manager.ts | 9 +++++++-- packages/coding-agent/test/package-manager.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 6b546d86..9f11b750 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1805,11 +1805,16 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(installRoot)) { return; } - if (this.getPackageManagerName() === "bun") { + const packageManagerName = this.getPackageManagerName(); + if (packageManagerName === "bun") { await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]); return; } - await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]); + const args = ["uninstall", source.name, "--prefix", installRoot]; + if (packageManagerName !== "pnpm") { + args.push("--legacy-peer-deps"); + } + await this.runNpmCommand(args); } private async installGit(source: GitSource, scope: SourceScope): Promise { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 19bda172..a4451e19 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -722,6 +722,19 @@ Content`, ); }); + it("should pass legacy peer deps when uninstalling npm packages", async () => { + mkdirSync(join(agentDir, "npm"), { recursive: true }); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.remove("npm:@scope/pkg"); + + expect(runCommandSpy).toHaveBeenCalledWith( + "npm", + ["uninstall", "@scope/pkg", "--prefix", join(agentDir, "npm"), "--legacy-peer-deps"], + undefined, + ); + }); + it("should use bun --cwd for npm package installs", async () => { settingsManager = SettingsManager.inMemory({ npmCommand: ["mise", "exec", "bun@1", "--", "bun"], From 1f0dbc008c9b3e88017d42e8a1b46d416ad2b6b6 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 17:27:36 +0200 Subject: [PATCH 27/33] backfill encrypted_content from response.completed for missing reasoning blocks (#6608) fixes #6409 --- .../ai/src/api/openai-responses-shared.ts | 21 +++ ...-openai-responses-reasoning-replay.test.ts | 136 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/ai/test/azure-openai-responses-reasoning-replay.test.ts diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 246ae22a..ddb3e734 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -337,6 +337,7 @@ export async function processResponsesStream( ): Promise { let sawTerminalResponseEvent = false; const outputSlots = new Map(); + const reasoningBlocksById = new Map(); const getSlot = ( outputIndex: number, type: TType, @@ -388,10 +389,29 @@ export async function processResponsesStream( const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => { return outputSlots.get(outputIndex) ?? createSlot(outputIndex, item); }; + // Azure OpenAI can omit reasoning.encrypted_content from response.output_item.done + // and provide it only in response.completed.response.output. Backfill the + // persisted reasoning signature from the terminal response to keep store:false + // multi-turn replay stateless. See https://github.com/earendil-works/pi/issues/6409. + const backfillReasoningSignatures = (responseOutput: ResponseOutputItem[]): void => { + for (const item of responseOutput) { + if (item.type !== "reasoning" || !item.encrypted_content) continue; + const block = reasoningBlocksById.get(item.id); + if (!block?.thinkingSignature) continue; + + const storedItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem; + if (storedItem.encrypted_content) continue; + block.thinkingSignature = JSON.stringify({ + ...storedItem, + encrypted_content: item.encrypted_content, + }); + } + }; const finalizeResponse = ( response: Extract["response"], ): void => { sawTerminalResponseEvent = true; + backfillReasoningSignatures(response.output ?? []); if (response?.id) { output.responseId = response.id; } @@ -519,6 +539,7 @@ export async function processResponsesStream( const contentText = item.content?.map((c) => c.text).join("\n\n") || ""; slot.block.thinking = summaryText || contentText || slot.block.thinking; slot.block.thinkingSignature = JSON.stringify(item); + reasoningBlocksById.set(item.id, slot.block); stream.push({ type: "thinking_end", contentIndex: slot.contentIndex, diff --git a/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts b/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts new file mode 100644 index 00000000..12c0fb62 --- /dev/null +++ b/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts @@ -0,0 +1,136 @@ +import type { ResponseReasoningItem, ResponseStreamEvent } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts"; +import type { AssistantMessage, Context, Model } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; + +function createModel(): Model<"azure-openai-responses"> { + return { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "https://example.invalid", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; +} + +function createOutput(model: Model<"azure-openai-responses">): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +async function* createEvents( + doneItem: ResponseReasoningItem, + completedItem: ResponseReasoningItem, +): AsyncIterable { + yield { + type: "response.output_item.added", + output_index: 0, + sequence_number: 0, + item: { type: "reasoning", id: doneItem.id, summary: [] }, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + output_index: 0, + sequence_number: 1, + item: doneItem, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { + id: "resp_test", + status: "completed", + output: [completedItem], + }, + } as ResponseStreamEvent; +} + +function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: AssistantMessage) { + const context: Context = { + messages: [ + { role: "user", content: "first", timestamp: Date.now() - 1 }, + assistant, + { role: "user", content: "follow-up", timestamp: Date.now() }, + ], + }; + const input = convertResponsesMessages(model, context, new Set(["azure-openai-responses"])); + return input.find((item) => item.type === "reasoning"); +} + +describe("Azure OpenAI Responses reasoning replay", () => { + it("preserves existing encrypted_content from output_item.done", async () => { + const model = createModel(); + const output = createOutput(model); + const doneItem: ResponseReasoningItem = { + type: "reasoning", + id: "rs_done", + summary: [], + encrypted_content: "from-output-item-done", + }; + const completedItem: ResponseReasoningItem = { + ...doneItem, + encrypted_content: "from-response-completed", + }; + + await processResponsesStream( + createEvents(doneItem, completedItem), + output, + new AssistantMessageEventStream(), + model, + ); + + expect(getReplayedReasoning(model, output)).toMatchObject({ + type: "reasoning", + id: "rs_done", + encrypted_content: "from-output-item-done", + }); + }); + + it("fills encrypted_content when output_item.done omitted it", async () => { + const model = createModel(); + const output = createOutput(model); + const doneItem: ResponseReasoningItem = { + type: "reasoning", + id: "rs_missing", + summary: [], + }; + const completedItem: ResponseReasoningItem = { + ...doneItem, + encrypted_content: "from-response-completed", + }; + + await processResponsesStream( + createEvents(doneItem, completedItem), + output, + new AssistantMessageEventStream(), + model, + ); + + expect(getReplayedReasoning(model, output)).toMatchObject({ + type: "reasoning", + id: "rs_missing", + encrypted_content: "from-response-completed", + }); + }); +}); From 0e6909f050eeb15e8f6c05185511f3788357ddb3 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Jul 2026 18:07:59 +0200 Subject: [PATCH 28/33] anthropic-messages: skip usage fields if empty (#6611) fixes: #6567 --- packages/ai/src/api/anthropic-messages.ts | 40 ++++++++++--------- .../ai/test/anthropic-sse-parsing.test.ts | 28 +++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index e5dc8bc6..6c419bce 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -703,25 +703,27 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( } // Only update usage fields if present (not null). // Preserves input_tokens from message_start when proxies omit it in message_delta. - if (event.usage.input_tokens != null) { - output.usage.input = event.usage.input_tokens; - } - if (event.usage.output_tokens != null) { - output.usage.output = event.usage.output_tokens; - } - if (event.usage.cache_read_input_tokens != null) { - output.usage.cacheRead = event.usage.cache_read_input_tokens; - } - if (event.usage.cache_creation_input_tokens != null) { - output.usage.cacheWrite = event.usage.cache_creation_input_tokens; - } - // Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the - // final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from - // its Usage type, so read it through a narrow cast. Verified against the live API. - const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } }) - .output_tokens_details?.thinking_tokens; - if (thinkingTokens != null) { - output.usage.reasoning = thinkingTokens; + if (event.usage) { + if (event.usage.input_tokens != null) { + output.usage.input = event.usage.input_tokens; + } + if (event.usage.output_tokens != null) { + output.usage.output = event.usage.output_tokens; + } + if (event.usage.cache_read_input_tokens != null) { + output.usage.cacheRead = event.usage.cache_read_input_tokens; + } + if (event.usage.cache_creation_input_tokens != null) { + output.usage.cacheWrite = event.usage.cache_creation_input_tokens; + } + // Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the + // final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from + // its Usage type, so read it through a narrow cast. Verified against the live API. + const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } }) + .output_tokens_details?.thinking_tokens; + if (thinkingTokens != null) { + output.usage.reasoning = thinkingTokens; + } } // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index e510ec55..0cdd0577 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -224,6 +224,34 @@ describe("Anthropic raw SSE parsing", () => { expect(result.errorMessage).toBe(explanation); }); + it("treats message_delta without usage as a no-op for usage accumulation", async () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + const context: Context = { + messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }], + }; + const response = createSseResponse( + minimalAnthropicEvents.map((event) => + event.event === "message_delta" + ? { + event: "message_delta", + data: JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } }), + } + : event, + ), + ); + + const stream = streamAnthropic(model, context, { + client: createFakeAnthropicClient(response), + }); + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + expect(result.content).toEqual([{ type: "text", text: "Hello" }]); + expect(result.usage.input).toBe(12); + expect(result.usage.totalTokens).toBe(12); + }); + it("ignores unknown SSE events after message_stop", async () => { const model = getModel("anthropic", "claude-haiku-4-5"); const context: Context = { From 961fa6c14228d3c652869256fa2349d1f08e6306 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 14 Jul 2026 11:01:21 +0200 Subject: [PATCH 29/33] feat(ai): add Radius gateway support --- packages/ai/src/api/pi-messages.lazy.ts | 4 + packages/ai/src/api/pi-messages.ts | 436 ++++++++++++++ packages/ai/src/compat.ts | 6 + packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/index.ts | 1 + packages/ai/src/providers/all.ts | 17 +- packages/ai/src/types.ts | 6 +- packages/ai/src/utils/oauth/index.ts | 16 + packages/ai/src/utils/oauth/radius.ts | 557 ++++++++++++++++++ packages/ai/test/pi-messages.test.ts | 243 ++++++++ .../coding-agent/src/core/model-registry.ts | 22 +- .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-display-names.ts | 1 + packages/coding-agent/src/core/radius.ts | 34 ++ packages/coding-agent/test/radius.test.ts | 146 +++++ test.sh | 3 + 16 files changed, 1483 insertions(+), 11 deletions(-) create mode 100644 packages/ai/src/api/pi-messages.lazy.ts create mode 100644 packages/ai/src/api/pi-messages.ts create mode 100644 packages/ai/src/utils/oauth/radius.ts create mode 100644 packages/ai/test/pi-messages.test.ts create mode 100644 packages/coding-agent/src/core/radius.ts create mode 100644 packages/coding-agent/test/radius.test.ts diff --git a/packages/ai/src/api/pi-messages.lazy.ts b/packages/ai/src/api/pi-messages.lazy.ts new file mode 100644 index 00000000..41af9ee5 --- /dev/null +++ b/packages/ai/src/api/pi-messages.lazy.ts @@ -0,0 +1,4 @@ +import type { ProviderStreams } from "../types.ts"; +import { lazyApi } from "./lazy.ts"; + +export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts")); diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts new file mode 100644 index 00000000..0af13f88 --- /dev/null +++ b/packages/ai/src/api/pi-messages.ts @@ -0,0 +1,436 @@ +/** + * pi-messages API implementation. + * + * Streams pi's own message protocol directly to a backend: the request is a + * single POST of `{ model, context, options }` to `/messages`, the + * response is an SSE stream of serialized assistant-message events plus a + * terminal `done`/`error` event. This is the wire protocol spoken by the + * Radius gateway, but any backend implementing it can be used, e.g. via a + * models.json custom provider with `"api": "pi-messages"`. + */ + +import type { + AssistantMessage, + AssistantMessageEvent, + CacheRetention, + Context, + Model, + ProviderEnv, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + ThinkingLevel, + ToolCall, +} from "../types.ts"; +import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; + +export interface PiMessagesOptions extends StreamOptions { + reasoning?: ThinkingLevel; + toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; + /** Ask the backend for debug metadata (e.g. routing response headers). */ + debug?: boolean; +} + +type PiMessagesUsage = AssistantMessage["usage"]; +type PiMessagesStopReason = AssistantMessage["stopReason"]; + +/** Impact summary of a server-side message rewrite (e.g. a gateway policy). */ +export type PiMessagesRewriteImpact = { + policyId: string; + policyVersion: number; + changed: boolean; + tokenCountChange: number; + messageCountChange: number; + systemPromptChanged: boolean; +}; + +/** Serialized assistant-message event as sent by a pi-messages backend. */ +export type PiMessagesEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; content: string; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { + type: "thinking_end"; + contentIndex: number; + content: string; + contentSignature?: string; + redacted?: boolean; + } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } + | { + type: "done"; + reason: Extract; + usage: PiMessagesUsage; + responseId?: string; + rewrite?: PiMessagesRewriteImpact; + } + | { + type: "error"; + reason: Extract; + usage: PiMessagesUsage; + errorMessage?: string; + responseId?: string; + rewrite?: PiMessagesRewriteImpact; + }; + +type PiMessagesErrorBody = { + error?: { + message?: unknown; + code?: unknown; + details?: unknown; + [key: string]: unknown; + }; +}; + +export class PiMessagesResponseError extends Error { + code?: string; + readonly diagnosticDetails: Record; + + constructor(message: string, code: string | undefined, diagnosticDetails: Record) { + super(message); + this.name = "PiMessagesResponseError"; + this.code = code; + this.diagnosticDetails = diagnosticDetails; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined { + try { + const parsed = JSON.parse(body) as unknown; + return isRecord(parsed) && isRecord(parsed.error) ? (parsed as PiMessagesErrorBody) : undefined; + } catch { + return undefined; + } +} + +function truncateDiagnosticString(value: string): string { + const maxLength = 8192; + return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; +} + +function formatPiMessagesResponseError( + response: Response, + body: string, + errorBody: PiMessagesErrorBody | undefined, +): string { + const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined; + const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined; + const suffix = message ?? body; + const codeSuffix = code ? ` (${code})` : ""; + return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`; +} + +function createPiMessagesResponseError( + model: Model<"pi-messages">, + url: URL, + response: Response, + body: string, +): PiMessagesResponseError { + const errorBody = parsePiMessagesErrorBody(body); + const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined; + return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, { + version: 1, + provider: model.provider, + model: model.id, + url: url.toString(), + status: response.status, + statusText: response.statusText, + error: errorBody?.error, + body: errorBody ? undefined : truncateDiagnosticString(body), + timestampMs: Date.now(), + }); +} + +function createEmptyUsage(): PiMessagesUsage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function appendRewriteDiagnostic(message: AssistantMessage, rewrite: PiMessagesRewriteImpact | undefined): void { + if (!rewrite) { + return; + } + appendAssistantMessageDiagnostic(message, { + type: "pi_messages_rewrite", + timestamp: Date.now(), + details: { ...rewrite }, + }); +} + +function createEventConverter(model: Model<"pi-messages">) { + const partial: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: createEmptyUsage(), + stopReason: "stop", + timestamp: Date.now(), + }; + const toolJson = new Map(); + + return (event: PiMessagesEvent): AssistantMessageEvent => { + switch (event.type) { + case "done": + Object.assign(partial, { + stopReason: event.reason, + usage: event.usage, + responseId: event.responseId, + }); + appendRewriteDiagnostic(partial, event.rewrite); + return { type: "done", reason: event.reason, message: partial }; + case "error": + Object.assign(partial, { + stopReason: event.reason, + usage: event.usage, + errorMessage: event.errorMessage, + responseId: event.responseId, + }); + appendRewriteDiagnostic(partial, event.rewrite); + return { type: "error", reason: event.reason, error: partial }; + case "start": + break; + case "text_start": + partial.content[event.contentIndex] = { type: "text", text: "" }; + break; + case "text_delta": + (partial.content[event.contentIndex] as { text: string }).text += event.delta; + break; + case "text_end": + Object.assign(partial.content[event.contentIndex]!, { + text: event.content, + textSignature: event.contentSignature, + }); + break; + case "thinking_start": + partial.content[event.contentIndex] = { type: "thinking", thinking: "" }; + break; + case "thinking_delta": + (partial.content[event.contentIndex] as { thinking: string }).thinking += event.delta; + break; + case "thinking_end": + Object.assign(partial.content[event.contentIndex]!, { + thinking: event.content, + thinkingSignature: event.contentSignature, + redacted: event.redacted, + }); + break; + case "toolcall_start": + partial.content[event.contentIndex] = { + type: "toolCall", + id: event.id, + name: event.toolName, + arguments: {}, + }; + toolJson.set(event.contentIndex, ""); + break; + case "toolcall_delta": { + const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`; + toolJson.set(event.contentIndex, json); + (partial.content[event.contentIndex] as ToolCall).arguments = + parseStreamingJson(json); + break; + } + case "toolcall_end": + Object.assign(partial.content[event.contentIndex]!, event.toolCall); + toolJson.delete(event.contentIndex); + return { + type: "toolcall_end", + contentIndex: event.contentIndex, + toolCall: partial.content[event.contentIndex] as ToolCall, + partial, + }; + } + + return { ...event, partial } as AssistantMessageEvent; + }; +} + +async function* readPiMessagesEvents(stream: ReadableStream): AsyncGenerator { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, "\n"); + + let split = buffer.indexOf("\n\n"); + while (split !== -1) { + const event = parsePiMessagesEvent(buffer.slice(0, split)); + if (event) { + yield event; + } + buffer = buffer.slice(split + 2); + split = buffer.indexOf("\n\n"); + } + + if (done) { + break; + } + } + + if (buffer.trim()) { + const event = parsePiMessagesEvent(buffer); + if (event) { + yield event; + } + } + } finally { + reader.releaseLock(); + } +} + +function parsePiMessagesEvent(raw: string): PiMessagesEvent | undefined { + const data = raw + .split("\n") + .find((line) => line.startsWith("data:")) + ?.slice(5) + .trim(); + + return data && data !== "[DONE]" ? (JSON.parse(data) as PiMessagesEvent) : undefined; +} + +function createErrorEvent(model: Model<"pi-messages">, error: unknown, aborted: boolean): AssistantMessageEvent { + const reason = aborted ? "aborted" : "error"; + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: createEmptyUsage(), + stopReason: reason, + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; + + if (!aborted && error instanceof PiMessagesResponseError) { + appendAssistantMessageDiagnostic( + assistantMessage, + createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails), + ); + } + + return { type: "error", reason, error: assistantMessage }; +} + +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention | undefined { + if (cacheRetention) { + return cacheRetention; + } + // Backend defaults apply when unset; only the legacy env opt-in is mapped. + return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined; +} + +export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = ( + model: Model<"pi-messages">, + context: Context, + options?: PiMessagesOptions, +): AssistantMessageEventStream => { + const eventStream = new AssistantMessageEventStream(); + const convertEvent = createEventConverter(model); + + void (async () => { + try { + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key provided for provider "${model.provider}"`); + } + + const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`); + if (options?.debug) { + url.searchParams.set("debug", "1"); + } + + let payload: unknown = { + model: model.id, + context, + options: { + temperature: options?.temperature, + maxTokens: options?.maxTokens, + reasoning: options?.reasoning, + cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env), + sessionId: options?.sessionId, + toolChoice: options?.toolChoice, + }, + }; + const nextPayload = await options?.onPayload?.(payload, model); + if (nextPayload !== undefined) { + payload = nextPayload; + } + + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + accept: "text/event-stream", + "content-type": "application/json", + ...providerHeadersToRecord(options?.headers), + }, + body: JSON.stringify(payload), + signal: options?.signal, + }); + + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); + + if (!response.ok) { + const body = await response.text(); + throw createPiMessagesResponseError(model, url, response, body); + } + if (!response.body) { + throw new Error(`${model.provider} response has no body`); + } + + for await (const piEvent of readPiMessagesEvents(response.body)) { + const event = convertEvent(piEvent); + eventStream.push(event); + if (event.type === "done" || event.type === "error") { + return; + } + } + + throw new Error(`${model.provider} stream ended without a terminal event`); + } catch (error) { + eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false)); + } + })(); + + return eventStream; +}; + +export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = ( + model: Model<"pi-messages">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const extra = options as PiMessagesOptions | undefined; + return stream(model, context, { + ...options, + reasoning: options?.reasoning, + toolChoice: extra?.toolChoice, + debug: extra?.debug, + }); +}; diff --git a/packages/ai/src/compat.ts b/packages/ai/src/compat.ts index eb1151fd..c2b9fc69 100644 --- a/packages/ai/src/compat.ts +++ b/packages/ai/src/compat.ts @@ -19,6 +19,7 @@ export * from "./api/mistral-conversations.lazy.ts"; export * from "./api/openai-codex-responses.lazy.ts"; export * from "./api/openai-completions.lazy.ts"; export * from "./api/openai-responses.lazy.ts"; +export * from "./api/pi-messages.lazy.ts"; export * from "./env-api-keys.ts"; export * from "./image-models.ts"; export * from "./images.ts"; @@ -36,8 +37,12 @@ import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts"; import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts"; import { openAICompletionsApi } from "./api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "./api/openai-responses.lazy.ts"; +import { piMessagesApi } from "./api/pi-messages.lazy.ts"; import { getEnvApiKey } from "./env-api-keys.ts"; import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts"; + +export type { BuiltinProvider } from "./providers/all.ts"; + import { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts"; import type { Api, @@ -179,6 +184,7 @@ const BUILTIN_APIS: [Api, ProviderStreams][] = [ ["google-vertex", googleVertexApi()], ["mistral-conversations", mistralConversationsApi()], ["bedrock-converse-stream", bedrockConverseStreamApi()], + ["pi-messages", piMessagesApi()], ]; const builtinApiProviderInstances = new Map>(); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 7bd955e1..8687fcc4 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -82,6 +82,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { groq: "GROQ_API_KEY", cerebras: "CEREBRAS_API_KEY", xai: "XAI_API_KEY", + radius: "PI_GATEWAY_API_KEY", openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", zai: "ZAI_API_KEY", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 57c448cc..25290c6d 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -17,6 +17,7 @@ export type { MistralOptions } from "./api/mistral-conversations.ts"; export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts"; export type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; export type { OpenAIResponsesOptions } from "./api/openai-responses.ts"; +export type { PiMessagesEvent, PiMessagesOptions, PiMessagesRewriteImpact } from "./api/pi-messages.ts"; export * from "./auth/context.ts"; export * from "./auth/credential-store.ts"; export * from "./auth/helpers.ts"; diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 85ba0301..ad919016 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -1,7 +1,7 @@ import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts"; import { MODELS } from "../models.generated.ts"; import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts"; -import type { Api, KnownProvider, Model } from "../types.ts"; +import type { Api, Model } from "../types.ts"; import { amazonBedrockProvider } from "./amazon-bedrock.ts"; import { antLingProvider } from "./ant-ling.ts"; import { anthropicProvider } from "./anthropic.ts"; @@ -39,13 +39,18 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts"; import { zaiProvider } from "./zai.ts"; import { zaiCodingCnProvider } from "./zai-coding-cn.ts"; +/** Providers present in the generated catalog. `KnownProvider` additionally + * includes purely dynamic providers (e.g. "radius") that have no static + * catalog entry. */ +export type BuiltinProvider = keyof typeof MODELS; + type BuiltinModelApi< - TProvider extends KnownProvider, + TProvider extends BuiltinProvider, TModelId extends keyof (typeof MODELS)[TProvider], > = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never; /** Typed read of the generated built-in catalog. */ -export function getBuiltinModel( +export function getBuiltinModel( provider: TProvider, modelId: TModelId, ): Model> { @@ -53,11 +58,11 @@ export function getBuiltinModel>; } -export function getBuiltinProviders(): KnownProvider[] { - return Object.keys(MODELS) as KnownProvider[]; +export function getBuiltinProviders(): BuiltinProvider[] { + return Object.keys(MODELS) as BuiltinProvider[]; } -export function getBuiltinModels( +export function getBuiltinModels( provider: TProvider, ): Model>[] { const models = MODELS[provider] as Record> | undefined; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 10530fae..69356b22 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -7,6 +7,7 @@ import type { MistralOptions } from "./api/mistral-conversations.ts"; import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts"; import type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "./api/openai-responses.ts"; +import type { PiMessagesOptions } from "./api/pi-messages.ts"; import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts"; import type { AssistantMessageEventStream } from "./utils/event-stream.ts"; @@ -21,7 +22,8 @@ export type KnownApi = | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" - | "google-vertex"; + | "google-vertex" + | "pi-messages"; export type Api = KnownApi | (string & {}); @@ -38,6 +40,7 @@ export type KnownProvider = | "openai" | "azure-openai-responses" | "openai-codex" + | "radius" | "nvidia" | "deepseek" | "github-copilot" @@ -202,6 +205,7 @@ export interface ApiOptionsMap { "google-vertex": GoogleVertexOptions; "mistral-conversations": MistralOptions; "bedrock-converse-stream": BedrockOptions; + "pi-messages": PiMessagesOptions; } /** diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index a57badda..e9e1f0e9 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -28,21 +28,37 @@ export { refreshOpenAICodexToken, } from "./openai-codex.ts"; +// Radius (pi-messages gateway) +export { + createRadiusOAuthProvider, + DEFAULT_RADIUS_GATEWAY, + type RadiusGatewayConfig, + type RadiusGatewayModel, + type RadiusOAuthCredentials, + type RadiusOAuthProviderOptions, +} from "./radius.ts"; export * from "./types.ts"; // ============================================================================ // Provider Registry // ============================================================================ +import { getProviderEnvValue } from "../provider-env.ts"; import { anthropicOAuthProvider } from "./anthropic.ts"; import { githubCopilotOAuthProvider } from "./github-copilot.ts"; import { openaiCodexOAuthProvider } from "./openai-codex.ts"; +import { createRadiusOAuthProvider, DEFAULT_RADIUS_GATEWAY } from "./radius.ts"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ anthropicOAuthProvider, githubCopilotOAuthProvider, openaiCodexOAuthProvider, + createRadiusOAuthProvider({ + id: "radius", + name: "Radius", + gateway: getProviderEnvValue("PI_GATEWAY") || DEFAULT_RADIUS_GATEWAY, + }), ]; const oauthProviderRegistry = new Map( diff --git a/packages/ai/src/utils/oauth/radius.ts b/packages/ai/src/utils/oauth/radius.ts new file mode 100644 index 00000000..077619ec --- /dev/null +++ b/packages/ai/src/utils/oauth/radius.ts @@ -0,0 +1,557 @@ +/** + * Radius gateway OAuth flow and model catalog loading. + * + * Radius is a pi-messages gateway. OAuth endpoints are discovered from the + * gateway (`/v1/oauth`); the model catalog comes from `/v1/config` and is + * cached on the stored credential (`gatewayConfig`) so models are available + * at startup and refreshed whenever the token refreshes. + * + * NOTE: This module uses node:http for the OAuth callback server. + * It is only intended for CLI use, not browser environments. + */ + +// NEVER convert to top-level imports - breaks browser/Vite builds +let _http: typeof import("node:http") | null = null; +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + import("node:http").then((m) => { + _http = m; + }); +} + +import type { Api, Model, ThinkingLevelMap } from "../../types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev"; + +const CALLBACK_HOST = "127.0.0.1"; +const CALLBACK_PORT = 1456; +const CALLBACK_PATH = "/oauth/callback"; +const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`; +const TOKEN_EXPIRY_SKEW_MS = 60_000; +const LOGIN_METHOD_BROWSER = "browser"; +const LOGIN_METHOD_DEVICE_CODE = "device-code"; + +/** Model metadata served by the gateway config endpoint. */ +export type RadiusGatewayModel = { + id: string; + name: string; + reasoning: boolean; + thinkingLevelMap?: ThinkingLevelMap; + input: ("text" | "image")[]; + cost: Model["cost"]; + contextWindow: number; + maxTokens: number; +}; + +export type RadiusGatewayConfig = { + baseUrl: string; + models: RadiusGatewayModel[]; +}; + +export type RadiusOAuthCredentials = OAuthCredentials & { + gatewayConfig?: RadiusGatewayConfig; +}; + +type RadiusOAuthConfig = { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + deviceAuthorizationEndpoint: string; + deviceAuthorizationEventsEndpoint: string; + verificationEndpoint: string; + clientId: string; + scope: string; + deviceCodeGrantType: string; +}; + +type DeviceAuthorizationResponse = { + device_code: string; + user_code: string; + verification_uri?: string; + verification_uri_complete?: string; + expires_in: number; + interval?: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeRadiusGatewayUrl(value: string): string { + const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`; + return withScheme.replace(/\/+$/u, ""); +} + +// The gateway is a trusted first-party service. The shape checks below only +// guard against version skew and stale credential caches: malformed entries +// are dropped rather than failing the whole catalog, and nested fields (e.g. +// `input` members, `cost` rates) are intentionally not validated in depth. +// Do not turn this into strict validation. +function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.reasoning === "boolean" && + Array.isArray(value.input) && + isRecord(value.cost) && + typeof value.contextWindow === "number" && + typeof value.maxTokens === "number" + ); +} + +function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined { + if (!isRecord(config)) { + return undefined; + } + const baseUrl = config.baseUrl; + const models = config.models; + if (typeof baseUrl !== "string" || !Array.isArray(models)) { + return undefined; + } + + return { + baseUrl, + models: models.filter(isRadiusGatewayModel).map((model) => ({ ...model })), + }; +} + +function getRadiusCredentialConfig(credentials: OAuthCredentials | undefined): RadiusGatewayConfig | undefined { + return sanitizeRadiusGatewayConfig((credentials as RadiusOAuthCredentials | undefined)?.gatewayConfig); +} + +function truncateHttpBody(body: string): string { + const trimmed = body.trim(); + return trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed; +} + +async function loadRadiusGatewayConfig(gateway: string, apiKey?: string): Promise { + const headers: Record = { accept: "application/json" }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(new URL("/v1/config", gateway), { headers }); + if (!response.ok) { + throw new Error( + `Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`, + ); + } + + const config = sanitizeRadiusGatewayConfig(await response.json()); + if (!config) { + throw new Error(`Invalid Radius config from ${gateway}`); + } + return config; +} + +async function loadRadiusOAuthConfig(gateway: string): Promise { + const response = await fetch(new URL("/v1/oauth", gateway), { + headers: { accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error( + `Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`, + ); + } + + return (await response.json()) as RadiusOAuthConfig; +} + +class OAuthResponseError extends Error { + readonly status: number; + readonly oauthError?: string; + + constructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) { + const detail = oauthError + ? description + ? `${oauthError}: ${description}` + : oauthError + : description || String(status); + super(`${message}: ${detail}`); + this.status = status; + this.oauthError = oauthError; + } +} + +async function readOAuthResponseError(response: Response, message: string): Promise { + const text = await response.text().catch(() => ""); + let oauthError: string | undefined; + let description: string | undefined; + + if (text) { + try { + const data = JSON.parse(text) as { error?: unknown; error_description?: unknown }; + oauthError = typeof data.error === "string" ? data.error : undefined; + description = typeof data.error_description === "string" ? data.error_description : undefined; + } catch { + description = text; + } + } + + return new OAuthResponseError(response.status, oauthError, description, message); +} + +async function requestOAuthToken( + oauth: RadiusOAuthConfig, + body: URLSearchParams, + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(oauth.tokenEndpoint, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body, + signal, + }); + } catch (error) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } + + if (!response.ok) { + throw await readOAuthResponseError(response, "Radius OAuth token request failed"); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS, + scope: data.scope, + }; +} + +type OAuthCallbackServer = { + waitForCode(): Promise; + close(): void; +}; + +function startOAuthCallbackServer( + expectedState: string, + signal: AbortSignal | undefined, +): Promise { + if (!_http) { + throw new Error("Radius OAuth is only available in Node.js environments"); + } + + let settle: (code: string | null) => void = () => {}; + let settled = false; + const wait = new Promise((resolve) => { + settle = resolve; + }); + const finish = (code: string | null) => { + if (settled) { + return; + } + settled = true; + signal?.removeEventListener("abort", onAbort); + settle(code); + }; + const onAbort = () => finish(null); + signal?.addEventListener("abort", onAbort, { once: true }); + + const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => { + response.statusCode = status; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(html); + }; + + const server = _http.createServer((request, response) => { + const url = new URL(request.url ?? "/", REDIRECT_URI); + if (url.pathname !== CALLBACK_PATH) { + sendPage(response, 404, oauthErrorHtml("Callback route not found.")); + return; + } + if (url.searchParams.get("state") !== expectedState) { + sendPage(response, 400, oauthErrorHtml("OAuth state mismatch.")); + return; + } + + const error = url.searchParams.get("error"); + if (error) { + sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error)); + finish(null); + return; + } + + const code = url.searchParams.get("code"); + if (!code) { + sendPage(response, 400, oauthErrorHtml("Missing authorization code.")); + return; + } + + sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page.")); + finish(code); + }); + + return new Promise((resolve) => { + server + .listen(CALLBACK_PORT, CALLBACK_HOST, () => { + resolve({ + waitForCode: () => wait, + close: () => { + finish(null); + server.close(); + }, + }); + }) + .once("error", () => { + finish(null); + resolve({ waitForCode: async () => null, close: () => {} }); + }); + }); +} + +async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = await generatePKCE(); + const state = crypto.randomUUID(); + const authorizeUrl = new URL(oauth.authorizationEndpoint); + authorizeUrl.search = new URLSearchParams({ + response_type: "code", + client_id: oauth.clientId, + redirect_uri: REDIRECT_URI, + scope: oauth.scope, + code_challenge: challenge, + code_challenge_method: "S256", + handoff: "url", + state, + }).toString(); + + const callbackServer = await startOAuthCallbackServer(state, callbacks.signal); + callbacks.onProgress?.(`Listening for OAuth callback on ${REDIRECT_URI}`); + callbacks.onAuth({ + url: authorizeUrl.toString(), + instructions: "Continue in your browser.", + }); + + try { + const code = await callbackServer.waitForCode(); + if (!code) { + if (callbacks.signal?.aborted) { + throw new Error("Login cancelled"); + } + throw new Error("OAuth callback did not complete."); + } + return await requestOAuthToken( + oauth, + new URLSearchParams({ + grant_type: "authorization_code", + client_id: oauth.clientId, + redirect_uri: REDIRECT_URI, + code, + code_verifier: verifier, + }), + callbacks.signal, + ); + } finally { + callbackServer.close(); + } +} + +async function requestDeviceAuthorization( + oauth: RadiusOAuthConfig, + signal: AbortSignal | undefined, +): Promise { + let response: Response; + try { + response = await fetch(oauth.deviceAuthorizationEndpoint, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }), + signal, + }); + } catch (error) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } + + if (!response.ok) { + throw await readOAuthResponseError(response, "Radius OAuth device authorization failed"); + } + + const data = (await response.json()) as Partial; + if (!data.device_code || !data.user_code || !data.expires_in) { + throw new Error("Radius OAuth device authorization response is missing required fields"); + } + + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri, + verification_uri_complete: data.verification_uri_complete, + expires_in: data.expires_in, + interval: data.interval, + }; +} + +async function loginWithDeviceCode( + oauth: RadiusOAuthConfig, + callbacks: OAuthLoginCallbacks, +): Promise { + const device = await requestDeviceAuthorization(oauth, callbacks.signal); + callbacks.onDeviceCode({ + userCode: device.user_code, + verificationUri: device.verification_uri || oauth.verificationEndpoint, + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + }); + + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + signal: callbacks.signal, + poll: async () => { + try { + const credentials = await requestOAuthToken( + oauth, + new URLSearchParams({ + grant_type: oauth.deviceCodeGrantType, + client_id: oauth.clientId, + device_code: device.device_code, + }), + callbacks.signal, + ); + return { status: "complete", value: credentials }; + } catch (error) { + if (!(error instanceof OAuthResponseError)) { + throw error; + } + switch (error.oauthError) { + case "authorization_pending": + return { status: "pending" }; + case "slow_down": + return { status: "slow_down" }; + case "expired_token": + return { status: "failed", message: "Device authorization expired." }; + case "access_denied": + return { status: "failed", message: "Device authorization was denied." }; + default: + throw error; + } + } + }, + }); +} + +async function attachGatewayConfig( + gateway: string, + credentials: OAuthCredentials, + previous?: OAuthCredentials, +): Promise { + try { + const config = await loadRadiusGatewayConfig(gateway, credentials.access); + return { ...credentials, gatewayConfig: config }; + } catch (error) { + // Keep the previous catalog so models do not vanish on transient + // config failures; the next token refresh retries. + const previousConfig = getRadiusCredentialConfig(previous); + if (previousConfig) { + return { ...credentials, gatewayConfig: previousConfig }; + } + // No catalog to retain (e.g. initial login): fail loudly instead of + // completing a sign-in that would register no models. + throw error; + } +} + +export interface RadiusOAuthProviderOptions { + id: string; + name: string; + gateway: string; +} + +export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions): OAuthProviderInterface { + const gateway = normalizeRadiusGatewayUrl(options.gateway); + + return { + id: options.id, + name: options.name, + + async login(callbacks: OAuthLoginCallbacks): Promise { + const oauth = await loadRadiusOAuthConfig(gateway); + const loginMethod = await callbacks.onSelect({ + message: `Sign in to ${options.name}:`, + options: [ + { id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" }, + { + id: LOGIN_METHOD_DEVICE_CODE, + label: "Sign in with device code (when signing in from another device)", + }, + ], + }); + if (!loginMethod) { + throw new Error("Login cancelled"); + } + + let credentials: OAuthCredentials; + if (loginMethod === LOGIN_METHOD_DEVICE_CODE) { + credentials = await loginWithDeviceCode(oauth, callbacks); + } else if (loginMethod === LOGIN_METHOD_BROWSER) { + credentials = await loginWithBrowser(oauth, callbacks); + } else { + throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); + } + + return attachGatewayConfig(gateway, credentials); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + const oauth = await loadRadiusOAuthConfig(gateway); + const refreshed = await requestOAuthToken( + oauth, + new URLSearchParams({ + grant_type: "refresh_token", + client_id: oauth.clientId, + refresh_token: credentials.refresh, + }), + ); + return attachGatewayConfig(gateway, refreshed, credentials); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, + + modifyModels(models: Model[], credentials: OAuthCredentials): Model[] { + const config = getRadiusCredentialConfig(credentials); + if (!config) { + return models; + } + + // Keep models already registered for this provider (e.g. models.json + // custom entries) and add catalog models that are not present. + const existingIds = new Set(models.filter((model) => model.provider === options.id).map((model) => model.id)); + const added = config.models + .filter((model) => !existingIds.has(model.id)) + .map( + (model) => + ({ + ...model, + api: "pi-messages", + provider: options.id, + baseUrl: config.baseUrl, + }) as Model, + ); + + return [...models, ...added]; + }, + }; +} diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts new file mode 100644 index 00000000..a689c9f2 --- /dev/null +++ b/packages/ai/test/pi-messages.test.ts @@ -0,0 +1,243 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { type PiMessagesOptions, stream, streamSimple } from "../src/api/pi-messages.ts"; +import type { Api, AssistantMessageEvent, Context, Model } from "../src/types.ts"; + +type RecordedRequest = { + url: string; + headers: IncomingMessage["headers"]; + body: unknown; +}; + +type ResponderOptions = { + status?: number; + headers?: Record; + events?: unknown[]; + rawBody?: string; +}; + +let server: Server | undefined; + +afterEach(() => { + server?.close(); + server = undefined; +}); + +async function startServer(options: ResponderOptions): Promise<{ baseUrl: string; requests: RecordedRequest[] }> { + const requests: RecordedRequest[] = []; + + server = createServer((request: IncomingMessage, response: ServerResponse) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf-8"); + requests.push({ + url: request.url ?? "", + headers: request.headers, + body: raw ? JSON.parse(raw) : undefined, + }); + + if (options.status && options.status !== 200) { + response.statusCode = options.status; + response.setHeader("content-type", "application/json"); + response.end(options.rawBody ?? "{}"); + return; + } + + response.statusCode = 200; + response.setHeader("content-type", "text/event-stream"); + for (const [name, value] of Object.entries(options.headers ?? {})) { + response.setHeader(name, value); + } + for (const event of options.events ?? []) { + response.write(`data: ${JSON.stringify(event)}\n\n`); + } + response.end(); + }); + }); + + await new Promise((resolve) => { + server!.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server!.address() as AddressInfo; + return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests }; +} + +function createModel(baseUrl: string): Model<"pi-messages"> { + return { + id: "auto", + name: "Radius Auto", + api: "pi-messages", + provider: "radius", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + contextWindow: 128000, + maxTokens: 16384, + }; +} + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], +}; + +const usage = { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 }, +}; + +describe("pi-messages", () => { + it("streams text and tool calls and resolves the terminal message", async () => { + const { baseUrl, requests } = await startServer({ + events: [ + { type: "start" }, + { type: "text_start", contentIndex: 0 }, + { type: "text_delta", contentIndex: 0, delta: "Hel" }, + { type: "text_delta", contentIndex: 0, delta: "lo" }, + { type: "text_end", contentIndex: 0, content: "Hello" }, + { type: "toolcall_start", contentIndex: 1, id: "call_1", toolName: "read" }, + { type: "toolcall_delta", contentIndex: 1, delta: '{"path":' }, + { type: "toolcall_delta", contentIndex: 1, delta: '"a.txt"}' }, + { + type: "toolcall_end", + contentIndex: 1, + toolCall: { type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } }, + }, + { type: "done", reason: "toolUse", usage, responseId: "resp_1" }, + ], + }); + const model = createModel(baseUrl); + + const events: AssistantMessageEvent[] = []; + const eventStream = stream(model, context, { + apiKey: "test-key", + sessionId: "session-1", + toolChoice: "auto", + maxTokens: 100, + headers: { "x-custom": "1" }, + }); + for await (const event of eventStream) { + events.push(event); + } + const message = await eventStream.result(); + + expect(message.stopReason).toBe("toolUse"); + expect(message.usage).toEqual(usage); + expect(message.responseId).toBe("resp_1"); + expect(message.model).toBe("auto"); + expect(message.provider).toBe("radius"); + expect(message.content).toEqual([ + { type: "text", text: "Hello", textSignature: undefined }, + { type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } }, + ]); + expect(events.some((event) => event.type === "text_delta")).toBe(true); + expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(1); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.url).toBe("/v1/messages"); + expect(request.headers.authorization).toBe("Bearer test-key"); + expect(request.headers["x-custom"]).toBe("1"); + expect(request.body).toEqual({ + model: "auto", + context, + options: { maxTokens: 100, sessionId: "session-1", toolChoice: "auto" }, + }); + }); + + it("appends debug=1 and reports response headers via onResponse", async () => { + const { baseUrl, requests } = await startServer({ + headers: { "x-pi-gateway-upstream-provider": "anthropic" }, + events: [{ type: "done", reason: "stop", usage }], + }); + const model = createModel(baseUrl); + + let observedHeaders: Record | undefined; + const options: PiMessagesOptions = { + apiKey: "test-key", + debug: true, + onResponse: (response) => { + observedHeaders = response.headers; + }, + }; + const message = await streamSimple(model, context, options).result(); + + expect(message.stopReason).toBe("stop"); + expect(requests[0].url).toBe("/v1/messages?debug=1"); + expect(observedHeaders?.["x-pi-gateway-upstream-provider"]).toBe("anthropic"); + }); + + it("surfaces backend error responses with diagnostics", async () => { + const { baseUrl } = await startServer({ + status: 401, + rawBody: JSON.stringify({ error: { message: "Token expired", code: "unauthorized" } }), + }); + const model = createModel(baseUrl); + + const message = await stream(model, context, { apiKey: "stale" }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toContain("401"); + expect(message.errorMessage).toContain("Token expired"); + expect(message.errorMessage).toContain("unauthorized"); + expect(message.diagnostics?.[0]?.type).toBe("pi_messages_response_failure"); + expect(message.diagnostics?.[0]?.details?.status).toBe(401); + }); + + it("propagates server-sent error events", async () => { + const { baseUrl } = await startServer({ + events: [{ type: "start" }, { type: "error", reason: "error", usage, errorMessage: "Upstream failed" }], + }); + const model = createModel(baseUrl); + + const message = await stream(model, context, { apiKey: "test-key" }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toBe("Upstream failed"); + expect(message.usage).toEqual(usage); + }); + + it("errors when no API key is provided", async () => { + const model = createModel("http://127.0.0.1:1/v1"); + + const message = await stream(model, context).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toContain("No API key provided"); + }); + + it("errors when the stream ends without a terminal event", async () => { + const { baseUrl } = await startServer({ + events: [ + { type: "start" }, + { type: "text_start", contentIndex: 0 }, + { type: "text_delta", contentIndex: 0, delta: "partial" }, + ], + }); + const model = createModel(baseUrl); + + const message = await stream(model, context, { apiKey: "test-key" }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toContain("stream ended without a terminal event"); + }); +}); + +describe("pi-messages api registration", () => { + it("is registered as a builtin api provider", async () => { + const { getApiProvider } = await import("../src/compat.ts"); + expect(getApiProvider("pi-messages")).toBeDefined(); + }); + + it("is a known api usable on models", () => { + const api: Api = "pi-messages"; + expect(api).toBe("pi-messages"); + }); +}); diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index cb84fe5d..450ea15e 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -6,10 +6,10 @@ import { type AnthropicMessagesCompat, type Api, type AssistantMessageEventStream, + type BuiltinProvider, type Context, getModels, getProviders, - type KnownProvider, type Model, type OAuthProviderInterface, type OpenAICompletionsCompat, @@ -29,6 +29,7 @@ import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; +import { registerCustomRadiusOAuthProvider } from "./radius.ts"; import { clearConfigValueCache, getConfigValueEnvVarNames, @@ -224,6 +225,9 @@ const ProviderConfigSchema = Type.Object({ baseUrl: Type.Optional(Type.String({ minLength: 1 })), apiKey: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional(Type.String({ minLength: 1 })), + /** OAuth flavor spoken by this provider's endpoint. Registers a sign-in + * provider with a dynamic model catalog (e.g. a custom Radius gateway). */ + oauth: Type.Optional(Type.Literal("radius")), headers: Type.Optional(Type.Record(Type.String(), Type.String())), compat: Type.Optional(ProviderCompatSchema), authHeader: Type.Optional(Type.Boolean()), @@ -453,7 +457,7 @@ export class ModelRegistry { modelOverrides: Map>, ): Model[] { return getProviders().flatMap((provider) => { - const models = getModels(provider as KnownProvider) as Model[]; + const models = getModels(provider as BuiltinProvider) as Model[]; const providerOverride = overrides.get(provider); const perModelOverrides = modelOverrides.get(provider); @@ -537,6 +541,12 @@ export class ModelRegistry { }); } + if (providerConfig.oauth === "radius") { + // Must run before the modifyModels loop in loadModels() so the + // credential-cached catalog is injected on this load. + registerCustomRadiusOAuthProvider(providerName, providerConfig.name, providerConfig.baseUrl!); + } + this.storeProviderRequestConfig(providerName, providerConfig); if (providerConfig.modelOverrides) { @@ -568,7 +578,11 @@ export class ModelRegistry { const hasModelOverrides = providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0; - if (models.length === 0) { + if (providerConfig.oauth && !providerConfig.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when "oauth" is set.`); + } + + if (models.length === 0 && !providerConfig.oauth) { // Override-only config: needs baseUrl, headers, compat, modelOverrides, or some combination. if (!providerConfig.baseUrl && !providerConfig.headers && !providerConfig.compat && !hasModelOverrides) { throw new Error( @@ -614,7 +628,7 @@ export class ModelRegistry { const getBuiltInDefaults = (providerName: string): { api: string; baseUrl: string } | undefined => { if (!builtInProviders.has(providerName)) return undefined; if (builtInDefaultsCache.has(providerName)) return builtInDefaultsCache.get(providerName); - const builtIn = getModels(providerName as KnownProvider) as Model[]; + const builtIn = getModels(providerName as BuiltinProvider) as Model[]; if (builtIn.length === 0) return undefined; const defaults = { api: builtIn[0].api, baseUrl: builtIn[0].baseUrl }; builtInDefaultsCache.set(providerName, defaults); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 3a3341df..c23fe59b 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record = { openai: "gpt-5.5", "azure-openai-responses": "gpt-5.4", "openai-codex": "gpt-5.5", + radius: "auto", nvidia: "nvidia/nemotron-3-super-120b-a12b", deepseek: "deepseek-v4-pro", google: "gemini-3.1-pro-preview", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index d33c3d7d..fd29d713 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -23,6 +23,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "opencode-go": "OpenCode Go", openai: "OpenAI", openrouter: "OpenRouter", + radius: "Radius", together: "Together AI", "vercel-ai-gateway": "Vercel AI Gateway", xai: "xAI", diff --git a/packages/coding-agent/src/core/radius.ts b/packages/coding-agent/src/core/radius.ts new file mode 100644 index 00000000..93ac3301 --- /dev/null +++ b/packages/coding-agent/src/core/radius.ts @@ -0,0 +1,34 @@ +/** + * Radius (pi-messages gateway) provider wiring. + * + * The main Radius provider is a built-in OAuth provider in pi-ai; models are + * dynamic, cached on the stored OAuth credential (`gatewayConfig`) and + * injected via the OAuth provider's `modifyModels` hook, so startup, /reload, + * and registry refreshes work without network access. The catalog refreshes + * on login and on every token refresh. + * + * Additional gateways (e.g. a local dev gateway) can be declared in + * models.json with `"oauth": "radius"`; each entry is an independent Radius + * instance with its own credentials and catalog. + */ + +import { createRadiusOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; + +export const RADIUS_PROVIDER_ID = "radius"; + +/** + * Register a Radius-style OAuth provider for a custom gateway declared in + * models.json (`"oauth": "radius"`). Runs on every models.json load so the + * registration survives `resetOAuthProviders()` during registry refreshes. + */ +export function registerCustomRadiusOAuthProvider(id: string, name: string | undefined, gateway: string): void { + registerOAuthProvider( + createRadiusOAuthProvider({ + id, + name: name ?? id, + // Tolerate an API base URL: the gateway root is what the OAuth and + // config discovery endpoints hang off. + gateway: gateway.replace(/\/v1\/?$/u, ""), + }), + ); +} diff --git a/packages/coding-agent/test/radius.test.ts b/packages/coding-agent/test/radius.test.ts new file mode 100644 index 00000000..99ba8db1 --- /dev/null +++ b/packages/coding-agent/test/radius.test.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { RADIUS_PROVIDER_ID } from "../src/core/radius.ts"; + +function radiusOAuthCredential(gatewayBaseUrl: string) { + return { + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60 * 1000, + gatewayConfig: { + baseUrl: gatewayBaseUrl, + models: [ + { + id: "auto", + name: "Radius Auto", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + contextWindow: 128000, + maxTokens: 16384, + }, + { + id: "byok/gpt-5.5", + name: "GPT-5.5 (BYOK)", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 32000, + }, + ], + }, + }; +} + +let tempDir: string; + +beforeEach(() => { + tempDir = join(tmpdir(), `pi-test-radius-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + resetOAuthProviders(); +}); + +afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + resetOAuthProviders(); +}); + +describe("radius oauth provider", () => { + it("is registered as a built-in OAuth provider", () => { + expect(getOAuthProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius"); + }); +}); + +describe("radius models via ModelRegistry", () => { + it("injects catalog models from the stored credential", () => { + const registry = ModelRegistry.inMemory( + AuthStorage.inMemory({ radius: radiusOAuthCredential("https://radius.example.com/v1") }), + ); + + const auto = registry.find(RADIUS_PROVIDER_ID, "auto"); + expect(auto).toBeDefined(); + expect(auto?.api).toBe("pi-messages"); + expect(auto?.baseUrl).toBe("https://radius.example.com/v1"); + expect(auto?.name).toBe("Radius Auto"); + + // byok ids are registered verbatim + const byok = registry.find(RADIUS_PROVIDER_ID, "byok/gpt-5.5"); + expect(byok).toBeDefined(); + expect(byok?.contextWindow).toBe(256000); + + expect(registry.hasConfiguredAuth(auto!)).toBe(true); + expect(registry.getProviderDisplayName(RADIUS_PROVIDER_ID)).toBe("Radius"); + }); + + it("exposes no radius models without credentials", () => { + const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); + + expect(registry.getAll().filter((model) => model.provider === RADIUS_PROVIDER_ID)).toHaveLength(0); + expect(getOAuthProvider(RADIUS_PROVIDER_ID)).toBeDefined(); + }); + + it("keeps radius models across registry refresh", () => { + const registry = ModelRegistry.inMemory( + AuthStorage.inMemory({ radius: radiusOAuthCredential("https://radius.example.com/v1") }), + ); + + registry.refresh(); + + expect(registry.find(RADIUS_PROVIDER_ID, "auto")).toBeDefined(); + }); +}); + +describe("custom radius gateways via models.json", () => { + function createRegistry(providers: Record, authStorage: AuthStorage): ModelRegistry { + const modelsJsonPath = join(tempDir, "models.json"); + writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + return ModelRegistry.create(authStorage, modelsJsonPath); + } + + it("registers an independent radius-style provider", () => { + const registry = createRegistry( + { "radius-dev": { name: "Radius (dev)", baseUrl: "http://localhost:8788", oauth: "radius" } }, + AuthStorage.inMemory({ "radius-dev": radiusOAuthCredential("http://localhost:8788/v1") }), + ); + + expect(registry.getError()).toBeUndefined(); + expect(getOAuthProvider("radius-dev")?.name).toBe("Radius (dev)"); + expect(getOAuthProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius"); + + // Dev gateway models are injected under the custom provider id only. + const devAuto = registry.find("radius-dev", "auto"); + expect(devAuto).toBeDefined(); + expect(devAuto?.api).toBe("pi-messages"); + expect(devAuto?.baseUrl).toBe("http://localhost:8788/v1"); + expect(registry.find(RADIUS_PROVIDER_ID, "auto")).toBeUndefined(); + + expect(registry.getProviderDisplayName("radius-dev")).toBe("Radius (dev)"); + }); + + it("survives registry refresh", () => { + const registry = createRegistry( + { "radius-dev": { baseUrl: "http://localhost:8788", oauth: "radius" } }, + AuthStorage.inMemory({ "radius-dev": radiusOAuthCredential("http://localhost:8788/v1") }), + ); + + registry.refresh(); + + expect(getOAuthProvider("radius-dev")).toBeDefined(); + expect(registry.find("radius-dev", "auto")).toBeDefined(); + }); + + it("requires baseUrl when oauth is set", () => { + const registry = createRegistry({ "radius-dev": { oauth: "radius" } }, AuthStorage.inMemory()); + + expect(registry.getError()).toContain('"baseUrl" is required when "oauth" is set'); + }); +}); diff --git a/test.sh b/test.sh index b4ea70c7..f2ff5f68 100755 --- a/test.sh +++ b/test.sh @@ -55,6 +55,9 @@ unset XIAOMI_API_KEY unset XIAOMI_TOKEN_PLAN_CN_API_KEY unset XIAOMI_TOKEN_PLAN_AMS_API_KEY unset XIAOMI_TOKEN_PLAN_SGP_API_KEY +unset PI_GATEWAY_API_KEY +unset PI_GATEWAY +unset PI_EXPERIMENTAL unset COPILOT_GITHUB_TOKEN unset GH_TOKEN unset GITHUB_TOKEN From adfac437bbe3cd43182624a0f57ad0b244b75be8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 14 Jul 2026 12:24:50 +0200 Subject: [PATCH 30/33] fix(coding-agent): clarify login options --- .../coding-agent/src/modes/interactive/interactive-mode.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b745c454..e150e20e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4894,8 +4894,8 @@ export class InteractiveMode { } private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void { - const subscriptionLabel = "Use a subscription"; - const apiKeyLabel = "Use an API key"; + const subscriptionLabel = "Sign in with an account"; + const apiKeyLabel = "Sign in with an API key"; const availableAuthTypes = providerOptions ? new Set(providerOptions.map((provider) => provider.authType)) : new Set(["oauth", "api_key"]); From 92ffae5228ff1d7f7f215e85106afd02cd916b0f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 14 Jul 2026 12:29:38 +0200 Subject: [PATCH 31/33] fix(ai): type Anthropic probes by catalog providers --- packages/ai/test/anthropic-eager-tool-input-e2e.test.ts | 8 ++++---- .../ai/test/anthropic-long-cache-retention-e2e.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts index 680f0648..6f6c8bd2 100644 --- a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -1,6 +1,6 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { complete, getModels, getProviders } from "../src/compat.ts"; +import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts"; import { getEnvApiKey } from "../src/env-api-keys.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; @@ -19,7 +19,7 @@ const echoTool: Tool = { interface AnthropicEagerE2ECase { name: string; - provider: KnownProvider; + provider: BuiltinProvider; model: Model<"anthropic-messages">; apiKey: string | undefined; } @@ -31,7 +31,7 @@ function getE2EApiKey(provider: KnownProvider): string | undefined { return getEnvApiKey(provider); } -function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] { +function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] { const models = getModels(provider) as Model[]; return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[]; } @@ -64,7 +64,7 @@ function getProbePriority(model: Model<"anthropic-messages">): number { } function selectOneCasePerProvider(cases: AnthropicEagerE2ECase[]): AnthropicEagerE2ECase[] { - const byProvider = new Map(); + const byProvider = new Map(); for (const testCase of cases) { const providerCases = byProvider.get(testCase.provider) ?? []; providerCases.push(testCase); diff --git a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts index 042d3536..7855c872 100644 --- a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts +++ b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { complete, getModels, getProviders } from "../src/compat.ts"; +import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts"; import { getEnvApiKey } from "../src/env-api-keys.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; @@ -8,7 +8,7 @@ const githubCopilotToken = await resolveApiKey("github-copilot"); interface AnthropicLongCacheRetentionE2ECase { name: string; - provider: KnownProvider; + provider: BuiltinProvider; model: Model<"anthropic-messages">; apiKey: string | undefined; } @@ -20,7 +20,7 @@ function getE2EApiKey(provider: KnownProvider): string | undefined { return getEnvApiKey(provider); } -function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] { +function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] { const models = getModels(provider) as Model[]; return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[]; } @@ -51,7 +51,7 @@ function getProbePriority(model: Model<"anthropic-messages">): number { } function selectOneCasePerProvider(cases: AnthropicLongCacheRetentionE2ECase[]): AnthropicLongCacheRetentionE2ECase[] { - const byProvider = new Map(); + const byProvider = new Map(); for (const testCase of cases) { const providerCases = byProvider.get(testCase.provider) ?? []; providerCases.push(testCase); From 1f9e846c84f7d53356e7904e53f67b479d6f9c86 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 14 Jul 2026 15:57:01 +0200 Subject: [PATCH 32/33] don't send session-id header to opencode openai-responses models (#6645) fixes #6625 --- packages/ai/scripts/generate-models.ts | 3 +- packages/ai/src/providers/opencode.models.ts | 75 ++++++++++++++++++- ...est.ts => openai-responses-compat.test.ts} | 20 +++++ 3 files changed, 96 insertions(+), 2 deletions(-) rename packages/ai/test/{openai-responses-copilot-provider.test.ts => openai-responses-compat.test.ts} (94%) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 4fd84d86..5fad8aaf 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1370,11 +1370,12 @@ async function loadModelsDevData(): Promise[]> { const npm = m.provider?.npm; let api: Api; let baseUrl: string; - let compat: OpenAICompletionsCompat | undefined; + let compat: OpenAICompletionsCompat | OpenAIResponsesCompat | undefined; if (npm === "@ai-sdk/openai") { api = "openai-responses"; baseUrl = `${variant.basePath}/v1`; + compat = { sessionAffinityFormat: "openai-nosession" }; } else if (npm === "@ai-sdk/anthropic") { api = "anthropic-messages"; // Anthropic SDK appends /v1/messages to baseURL diff --git a/packages/ai/src/providers/opencode.models.ts b/packages/ai/src/providers/opencode.models.ts index ad18727a..cd2cfd34 100644 --- a/packages/ai/src/providers/opencode.models.ts +++ b/packages/ai/src/providers/opencode.models.ts @@ -392,6 +392,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -410,6 +411,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -428,6 +430,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -446,6 +449,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -464,6 +468,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -482,6 +487,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -500,6 +506,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -518,6 +525,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -536,6 +544,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -554,6 +563,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -572,6 +582,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -590,6 +601,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -608,6 +620,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -626,6 +639,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -644,6 +658,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -662,6 +677,7 @@ export const OPENCODE_MODELS = { api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], @@ -674,6 +690,63 @@ export const OPENCODE_MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, + "gpt-5.6-luna": { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 1, + output: 6, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.6-terra": { + id: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 3.125, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, "grok-4.5": { id: "grok-4.5", name: "Grok 4.5", @@ -726,7 +799,7 @@ export const OPENCODE_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 190000, maxTokens: 64000, } satisfies Model<"openai-completions">, "kimi-k2.5": { diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-compat.test.ts similarity index 94% rename from packages/ai/test/openai-responses-copilot-provider.test.ts rename to packages/ai/test/openai-responses-compat.test.ts index 8eeedea7..cabab825 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -360,6 +360,26 @@ describe("openai-responses provider defaults", () => { expect(capturedPayload?.prompt_cache_key).toBe("session-proxy"); }); + it("uses OpenAI no-session format for OpenCode Responses models", async () => { + const model = getModel("opencode", "gpt-5.4"); + let capturedPayload: CapturedResponsesPayload | undefined; + const captured = await captureOpenAIResponseHeaders( + { + sessionId: "session-opencode", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + model, + ); + + expect(model.compat?.sessionAffinityFormat).toBe("openai-nosession"); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBe("session-opencode"); + expect(captured.xSessionId).toBeNull(); + expect(capturedPayload?.prompt_cache_key).toBe("session-opencode"); + }); + it("can omit OpenAI session_id header while preserving other affinity data", async () => { const proxyModel: Model<"openai-responses"> = { ...getModel("openai", "gpt-5.4"), From f4e9ca7466b5576090d1093c27fe38d73909f3d2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 14 Jul 2026 14:58:24 +0000 Subject: [PATCH 33/33] remove current date from system prompt fixes #6621 --- packages/coding-agent/src/core/system-prompt.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 7580782b..d34ff88d 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -36,14 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { contextFiles: providedContextFiles, skills: providedSkills, } = options; - const resolvedCwd = cwd; - const promptCwd = resolvedCwd.replace(/\\/g, "/"); - - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const date = `${year}-${month}-${day}`; + const promptCwd = cwd.replace(/\\/g, "/"); const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : ""; @@ -73,8 +66,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { prompt += formatSkillsForPrompt(skills); } - // Add date and working directory last - prompt += `\nCurrent date: ${date}`; prompt += `\nCurrent working directory: ${promptCwd}`; return prompt; @@ -165,8 +156,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi prompt += formatSkillsForPrompt(skills); } - // Add date and working directory last - prompt += `\nCurrent date: ${date}`; prompt += `\nCurrent working directory: ${promptCwd}`; return prompt;