From f7175bfb1ebe0ef9e90c81254d146358bb4bc126 Mon Sep 17 00:00:00 2001 From: Claire Novotny Date: Sat, 3 Oct 2026 14:11:27 -0400 Subject: [PATCH 1/2] fix(anthropic): preserve deferred tool references on native OAuth requests --- src/adapters/anthropic/passthrough.ts | 34 +++++++++++++++---- structure/data-planes/protocol-paths.md | 4 +++ ...thropic-messages-passthrough-oauth.test.ts | 12 +++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/adapters/anthropic/passthrough.ts b/src/adapters/anthropic/passthrough.ts index 00af4870fcc..228c69a2746 100644 --- a/src/adapters/anthropic/passthrough.ts +++ b/src/adapters/anthropic/passthrough.ts @@ -150,14 +150,36 @@ export function anthropicOAuthWireBody(body: Rec): { body: Rec; toolNames: Map isRec(block) && block.type === "tool_use" && renames(block.name); + const mapContentBlocks = (blocks: unknown[]): { blocks: unknown[]; changed: boolean } => { + let changed = false; + const mapped = blocks.map(block => { + if (!isRec(block)) return block; + if (block.type === "tool_use" && renames(block.name)) { + changed = true; + return { ...block, name: wireName(block.name) }; + } + if (block.type === "tool_reference" && renames(block.tool_name)) { + changed = true; + return { ...block, tool_name: wireName(block.tool_name) }; + } + // tool_reference blocks are nested inside tool_result content. Follow only that + // typed content container; tool inputs and schemas are opaque caller data. + if (block.type === "tool_result" && Array.isArray(block.content)) { + const nested = mapContentBlocks(block.content); + if (nested.changed) { + changed = true; + return { ...block, content: nested.blocks }; + } + } + return block; + }); + return { blocks: mapped, changed }; + }; if (Array.isArray(body.messages)) { out.messages = body.messages.map(message => { - if (!isRec(message) || !Array.isArray(message.content) || !message.content.some(isRenamedUse)) return message; - return { - ...message, - content: message.content.map(block => isRenamedUse(block) ? { ...block, name: wireName(block.name) } : block), - }; + if (!isRec(message) || !Array.isArray(message.content)) return message; + const content = mapContentBlocks(message.content); + return content.changed ? { ...message, content: content.blocks } : message; }); } return { body: out, toolNames }; diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 3206e5acfe8..1cf814e16e4 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -435,3 +435,7 @@ implicitly. The three are declared as `api` capabilities in `src/cli/capabilitie routes carry no exemption in `src/server/management/route-registry.ts`; the capability mutation check reads the registry's `mutates`, so the read-only plan POST is not a write. `tests/cli/cli-api-protocols.test.ts` pins the requests, the usage errors and that every protocol route is verbed. + +Native OAuth Messages rename typed `tool_reference.tool_name` blocks consistently with declared +client tools, including nested tool-result content. Cache markers and lifetimes are retained, +and arbitrary tool arguments and input schemas are not traversed. diff --git a/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts index 663fd325295..8bb0e64e5f3 100644 --- a/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts +++ b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts @@ -40,11 +40,14 @@ const SOURCE = { messages: [ { role: "user", content: "fixture question" }, { role: "assistant", content: [ - { type: "tool_use", id: "toolu_1", name: "lookup", input: {} }, + { type: "tool_use", id: "toolu_1", name: "lookup", input: { opaque: { type: "tool_reference", tool_name: "lookup" } } }, { type: "tool_use", id: "toolu_2", name: "bash", input: { command: "true" } }, ] }, { role: "user", content: [ - { type: "tool_result", tool_use_id: "toolu_1", content: "fixture result" }, + { type: "tool_result", tool_use_id: "toolu_1", content: [ + { type: "text", text: "fixture result", cache_control: { type: "ephemeral", ttl: "1h", scope: "turn" } }, + { type: "tool_reference", tool_name: "lookup" }, + ] }, { type: "tool_result", tool_use_id: "toolu_2", content: "" }, ] }, ], @@ -100,10 +103,15 @@ describe("buildAnthropicMessagesPassthroughRequest with OAuth", () => { expect(wire.tool_choice).toEqual({ type: "tool", name: "custom_lookup" }); const history = wire.messages[1]!.content as { name: string }[]; expect(history.map(block => block.name)).toEqual(["custom_lookup", "bash"]); + expect((history[0] as { input: unknown }).input).toEqual({ opaque: { type: "tool_reference", tool_name: "lookup" } }); + const resultContent = (wire.messages[2]!.content as { content: { type: string; tool_name?: string; cache_control?: unknown }[] }[])[0]!.content; + expect(resultContent[1]).toEqual({ type: "tool_reference", tool_name: "custom_lookup" }); + expect(resultContent[0]!.cache_control).toEqual({ type: "ephemeral", ttl: "1h", scope: "turn" }); expect([...built.oauthToolNames!]).toEqual([["custom_lookup", "lookup"]]); // The source is untouched. expect(SOURCE.tools[0]!.name).toBe("lookup"); expect(SOURCE.system).toBe("fixture system"); + expect((SOURCE.messages[2]!.content[0] as { content: { tool_name?: string }[] }).content[1]!.tool_name).toBe("lookup"); }); test("an identity block already present is not repeated", () => { From 8e5c78912222b1616709d664c23de254dd71b7c9 Mon Sep 17 00:00:00 2001 From: Claire Novotny Date: Sat, 3 Oct 2026 14:30:40 -0400 Subject: [PATCH 2/2] docs: place native tool reference contract with OAuth --- structure/data-planes/protocol-paths.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 1cf814e16e4..237e6682700 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -355,6 +355,9 @@ builds without a provider UUID. Key-auth and caller-forward requests retain their metadata. The answer's `tool_use` names are mapped back for exactly those names. A 401 or 429 is answered as the bridge answers an unpooled account: no refresh replay, no same-token replay, no rotation. +Native OAuth Messages rename typed `tool_reference.tool_name` blocks consistently with declared +client tools, including nested tool-result content. Cache markers and lifetimes are retained, +and arbitrary tool arguments and input schemas are not traversed. `handleNativeMessages` mirrors native Chat on the shared pieces: `beginInferenceAttempt`, `createFinalRequestLog`, the request spend tracker charged per physical send, proactive key @@ -435,7 +438,3 @@ implicitly. The three are declared as `api` capabilities in `src/cli/capabilitie routes carry no exemption in `src/server/management/route-registry.ts`; the capability mutation check reads the registry's `mutates`, so the read-only plan POST is not a write. `tests/cli/cli-api-protocols.test.ts` pins the requests, the usage errors and that every protocol route is verbed. - -Native OAuth Messages rename typed `tool_reference.tool_name` blocks consistently with declared -client tools, including nested tool-result content. Cache markers and lifetimes are retained, -and arbitrary tool arguments and input schemas are not traversed.