diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ac3d06d48..28093061d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Fixed Anthropic-compatible Umans requests escaping client `web_search` tools so Kimi answers normally instead of returning raw gateway search results. +- Fixed Anthropic-compatible Umans requests escaping client tool names so Kimi answers normally instead of returning raw gateway search results. ## [16.0.1] - 2026-06-15 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 44b9c7543..9e8680e9d 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -758,18 +758,6 @@ function isUmansGatewayWebSearchEnabled( return isUmansAnthropicModel(model) && getUmansWebSearchProvider(headers) !== undefined; } -function shouldEscapeAnthropicBuiltinToolName( - name: string, - escapeBuiltinToolNames: boolean, - useUmansGatewayWebSearch: boolean, -): boolean { - return ( - escapeBuiltinToolNames && - ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase()) && - !shouldUseUmansGatewayWebSearch(name, useUmansGatewayWebSearch) - ); -} - function encodeAnthropicToolName( name: string, isOAuthToken: boolean, @@ -777,17 +765,13 @@ function encodeAnthropicToolName( useUmansGatewayWebSearch = false, ): string { if (shouldUseUmansGatewayWebSearch(name, useUmansGatewayWebSearch)) return name; - if (shouldEscapeAnthropicBuiltinToolName(name, escapeBuiltinToolNames, useUmansGatewayWebSearch)) { - return `${claudeToolPrefix}${name}`; - } + if (escapeBuiltinToolNames) return `${claudeToolPrefix}${name}`; return isOAuthToken ? applyClaudeToolPrefix(name) : name; } function decodeAnthropicToolName(name: string, isOAuthToken: boolean, escapeBuiltinToolNames: boolean): string { - if (isOAuthToken) return stripClaudeToolPrefix(name); - if (!escapeBuiltinToolNames) return name; - const stripped = stripClaudeToolPrefix(name); - return ANTHROPIC_BUILTIN_TOOL_NAMES.has(stripped.toLowerCase()) ? stripped : name; + if (isOAuthToken || escapeBuiltinToolNames) return stripClaudeToolPrefix(name); + return name; } const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index b3d0d526c..acd6b0645 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -823,7 +823,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(payload.metadata?.user_id).not.toBe("invalid-user-id"); expectClaudeMetadataUserId(payload.metadata?.user_id); }); - it("escapes Anthropic built-in tool names for compatible gateways", async () => { + it("escapes Umans client tool names for unambiguous round-trips", async () => { const tools: Tool[] = [ { name: "web_search", @@ -843,6 +843,15 @@ describe("Anthropic request fingerprint alignment", () => { required: ["path"], }, }, + { + name: "_web_search", + description: "literal prefixed search", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, ]; const payload = (await captureAnthropicPayload( @@ -858,7 +867,7 @@ describe("Anthropic request fingerprint alignment", () => { tool_choice?: { type?: string; name?: string }; }; - expect(payload.tools?.map(tool => tool.name)).toEqual(["_web_search", "read"]); + expect(payload.tools?.map(tool => tool.name)).toEqual(["_web_search", "_read", "__web_search"]); expect(payload.tool_choice).toEqual({ type: "tool", name: "_web_search" }); }); @@ -901,7 +910,7 @@ describe("Anthropic request fingerprint alignment", () => { tool_choice?: { type?: string; name?: string }; }; - expect(payload.tools?.map(tool => tool.name)).toEqual(["web_search", "read"]); + expect(payload.tools?.map(tool => tool.name)).toEqual(["web_search", "_read"]); expect(payload.tool_choice).toEqual({ type: "tool", name: "web_search" }); }); diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index e445a2d9b..e0453a219 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -383,6 +383,66 @@ describe("anthropic stream envelope handling", () => { ]); }); + it("decodes escaped literal-prefixed Umans tool names", async () => { + vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( + () => + createMockRequest([ + { + type: "message_start", + message: { + id: "msg_literal_tool", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tool_1", name: "__web_search", input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"literal"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "tool_use" }, + usage: { + input_tokens: 12, + output_tokens: 4, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + { type: "message_stop" }, + ]) as never, + ); + + const stream = streamAnthropic(umansModel, context, { apiKey: "sk-ant-test" }); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) { + events.push(event); + } + const result = await stream.result(); + + expect(countEvents(events, "toolcall_start")).toBe(1); + expect(result.stopReason).toBe("toolUse"); + expect(result.content).toEqual([ + { + type: "toolCall", + id: "tool_1", + name: "_web_search", + arguments: { query: "literal" }, + }, + ]); + }); + it("ignores Umans gateway web search server blocks and keeps final text", async () => { vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation( () =>