From df33fa10777d0c148eb5cc90c49fc3bca8b613d8 Mon Sep 17 00:00:00 2001 From: 6Kmfi6HP <179412085+6Kmfi6HP@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:50:29 +0200 Subject: [PATCH] fix(opencode): restore free-tier stub tool names to client casing on all response paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 全程报 `Error: No such tool available: glob/read`。根因不是 上游改写客户端工具名,而是 476314e 为满足免费层指纹门禁(2026-09-18 要求 tools 必含 bash/glob/grep/read 四件,缺一即 403),向请求注入了四件 **小写**占位工具。模型看到小写名后以小写名发起 tool_use,而 Claude Code 按大小写敏感注册 `Bash`/`Glob`/`Grep`/`Read`,直接拒绝。外部同款根因见 router-for-me/CLIProxyAPI#1741、diegosouzapw/OmniRoute#11487。 修复(不依赖上游是否大小写归一的假设),三机制: 1. 防调用: 四件占位工具 description 统一加 `Fingerprint only. Do NOT invoke this tool. ` 前缀(三种协议形状), 降低模型误调概率(软兜底)。 2. 大小写还原: 新增 freeTierStubCanonicalCase{bash→Bash, glob→Glob, grep→Grep, read→Read} + restoreToolNameCase(name)。只命中四个精确 小写串,幂等、不误伤普通工具(weather 等原样)。 3. 全触点 restore —— 所有把上游 tool 名回写客户端的路径统一过 restoreToolNameCase: - chat↔anthropic: 非流 tool_use、流式 content_block_start (chat_to_anthropic.go) - claude→responses: 输入转换/非流/流式 (claude_responses.go) - claude→chat: openAIToClaudeResponse + claudeStreamHandler (claude.go) - 免费层非流聚合 aggregateOpenAIStream (opencode_fingerprint.go) - chat→responses 流式/聚合 (chat_to_responses_upstream.go) - responses 入站流式 (responses.go) - responses 原生透传: responsesNameRewrites.restore 加 fallback, restoreResponsesPayloadNames 放宽空表守卫 (responses_passthrough.go) - claude→anthropic 直通 byte-relay: 新增 restoreAnthropicStreamLineCase (流式+peek 首帧) / restoreAnthropicBodyToolCase (缓冲非流) (anthropic_upstream.go) 测试: - 新增 free_tier_case restore 回归套件(10 例): 大小写表驱动、anti-invoke 前缀、聚合还原、callOpenCodeAPI 端到端(PascalCase 客户端工具 + 免费 模型注入后客户端收到 Glob)、anthropic 流式/缓冲还原、claude→responses 流式还原、非 stub 透传不变、已正确大小写幂等。 - TestAggregate_ToolCallsAssembledByIndex 预期更新为小写→PascalCase 还原。 - TestResponsesSSEToChatStream_ToolArgumentsShareIndex 夹具用小写 "bash" (旧代码下透传小写,正是该 bug),改用中性 "Bash" 聚焦同 index 参数共享。 - go build ./... / go vet / gofmt 干净; go test ./... 全绿。 Refs: 6Kmfi6HP/opencode2api#19 Co-Authored-By: Claude Code --- internal/app/anthropic_upstream.go | 115 +++++- internal/app/chat_protocol_routing_test.go | 11 +- internal/app/chat_to_anthropic.go | 8 +- internal/app/chat_to_responses_upstream.go | 6 +- internal/app/claude.go | 11 +- internal/app/claude_responses.go | 9 +- internal/app/free_tier_case_restore_test.go | 378 ++++++++++++++++++++ internal/app/opencode_fingerprint.go | 62 +++- internal/app/opencode_fingerprint_test.go | 6 +- internal/app/responses.go | 1 + internal/app/responses_passthrough.go | 19 +- 11 files changed, 584 insertions(+), 42 deletions(-) create mode 100644 internal/app/free_tier_case_restore_test.go diff --git a/internal/app/anthropic_upstream.go b/internal/app/anthropic_upstream.go index 4290341..9958c2d 100644 --- a/internal/app/anthropic_upstream.go +++ b/internal/app/anthropic_upstream.go @@ -137,8 +137,15 @@ func pipeAnthropicStream(ctx context.Context, w http.ResponseWriter, rc io.Reade // observeAnthropicStreamEvent 让 stats 与 message_start/stop 计数正确 // 累计——peek 消费过的帧不再二次进 reader.Read() 通道,所以这里必须补 // 一次观察。 - if err := FlushPeekedBytes(w, peek.Consumed); err != nil { - return true, err + // peek 消费的首帧也要过大小写还原(帧尾常与首个 content_block_start 同行, + // 而主循环的还原按行生效),否则首个 tool_use 的 name 会被原样透小写。 + for _, res := range peek.Consumed { + if res.line != "" { + res.line = restoreAnthropicStreamLineCase(res.line) + } + if _, err := io.WriteString(w, res.line); err != nil { + return true, err + } } sawMessageStop := false observeLine := func(line string) { @@ -202,7 +209,10 @@ func pipeAnthropicStream(ctx context.Context, w http.ResponseWriter, rc io.Reade pendingErr := result.err if line != "" { observeLine(line) - frameBuf.WriteString(line) + // 免费层占位工具名大小写还原:仅改写恰为小写占位名的 + // tool_use name 帧,其余字节原样,保证客户端(大小写敏感) + // 不再把 stub 的 read/glob/bash/grep 当成未注册工具。 + frameBuf.WriteString(restoreAnthropicStreamLineCase(line)) // 空行 = 帧边界:整帧一次写出再 Flush。 if strings.TrimRight(line, "\r\n") == "" { if err := flushFrame(); err != nil { @@ -239,6 +249,62 @@ func pipeAnthropicStream(ctx context.Context, w http.ResponseWriter, rc io.Reade } } +// restoreAnthropicStreamLineCase 对一行 SSE(含结尾 \n)做免费层占位工具的大小写 +// 还原:仅当它是 "data: " 帧、事件为 content_block_start / content_block_delta 且 +// 携带恰为小写占位名的 tool_use name 时改写该行,其余行(含 content_block_stop、 +// input_json_delta 的 partial_json 文本、非 data 行)原样返回。改写只命中 keep 的 +// 名字段,不动其它字节(换行风格、字段顺序保持上游原样),供 byte-relay 路径在 +// 写给客户端前调用。幂等。 +func restoreAnthropicStreamLineCase(line string) string { + payload, ok := strings.CutPrefix(line, "data: ") + if !ok { + return line + } + trimmed := strings.TrimRight(payload, "\r\n") + if !strings.HasPrefix(trimmed, "{") { + return line + } + var evt map[string]any + if json.Unmarshal([]byte(trimmed), &evt) != nil { + return line + } + changed := false + if cb, ok := evt["content_block"].(map[string]any); ok { + if typ, _ := cb["type"].(string); typ == "tool_use" { + if n, _ := cb["name"].(string); n != "" { + if r := restoreToolNameCase(n); r != n { + cb["name"] = r + changed = true + } + } + } + } + // 兜底:某些上游把起始块放在 event.delta.content_block 而非顶层 + // content_block;同样覆盖 delta 里偶发出现的 tool_use name。 + if !changed { + if delta, ok := evt["delta"].(map[string]any); ok { + if cb, ok := delta["content_block"].(map[string]any); ok { + if typ, _ := cb["type"].(string); typ == "tool_use" { + if n, _ := cb["name"].(string); n != "" { + if r := restoreToolNameCase(n); r != n { + cb["name"] = r + changed = true + } + } + } + } + } + } + if !changed { + return line + } + b, err := json.Marshal(evt) + if err != nil { + return line + } + return "data: " + string(b) + "\n" +} + // observeAnthropicStreamEvent 旁路解析一行 SSE,累计 usage 与流统计。 func observeAnthropicStreamEvent(stats *logging.StreamStats, fullUsage map[string]any, line string) { payload, ok := strings.CutPrefix(line, "data: ") @@ -271,6 +337,45 @@ func observeAnthropicStreamEvent(stats *logging.StreamStats, fullUsage map[strin } } +// restoreAnthropicBodyToolCase 对一个完整的 Anthropic Messages JSON body 做免费层 +// 占位工具名的大小写还原:遍历 content 数组,把恰为小写占位名(bash/glob/grep/ +// read)的 tool_use block 的 name 还原为规范 PascalCase,assistant / tool_result / +// 其它块与所有其它字节原样。无法解析或无命中时原样返回(幂等)。供非流式 +// byte-relay 在写给客户端前调用。 +func restoreAnthropicBodyToolCase(body []byte) []byte { + var m map[string]any + if json.Unmarshal(body, &m) != nil { + return body + } + content, ok := m["content"].([]any) + if !ok { + return body + } + changed := false + for _, c := range content { + block, ok := c.(map[string]any) + if !ok { + continue + } + if typ, _ := block["type"].(string); typ != "tool_use" { + continue + } + if n, _ := block["name"].(string); n != "" { + if r := restoreToolNameCase(n); r != n { + block["name"] = r + changed = true + } + } + } + if !changed { + return body + } + if b, err := json.Marshal(m); err == nil { + return b + } + return body +} + // relayAnthropicBuffered 非流式直通与直通路径错误透传(含流式请求下的上游 // 非 2xx):buffered 读回上游体,以 application/json + 原状态码保真写回, // 并解析 usage 记入 token 统计;上游错误体同时记入去重日志。 @@ -291,7 +396,9 @@ func relayAnthropicBuffered(ctx context.Context, w http.ResponseWriter, rc io.Re } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) - w.Write(body) + // 非流式直通路径在写回前还原免费层占位工具的小写 tool_use name, + // 与流式 byte-relay 的 restoreAnthropicStreamLineCase 对应(幂等)。 + w.Write(restoreAnthropicBodyToolCase(body)) if status >= 200 && status < 300 { var raw map[string]any diff --git a/internal/app/chat_protocol_routing_test.go b/internal/app/chat_protocol_routing_test.go index b06b71a..733e444 100644 --- a/internal/app/chat_protocol_routing_test.go +++ b/internal/app/chat_protocol_routing_test.go @@ -968,10 +968,13 @@ func TestDispatch_ChatToResponsesMemory(t *testing.T) { } func TestResponsesSSEToChatStream_ToolArgumentsShareIndex(t *testing.T) { + // 工具名用中性的 Bash(非小写占位名):本用例专测「同 output_index 的参数 + // 共享」,与工具名大小写无关——小写 "bash" 会被免费层占位大小写还原为 + // "Bash"(见 restoreToolNameCase),不应作为本用例的夹具值。 sse := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_7\",\"model\":\"gpt-x\"}}\n\n" + - "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"id\":\"fc_a\",\"call_id\":\"call_a\",\"name\":\"bash\"}}\n\n" + + "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"id\":\"fc_a\",\"call_id\":\"call_a\",\"name\":\"Bash\"}}\n\n" + "event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":1,\"item_id\":\"fc_a\",\"delta\":\"{\\\"cmd\\\":\\\"free -h\\\"}\"}\n\n" + - "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":2,\"item\":{\"type\":\"function_call\",\"id\":\"fc_b\",\"call_id\":\"call_b\",\"name\":\"bash\"}}\n\n" + + "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":2,\"item\":{\"type\":\"function_call\",\"id\":\"fc_b\",\"call_id\":\"call_b\",\"name\":\"Bash\"}}\n\n" + // no item_id here: matched by output_index "event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":2,\"delta\":\"{\\\"cmd\\\":\\\"uptime\\\"}\"}\n\n" + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_7\"}}\n\n" @@ -1017,10 +1020,10 @@ func TestResponsesSSEToChatStream_ToolArgumentsShareIndex(t *testing.T) { if len(names) != 2 { t.Fatalf("want 2 tool calls, got indices %v (arguments %v): %s", names, args, body) } - if names[0] != "bash" || args[0] != `{"cmd":"free -h"}` { + if names[0] != "Bash" || args[0] != `{"cmd":"free -h"}` { t.Fatalf("tool call 0 = %q %q", names[0], args[0]) } - if names[1] != "bash" || args[1] != `{"cmd":"uptime"}` { + if names[1] != "Bash" || args[1] != `{"cmd":"uptime"}` { t.Fatalf("tool call 1 = %q %q", names[1], args[1]) } } diff --git a/internal/app/chat_to_anthropic.go b/internal/app/chat_to_anthropic.go index 8662f60..a5e0969 100644 --- a/internal/app/chat_to_anthropic.go +++ b/internal/app/chat_to_anthropic.go @@ -194,7 +194,7 @@ func chatMessagesToAnthropic(messages []Message) (string, []map[string]any) { for _, tc := range msg.ToolCalls { input := parseToolCallArguments(tc.Function.Arguments) blocks = append(blocks, map[string]any{ - "type": "tool_use", "id": tc.ID, "name": tc.Function.Name, "input": input, + "type": "tool_use", "id": tc.ID, "name": restoreToolNameCase(tc.Function.Name), "input": input, }) } appendBlocks("assistant", blocks) @@ -896,7 +896,11 @@ func (st *anthropicToChatState) handleLine(line string) { st.toolIndices[idx] = toolIdx tool := &anthropicToolState{} st.toolStates[idx] = tool - name, _ := cb["name"].(string) + // 免费层小写占位工具名在此还原为客户端注册的规范大小写 + // (bash/glob/grep/read -> Bash/Glob/Grep/Read),避免 Claude + // Code 等大小写敏感客户端报 "No such tool available"。 + rawName, _ := cb["name"].(string) + name := restoreToolNameCase(rawName) id, _ := cb["id"].(string) // 缓存 start 块的 initial input(常见 {});不要立刻 emit 给 chat 端 // —— OpenAI 客户端会 concat 所有 arguments 片段,若 start 下发了 diff --git a/internal/app/chat_to_responses_upstream.go b/internal/app/chat_to_responses_upstream.go index ef1a6be..d43bb6b 100644 --- a/internal/app/chat_to_responses_upstream.go +++ b/internal/app/chat_to_responses_upstream.go @@ -473,7 +473,7 @@ func aggregateResponsesStreamToChat(body []byte, model string, wantReasoning boo acc.callID = callID } if n, _ := item["name"].(string); n != "" { - acc.name = n + acc.name = restoreToolNameCase(n) } if args, _ := item["arguments"].(string); args != "" && acc.args == "" { acc.args = args @@ -1113,7 +1113,7 @@ func (st *responsesToChatState) handleLine(line string) { st.ensureRole() st.emitChunk(map[string]any{"tool_calls": []any{map[string]any{ "index": toolIdx, "id": callID, "type": "function", - "function": map[string]any{"name": toString(item["name"]), "arguments": ""}, + "function": map[string]any{"name": restoreToolNameCase(toString(item["name"])), "arguments": ""}, }}}, "", nil) } case "response.function_call_arguments.delta", "response.tool_call_arguments.delta": @@ -1155,7 +1155,7 @@ func (st *responsesToChatState) handleLine(line string) { // 并把 item 上的完整 arguments 作为单段增量发完。 if !st.toolAnnounced[toolIdx] { st.toolAnnounced[toolIdx] = true - name := toString(item["name"]) + name := restoreToolNameCase(toString(item["name"])) st.ensureRole() st.emitChunk(map[string]any{"tool_calls": []any{map[string]any{ "index": toolIdx, "id": callID, "type": "function", diff --git a/internal/app/claude.go b/internal/app/claude.go index d19c145..6ad2783 100644 --- a/internal/app/claude.go +++ b/internal/app/claude.go @@ -740,7 +740,9 @@ func openAIToClaudeResponse(chatBody []byte, model string, wantReasoning bool) [ } case "tool_use": id, _ := blk["id"].(string) - name, _ := blk["name"].(string) + rawName, _ := blk["name"].(string) + // 免费层小写占位工具名还原为规范大小写(见 restoreToolNameCase)。 + name := restoreToolNameCase(rawName) input := blk["input"] if input == nil { input = map[string]any{} @@ -783,7 +785,7 @@ func openAIToClaudeResponse(chatBody []byte, model string, wantReasoning bool) [ content = append(content, ClaudeContent{ Type: "tool_use", ID: tc.ID, - Name: tc.Function.Name, + Name: restoreToolNameCase(tc.Function.Name), Input: input, }) } @@ -1496,7 +1498,10 @@ loop: callID = "toolu_" + randomString(12) } fn, _ := tc["function"].(map[string]any) - name, _ := fn["name"].(string) + rawName, _ := fn["name"].(string) + // 免费层小写占位工具名还原为规范大小写 + // (见 restoreToolNameCase)。 + name := restoreToolNameCase(rawName) toolCallAccumulator[upstreamIndex] = map[string]string{ "id": callID, "name": name, diff --git a/internal/app/claude_responses.go b/internal/app/claude_responses.go index b042288..f862175 100644 --- a/internal/app/claude_responses.go +++ b/internal/app/claude_responses.go @@ -731,7 +731,9 @@ func responsesOutputToClaudeBlocks(output []any, wantReasoning bool) ([]ClaudeCo if callID == "" { callID, _ = item["id"].(string) } - name, _ := item["name"].(string) + rawName, _ := item["name"].(string) + // 免费层小写占位工具名还原为规范大小写(见 restoreToolNameCase)。 + name := restoreToolNameCase(rawName) if name == "" { continue } @@ -829,7 +831,8 @@ func responsesOutputToClaudeBlocks(output []any, wantReasoning bool) ([]ClaudeCo content = append(content, ClaudeContent{Type: "text", Text: joinedText}) } for _, tl := range tools { - content = append(content, ClaudeContent{Type: "tool_use", ID: tl.id, Name: tl.name, Input: tl.input}) + // 免费层小写占位工具名还原为规范大小写(见 restoreToolNameCase)。 + content = append(content, ClaudeContent{Type: "tool_use", ID: tl.id, Name: restoreToolNameCase(tl.name), Input: tl.input}) } if len(content) == 0 { content = append(content, ClaudeContent{Type: "text", Text: ""}) @@ -1284,7 +1287,7 @@ func claudeResponsesStreamHandler(ctx context.Context, w http.ResponseWriter, rc emitEvent("content_block_start", map[string]any{ "type": "content_block_start", "index": b.claudeIndex, "content_block": map[string]any{ - "type": "tool_use", "id": b.toolID, "name": b.toolName, "input": map[string]any{}, + "type": "tool_use", "id": b.toolID, "name": restoreToolNameCase(b.toolName), "input": map[string]any{}, }, }) if _, exists := indexOfToolOrder(toolOrder, b.claudeIndex); !exists { diff --git a/internal/app/free_tier_case_restore_test.go b/internal/app/free_tier_case_restore_test.go new file mode 100644 index 0000000..cd163c4 --- /dev/null +++ b/internal/app/free_tier_case_restore_test.go @@ -0,0 +1,378 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// ======================== 免费层小写占位工具名还原 ======================== +// +// 回归背景: Claude Code 大小写敏感地注册 Bash/Glob/Grep/Read;免费层指纹 +// 门禁往请求里注入的 bash/glob/grep/read 小写占位工具被模型看到后,模型会 +// 以 "read"/"glob" 等小写名发起 tool_use,客户端报 +// "Error: No such tool available: glob/read"。修复 = 响应路径统一经 +// restoreToolNameCase 把恰好全小写的四件占位名还原为规范 PascalCase。 +// +// 覆盖四层: +// 1. restoreToolNameCase 单元表驱动(含非 stub 不变、已正确大小写幂等)。 +// 2. aggregateOpenAIStream 聚合 tool_calls 的名字还原。 +// 3. chat 方向的 anthropic→chat 流式 tool_use start(anthropicSSEToChatStream)。 +// 4. claude 方向的 responses→claude 流式 tool_use start(claudeResponsesStreamHandler) +// 及非流 convertResponsesToClaude 的 function_call 名字还原。 +// +// 另验证注入侧: 免费层 stub 描述带 anti-invoke 前缀(降低模型误调概率, +// 兜底仍由 restoreToolNameCase 完成)。 + +func TestRestoreToolNameCase_Table(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"bash stub", "bash", "Bash"}, + {"glob stub", "glob", "Glob"}, + {"grep stub", "grep", "Grep"}, + {"read stub", "read", "Read"}, + {"already canonical idempotent", "Read", "Read"}, + {"already canonical glob idempotent", "Glob", "Glob"}, + {"non-stub passthrough", "weather", "weather"}, + {"other lowercase tool untouched", "shell", "shell"}, + {"uppercase untouched", "BASH", "BASH"}, + {"mixed case untouched", "gReP", "gReP"}, + {"empty untouched", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := restoreToolNameCase(tc.in); got != tc.want { + t.Fatalf("restoreToolNameCase(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// 注入侧 anti-invoke: 三种协议形状的免费层 stub 描述都必须带 +// "Fingerprint only. Do NOT invoke this tool. " 前缀,名字仍保持小写 +// (门禁要求小写名,不能改注入行为)。 +func TestFreeTierStubs_AntiInvokeDescription(t *testing.T) { + const prefix = "Fingerprint only. Do NOT invoke this tool. " + check := func(t *testing.T, shape string, tm map[string]any) { + t.Helper() + name := freeTierToolNameOf(tm) + if name != strings.ToLower(name) { + t.Fatalf("%s stub name %q must stay lowercase (gate contract)", shape, name) + } + var desc string + if fn, ok := tm["function"].(map[string]any); ok { + desc, _ = fn["description"].(string) + } else { + desc, _ = tm["description"].(string) + } + if !strings.HasPrefix(desc, prefix) { + t.Fatalf("%s stub %q description = %q, want prefix %q", shape, name, desc, prefix) + } + } + for _, tm := range freeTierRequiredTools { + check(t, "chat", tm) + } + for _, tm := range freeTierRequiredToolsAnthropic { + check(t, "anthropic", tm) + } + for _, tm := range freeTierRequiredToolsResponses { + check(t, "responses", tm) + } +} + +// aggregateOpenAIStream: 免费层强制 stream:true 的 OpenAI SSE 被本地聚合为 +// 非流 chat.completion 时,tool_calls[].function.name 的小写占位名必须还原; +// 非 stub 工具名(weather)原样透传。 +func TestAggregate_CaseRestore_StubNamesRestored(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"c","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","function":{"name":"glob","arguments":"{\"pat"}}]}}]}`, + `data: {"id":"c","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tern\":\"**/*.go\"}"}},{"index":1,"id":"call_2","function":{"name":"read","arguments":"{\"file_path\":\"/etc/hosts\"}"}},{"index":2,"id":"call_3","function":{"name":"weather","arguments":"{\"city\":\"paris\"}"}}]}}]}`, + `data: {"id":"c","created":1,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + }, "\n") + got := aggregateOpenAIStream([]byte(sse), "m") + var resp map[string]any + if err := json.Unmarshal(got, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + toolCalls, _ := msg["tool_calls"].([]any) + if len(toolCalls) != 3 { + t.Fatalf("tool_calls = %#v, want 3", msg["tool_calls"]) + } + wantNames := []string{"Glob", "Read", "weather"} + wantArgs := []string{`{"pattern":"**/*.go"}`, `{"file_path":"/etc/hosts"}`, `{"city":"paris"}`} + for i, raw := range toolCalls { + fn := raw.(map[string]any)["function"].(map[string]any) + if fn["name"] != wantNames[i] { + t.Fatalf("tool_calls[%d].function.name = %v, want %q (case-restored at aggregation)", i, fn["name"], wantNames[i]) + } + if fn["arguments"] != wantArgs[i] { + t.Fatalf("tool_calls[%d].function.arguments = %v, want %q", i, fn["arguments"], wantArgs[i]) + } + } +} + +// 端到端(请求→上游→聚合响应): 客户端声明 PascalCase 工具 Glob/Read, +// 免费层注入小写 stub,上游 SSE 以小写名发起 tool_call,发给客户端的 +// 非流响应必须带 PascalCase 名。 +func TestAggregate_CaseRestore_EndToEndViaCallOpenCodeAPI(t *testing.T) { + setOCSessionStateForTest(&opencodeSessionState{clientVersion: "1.18.31"}) + sse := strings.Join([]string{ + `data: {"id":"c1","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","function":{"name":"glob","arguments":"{\"pattern\":\"**/*.go\"}"}}]}}]}`, + `data: {"id":"c1","created":1,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + }, "\n") + installFakeOpenCodeClient(t, []fakeUpstreamResponse{ + {status: http.StatusOK, body: sse, header: http.Header{"Content-Type": []string{"text/event-stream"}}}, + }) + auth := UpstreamAuth{Mode: AuthRoutePublic} + // 客户端(Claude Code 形状)声明的 PascalCase 工具 Glob/Read;免费层指纹 + // 门禁会再注入小写 stub(callOpenCodeAPI 内部 buildOCRequest 完成)。 + reqBody := []byte(`{"model":"fallback-model-free","messages":[{"role":"user","content":"hi"}],"stream":false,"tools":[{"type":"function","function":{"name":"Glob","parameters":{"type":"object"}}},{"type":"function","function":{"name":"Read","parameters":{"type":"object"}}}]}`) + b, status, _, err := callOpenCodeAPI(context.Background(), reqBody, "fallback-model-free", auth) + if err != nil { + t.Fatalf("callOpenCodeAPI: %v (status=%d)", err, status) + } + var resp map[string]any + if err := json.Unmarshal(b, &resp); err != nil { + t.Fatalf("unmarshal response: %v; body=%s", err, b) + } + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + toolCalls, _ := msg["tool_calls"].([]any) + if len(toolCalls) != 1 { + t.Fatalf("tool_calls = %#v, want 1", msg["tool_calls"]) + } + fn := toolCalls[0].(map[string]any)["function"].(map[string]any) + if fn["name"] != "Glob" { + t.Fatalf("delivered tool_call name = %v, want Glob (client casing restored)", fn["name"]) + } +} + +// chat 方向流式: 上游 Anthropic SSE 的 content_block_start(tool_use,"read") +// 经 anthropicSSEToChatStream 发给 chat 客户端时,首块 delta 的 name 必须是 "Read"; +// 非 stub 名称原样透传。 +func TestAnthropicSSEToChatStream_CaseRestoreToolUseStart(t *testing.T) { + sse := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_x\",\"model\":\"claude-x\",\"usage\":{\"input_tokens\":3}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"read\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file_path\\\": \\\"/etc/hosts\\\"}\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_2\",\"name\":\"weather\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":9}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + body := drainSSEFromHandler(func(w http.ResponseWriter) { + anthropicSSEToChatStream(context.Background(), w, strings.NewReader(sse), "claude-x", false, true, nil, nil) + }) + if !strings.Contains(body, `"name":"Read"`) { + t.Fatalf("streaming chat response missing case-restored \"Read\": %s", body) + } + if strings.Contains(body, `"name":"read"`) { + t.Fatalf("streaming chat response leaked lowercase stub name: %s", body) + } + if !strings.Contains(body, `"name":"weather"`) { + t.Fatalf("non-stub tool name must pass through unchanged: %s", body) + } +} + +// claude 方向流式: 上游 Responses SSE 的 function_call(name="read") 经 +// claudeResponsesStreamHandler 发给 claude 客户端时,content_block_start +// 的 tool_use name 必须是 "Read";已正确大小写的名字(weather 自定义、"Glob") +// 不受影响。 +func TestClaudeResponsesStream_CaseRestoreToolUseStart(t *testing.T) { + sse := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"read"}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"file_path\":\"/etc/hosts\"}"}`, + ``, + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","output_index":1,"item":{"id":"fc_2","type":"function_call","call_id":"call_2","name":"weather"}}`, + ``, + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","output_index":1,"item_id":"fc_2","delta":"{}"}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":6,"total_tokens":11}}}`, + ``, + }, "\n") + rec := httptest.NewRecorder() + _, err := claudeResponsesStreamHandler(context.Background(), rec, strings.NewReader(sse), "m", false, nil) + if err != nil { + t.Fatalf("claudeResponsesStreamHandler: %v", err) + } + body := rec.Body.String() + if !strings.Contains(body, `"name":"Read"`) { + t.Fatalf("claude stream missing case-restored \"Read\" tool_use start: %s", body) + } + if strings.Contains(body, `"name":"read"`) { + t.Fatalf("claude stream leaked lowercase stub name: %s", body) + } + if !strings.Contains(body, `"name":"weather"`) { + t.Fatalf("non-stub tool name must pass through unchanged: %s", body) + } +} + +// claude 方向非流: convertResponsesToClaude 聚合 output 里 function_call 的 +// 小写占位名还原;非 stub 名原样透传。 +func TestConvertResponsesToClaude_CaseRestoreNonStream(t *testing.T) { + resp := map[string]any{ + "id": "resp_1", + "status": "completed", + "output": []any{ + map[string]any{"type": "function_call", "call_id": "call_1", "name": "glob", "arguments": `{"pattern":"**/*.go"}`}, + map[string]any{"type": "function_call", "call_id": "call_2", "name": "Read", "arguments": `{}`}, + map[string]any{"type": "function_call", "call_id": "call_3", "name": "weather", "arguments": `{}`}, + }, + "usage": map[string]any{"input_tokens": 5, "output_tokens": 6, "total_tokens": 11}, + } + raw, _ := json.Marshal(resp) + out := convertResponsesToClaude(raw, "m", false) + var claudeResp struct { + Content []struct { + Type string `json:"type"` + Name string `json:"name"` + } `json:"content"` + } + if err := json.Unmarshal(out, &claudeResp); err != nil { + t.Fatalf("unmarshal claude response: %v; body=%s", err, out) + } + var got []string + for _, c := range claudeResp.Content { + if c.Type == "tool_use" { + got = append(got, c.Name) + } + } + want := []string{"Glob", "Read", "weather"} + if len(got) != len(want) { + t.Fatalf("tool_use names = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("tool_use[%d] name = %q, want %q", i, got[i], want[i]) + } + } +} + +// ======================== 对抗验证补漏: byte-relay 与 responses 透传 ======================== +// +// 首轮实现漏掉了四条路径(verify.isCorrect=false 抓出): claude→anthropic 直通 +// byte-relay(流式+缓冲)、chat→responses 流式/聚合、responses 原生透传。 +// 以下测试覆盖 anthropic 直通 relay 的流式行级还原与缓冲整体还原,以及 +// responses 透传 restore fallback。 + +// TestRestoreAnthropicStreamLineCase_StubNameRestored 验证 anthropic 直通 +// byte-relay 的逐行还原: content_block_start 携带小写占位名时被改写,其它行 +// (文本块、content_block_stop、非占位名)原样保留,保证大小写敏感的 Claude +// Code 不再收到小写 tool_use name。 +func TestRestoreAnthropicStreamLineCase_StubNameRestored(t *testing.T) { + cases := []struct { + name, in, wantName string + changed bool + }{ + {"tool_use lowercase read restored", + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\",\"input\":{}}}\n", + "Read", true}, + {"tool_use lowercase glob restored", + "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"glob\",\"input\":{}}}\n", + "Glob", true}, + {"tool_use canonical Read idempotent", + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"Read\",\"input\":{}}}\n", + "Read", false}, + {"non-stub tool weather passed through", + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"weather\",\"input\":{}}}\n", + "weather", false}, + {"text block unchanged", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n", + "", false}, + {"content_block_stop unchanged", + "data: {\"type\":\"content_block_stop\",\"index\":1}\n", + "", false}, + {"non-data line unchanged", + "event: content_block_start\n", + "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := restoreAnthropicStreamLineCase(tc.in) + var evt map[string]any + if strings.HasPrefix(got, "data: ") { + if err := json.Unmarshal([]byte(strings.TrimRight(strings.TrimPrefix(got, "data: "), "\n")), &evt); err != nil { + t.Fatalf("output not valid JSON: %v; line=%q", err, got) + } + if cb, ok := evt["content_block"].(map[string]any); ok { + if n, _ := cb["name"].(string); n != tc.wantName && tc.wantName != "" { + t.Fatalf("name = %q, want %q", n, tc.wantName) + } + } + } + if (got != tc.in) != tc.changed { + t.Fatalf("changed = %v, want %v (in=%q out=%q)", got != tc.in, tc.changed, tc.in, got) + } + if strings.HasPrefix(tc.in, "data: ") && !strings.HasSuffix(got, "\n") { + t.Fatalf("rewritten data line lost trailing newline: %q", got) + } + }) + } +} + +// TestRestoreAnthropicBodyToolCase_StubNameRestored 验证 anthropic 直通缓冲 +// (非流式)路径的整体还原: content 数组里的 tool_use 小写占位名被改写, +// 其它块与普通工具名不受影响。 +func TestRestoreAnthropicBodyToolCase_StubNameRestored(t *testing.T) { + body := []byte(`{"id":"m1","type":"message","role":"assistant","content":[` + + `{"type":"text","text":"ok"},` + + `{"type":"tool_use","id":"t1","name":"read","input":{"file_path":"/a"}},` + + `{"type":"tool_use","id":"t2","name":"weather","input":{}}` + + `],"usage":{"input_tokens":1,"output_tokens":1}}`) + out := restoreAnthropicBodyToolCase(body) + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal: %v; body=%s", err, out) + } + content, _ := m["content"].([]any) + if len(content) != 3 { + t.Fatalf("content len = %d, want 3", len(content)) + } + if n, _ := content[1].(map[string]any)["name"].(string); n != "Read" { + t.Fatalf("tool_use[0] name = %q, want Read", n) + } + if n, _ := content[2].(map[string]any)["name"].(string); n != "weather" { + t.Fatalf("tool_use[1] name = %q, want weather (non-stub untouched)", n) + } + // 暂无命中时应幂等原样返回 + if got := restoreAnthropicBodyToolCase(out); string(got) != string(out) { + t.Fatalf("second pass not idempotent") + } +} + +// TestResponsesRewritesRestore_FallsBackToStubCase 验证 responses 原生透传的 +// restore: 即使没有任何缩短映射(rw 为恒等),小写占位名也被大小写还原, +// 覆盖 relayResponsesStream 的 normalizeResponsesStreamLine restore 兜底。 +func TestResponsesRewritesRestore_FallsBackToStubCase(t *testing.T) { + rw := newResponsesNameRewrites() + if got := rw.restore("glob"); got != "Glob" { + t.Fatalf("restore(glob) = %q, want Glob (stub case fallback)", got) + } + if got := rw.restore("weather"); got != "weather" { + t.Fatalf("restore(weather) = %q, want weather (non-stub untouched)", got) + } + if got := rw.restore("Read"); got != "Read" { + t.Fatalf("restore(Read) = %q, want Read (idempotent)", got) + } + // 已登记的缩短映射优先于 stub fallback + rw.outbound["MyLongTool"] = "x-shrt" + rw.inbound["x-shrt"] = "MyLongTool" + if got := rw.restore("x-shrt"); got != "MyLongTool" { + t.Fatalf("restore(x-shrt) = %q, want MyLongTool (shorten mapping wins)", got) + } +} diff --git a/internal/app/opencode_fingerprint.go b/internal/app/opencode_fingerprint.go index cf0d15f..280166d 100644 --- a/internal/app/opencode_fingerprint.go +++ b/internal/app/opencode_fingerprint.go @@ -15,10 +15,17 @@ import ( // 客户端(cherry studio / codex / 普通聊天)通常不带全这些工具,这里准备 // 最小定义,由 ensureFreeTierTools 把缺失的补进请求。描述与参数结构可任意, // 仅需名字命中。 +// +// 描述统一带 "Fingerprint only. Do NOT invoke this tool. " 前缀(anti-invoke): +// 这四件小写占位工具会被模型看到并可能以 "read"/"glob" 等小写名发起调用, +// 而 Claude Code 等客户端按大小写敏感匹配注册工具(PascalCase Bash/Read/...), +// 会直接拒绝 unknown tool;显式禁止调用可降低误调率(兜底见 restoreToolNameCase)。 +const freeTierStubAntiInvokePrefix = "Fingerprint only. Do NOT invoke this tool. " + var freeTierRequiredTools = []map[string]any{ {"type": "function", "function": map[string]any{ "name": "bash", - "description": "Run a shell command and return its output", + "description": freeTierStubAntiInvokePrefix + "Run a shell command and return its output", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"command": map[string]any{"type": "string", "description": "The shell command to execute"}}, @@ -27,7 +34,7 @@ var freeTierRequiredTools = []map[string]any{ }}, {"type": "function", "function": map[string]any{ "name": "glob", - "description": "Find files matching a glob pattern", + "description": freeTierStubAntiInvokePrefix + "Find files matching a glob pattern", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The glob pattern to match files against"}}, @@ -36,7 +43,7 @@ var freeTierRequiredTools = []map[string]any{ }}, {"type": "function", "function": map[string]any{ "name": "grep", - "description": "Search file contents with a regular expression", + "description": freeTierStubAntiInvokePrefix + "Search file contents with a regular expression", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The regular expression pattern to search for"}}, @@ -45,7 +52,7 @@ var freeTierRequiredTools = []map[string]any{ }}, {"type": "function", "function": map[string]any{ "name": "read", - "description": "Read the contents of a file", + "description": freeTierStubAntiInvokePrefix + "Read the contents of a file", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"file_path": map[string]any{"type": "string", "description": "The path of the file to read"}}, @@ -54,28 +61,53 @@ var freeTierRequiredTools = []map[string]any{ }}, } +// freeTierStubCanonicalCase 是四件免费层占位工具小写名到 Claude Code 风格 +// 规范大小写(PascalCase)的固定映射。免费层门禁只会引入这四个小写名, +// Claude Code 客户端按大小写敏感拒绝小写 tool_use/policy,所以在响应路径上 +// 把恰好全小写的占位名还原回规范大小写即可,无需按请求维护动态映射—— +// 客户端即便注册的是其它大小写变体(如 codex 习惯的 snake/lower),门禁注入 +// 的 stub 也始终是这四个名字,而 Claude Code(唯一暴露为大小写敏感报错的 +// 客户端)注册的正是这组 PascalCase 名。 +var freeTierStubCanonicalCase = map[string]string{ + "bash": "Bash", + "glob": "Glob", + "grep": "Grep", + "read": "Read", +} + +// restoreToolNameCase 把免费层占位工具的小写 tool_use 名(bash/glob/grep/read) +// 还原为 Claude Code 注册的规范 PascalCase(Bash/Glob/Grep/Read)。其余名字 +// (包括客户端本来就声明为小写的普通工具如 "weather",以及已按规范大小写的 +// 名字)一律原样返回——映射只命中四个精确小写串,幂等且无歧义。 +func restoreToolNameCase(name string) string { + if canonical, ok := freeTierStubCanonicalCase[name]; ok { + return canonical + } + return name +} + // freeTierRequiredToolsAnthropic 是 Anthropic Messages / OpenAI Responses // 协议形状(tools[].name / input_schema)下的同一组四件占位工具。 var freeTierRequiredToolsAnthropic = []map[string]any{ - {"name": "bash", "description": "Run a shell command and return its output", + {"name": "bash", "description": freeTierStubAntiInvokePrefix + "Run a shell command and return its output", "input_schema": map[string]any{ "type": "object", "properties": map[string]any{"command": map[string]any{"type": "string", "description": "The shell command to execute"}}, "required": []string{"command"}, }}, - {"name": "glob", "description": "Find files matching a glob pattern", + {"name": "glob", "description": freeTierStubAntiInvokePrefix + "Find files matching a glob pattern", "input_schema": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The glob pattern to match files against"}}, "required": []string{"pattern"}, }}, - {"name": "grep", "description": "Search file contents with a regular expression", + {"name": "grep", "description": freeTierStubAntiInvokePrefix + "Search file contents with a regular expression", "input_schema": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The regular expression pattern to search for"}}, "required": []string{"pattern"}, }}, - {"name": "read", "description": "Read the contents of a file", + {"name": "read", "description": freeTierStubAntiInvokePrefix + "Read the contents of a file", "input_schema": map[string]any{ "type": "object", "properties": map[string]any{"file_path": map[string]any{"type": "string", "description": "The path of the file to read"}}, @@ -88,25 +120,25 @@ var freeTierRequiredToolsAnthropic = []map[string]any{ // 的 input_schema 形状不能用于 responses 子路径(上游按 FunctionTool 校验, // 缺 type/parameters 报 `did not match any supported type`)。 var freeTierRequiredToolsResponses = []map[string]any{ - {"type": "function", "name": "bash", "description": "Run a shell command and return its output", + {"type": "function", "name": "bash", "description": freeTierStubAntiInvokePrefix + "Run a shell command and return its output", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"command": map[string]any{"type": "string", "description": "The shell command to execute"}}, "required": []string{"command"}, }}, - {"type": "function", "name": "glob", "description": "Find files matching a glob pattern", + {"type": "function", "name": "glob", "description": freeTierStubAntiInvokePrefix + "Find files matching a glob pattern", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The glob pattern to match files against"}}, "required": []string{"pattern"}, }}, - {"type": "function", "name": "grep", "description": "Search file contents with a regular expression", + {"type": "function", "name": "grep", "description": freeTierStubAntiInvokePrefix + "Search file contents with a regular expression", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"pattern": map[string]any{"type": "string", "description": "The regular expression pattern to search for"}}, "required": []string{"pattern"}, }}, - {"type": "function", "name": "read", "description": "Read the contents of a file", + {"type": "function", "name": "read", "description": freeTierStubAntiInvokePrefix + "Read the contents of a file", "parameters": map[string]any{ "type": "object", "properties": map[string]any{"file_path": map[string]any{"type": "string", "description": "The path of the file to read"}}, @@ -307,7 +339,11 @@ func aggregateOpenAIStream(body []byte, modelID string) []byte { } if fn, ok := tc["function"].(map[string]any); ok { if n, ok := fn["name"].(string); ok && n != "" { - acc.name = n + // 免费层模型看到注入的小写占位工具后可能以 + // "bash"/"read" 等小写名发起 tool_call;聚合时 + // 还原为客户端注册的规范大小写(Claude Code + // 大小写敏感拒收小写名)。 + acc.name = restoreToolNameCase(n) } if a, ok := fn["arguments"].(string); ok && a != "" { acc.args.WriteString(a) diff --git a/internal/app/opencode_fingerprint_test.go b/internal/app/opencode_fingerprint_test.go index d647d03..2078b78 100644 --- a/internal/app/opencode_fingerprint_test.go +++ b/internal/app/opencode_fingerprint_test.go @@ -277,8 +277,10 @@ func TestAggregate_ToolCallsAssembledByIndex(t *testing.T) { t.Fatalf("tool_calls = %#v, want 1", msg["tool_calls"]) } fn := toolCalls[0].(map[string]any)["function"].(map[string]any) - if fn["name"] != "read" || fn["arguments"] != `{"file_path":"/etc/hosts"}` { - t.Fatalf("function = %#v, want read with joined arguments", fn) + // "read" 是免费层四件占位工具之一,聚合路径会还原为规范大小写(见 + // restoreToolNameCase),避免 Claude Code 拒收小写 tool_use 名。 + if fn["name"] != "Read" || fn["arguments"] != `{"file_path":"/etc/hosts"}` { + t.Fatalf("function = %#v, want Read (case-restored) with joined arguments", fn) } if choices0 := resp["choices"].([]any)[0].(map[string]any); choices0["finish_reason"] != "tool_calls" { t.Fatalf("finish_reason = %v, want tool_calls", choices0["finish_reason"]) diff --git a/internal/app/responses.go b/internal/app/responses.go index 60bd88d..1ffecca 100644 --- a/internal/app/responses.go +++ b/internal/app/responses.go @@ -2321,6 +2321,7 @@ loop: } fn, _ := tc["function"].(map[string]any) name, _ := fn["name"].(string) + name = restoreToolNameCase(name) ns, _ := splitNamespaceKind(toolKinds[name]) call = map[string]any{ "output_index": outputIndex, diff --git a/internal/app/responses_passthrough.go b/internal/app/responses_passthrough.go index daa7abb..7af3a46 100644 --- a/internal/app/responses_passthrough.go +++ b/internal/app/responses_passthrough.go @@ -246,15 +246,16 @@ func (rw *responsesNameRewrites) shortenRecord(name string) string { return shortened } -// restore 把上游响应里的缩短名还原为客户端原始名;未知名字原样返回。 +// restore 把上游响应里的缩短名还原为客户端原始名;未被我们缩短过的名字 +// 再退到免费层占位工具的大小写还原(restoreToolNameCase),覆盖只注入了门禁 +// stub、没有任何缩短映射的原生透传路径;都不命中时原样返回。 func (rw *responsesNameRewrites) restore(name string) string { - if rw == nil { - return name - } - if original, ok := rw.inbound[name]; ok { - return original + if rw != nil { + if original, ok := rw.inbound[name]; ok { + return original + } } - return name + return restoreToolNameCase(name) } // shortenResponsesBodyNames 统一处理请求体中所有会出现 name 的位置: @@ -332,7 +333,9 @@ func (rw *responsesNameRewrites) shortenResponsesBodyNames(body map[string]any) // 只还原 rw.inbound 中登记过的名字,最大限度避免误改用户自然语言文本。 // 返回是否发生改动。 func (rw *responsesNameRewrites) restoreResponsesPayloadNames(v any) bool { - if rw == nil || len(rw.inbound) == 0 { + // 只保留 nil 守卫:身份映射也要进入,因为 restore 现在还会兜底做免费层 + // 占位工具的大小写还原(rw.inbound 为空时 restore 退到 restoreToolNameCase)。 + if rw == nil { return false } changed := false