From 30dc2e7f34960074d84bb65ae5fbfd76fc9d0ece Mon Sep 17 00:00:00 2001 From: sususu98 <33882693+sususu98@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:07:08 +0800 Subject: [PATCH] fix(translator): emit Claude server tool blocks for Codex web_search_call streams (#3868) * fix(translator): emit Claude server tool blocks for Codex web_search_call streams Map Codex Responses streaming web_search_call events to Claude SSE server_tool_use and web_search_tool_result blocks, with deduplication and a focused stream regression test. * fix(translator): stabilize Codex web_search fallback tool_use IDs Reuse the active fallback web_search tool_use ID across later stream events so tool_result blocks stay paired when upstream omits item IDs. This is defensive hardening; live Codex streams already provide ws_* IDs. * fix(translator): emit Codex web_search blocks from populated items Wait for output_item.done before emitting Claude web_search tool_use and tool_result blocks, and avoid deduping early added/completed events that arrive before action.query is available. Matches live Responses stream ordering seen in local tmux verification. * fix(translator): map Codex web_search_call items in non-stream Claude responses Emit server_tool_use and web_search_tool_result blocks from completed response.output web_search_call items, matching the streaming translator. * fix(translator): keep non-stream web_search on end_turn and dedupe output items Do not treat server web_search_call items as client tool_use for stop_reason. Skip duplicate or query-less open_page web_search output items in non-stream translation, matching spark live behavior. --- .../codex/claude/codex_claude_response.go | 12 ++ .../claude/codex_claude_response_test.go | 126 ++++++++++++ .../codex_claude_response_web_search.go | 189 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 internal/translator/codex/claude/codex_claude_response_web_search.go diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 4de759def..b6a8a2fbc 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -32,6 +32,9 @@ type ConvertCodexResponseToClaudeParams struct { ThinkingStopPending bool ThinkingSignature string ThinkingSummarySeen bool + WebSearchToolUseIDs map[string]struct{} + WebSearchToolResultIDs map[string]struct{} + LastWebSearchToolUseID string } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -120,6 +123,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) + case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress": + // Wait for populated web_search_call items on output_item.done. case "response.completed", "response.incomplete": template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) responseData := rootResult.Get("response") @@ -163,6 +168,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "reasoning": params.ThinkingSummarySeen = false params.ThinkingSignature = itemResult.Get("encrypted_content").String() + case "web_search_call": + // Defer server_tool_use until output_item.done carries action/query. } case "response.output_item.done": itemResult := rootResult.Get("item") @@ -227,6 +234,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } params.ThinkingSignature = "" params.ThinkingSummarySeen = false + case "web_search_call": + output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult) } case "response.function_call_arguments.delta": params.HasReceivedArgumentsDelta = true @@ -311,6 +320,7 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } hasToolCall := false + webSearchSeen := make(map[string]struct{}) if output := responseData.Get("output"); output.Exists() && output.IsArray() { output.ForEach(func(_, item gjson.Result) bool { @@ -379,6 +389,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } } } + case "web_search_call": + out = appendCodexWebSearchNonStreamContent(out, item, webSearchSeen) case "function_call": hasToolCall = true name := item.Get("name").String() diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index bf98a09cc..78e6a4d89 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -1,6 +1,7 @@ package claude import ( + "bytes" "context" "strings" "testing" @@ -508,6 +509,74 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage } } +func TestConvertCodexResponseToClaude_StreamWebSearchCallEmitsClaudeServerToolBlocks(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{ + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "messages":[{"role":"user","content":"search weather"}] + }`) + var param any + + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"id":"ws_123","type":"web_search_call","status":"in_progress"}}`), + []byte(`data: {"type":"response.web_search_call.searching","item_id":"ws_123"}`), + []byte(`data: {"type":"response.web_search_call.completed","item_id":"ws_123"}`), + []byte(`data: {"type":"response.output_item.done","item":{"id":"ws_123","type":"web_search_call","status":"completed","action":{"type":"search","query":"search weather"}}}`), + []byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2}}}`), + } + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + outputText := string(bytes.Join(outputs, nil)) + + for _, needle := range []string{ + `"type":"server_tool_use"`, + `"id":"ws_123"`, + `"type":"web_search_tool_result"`, + `event: message_stop`, + } { + if !strings.Contains(outputText, needle) { + t.Fatalf("stream output missing %s:\n%s", needle, outputText) + } + } + serverToolIndex := strings.Index(outputText, `"type":"server_tool_use"`) + resultIndex := strings.Index(outputText, `"type":"web_search_tool_result"`) + if serverToolIndex < 0 || resultIndex < 0 || resultIndex < serverToolIndex { + t.Fatalf("web_search_tool_result must follow server_tool_use:\n%s", outputText) + } + if !strings.Contains(outputText, `partial_json`) || !strings.Contains(outputText, "search weather") { + t.Fatalf("expected web search query delta after populated output_item.done:\n%s", outputText) + } +} + +func TestConvertCodexResponseToClaude_StreamWebSearchCallReusesFallbackToolUseID(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`) + var param any + + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"web_search_call","status":"in_progress"}}`), + []byte(`data: {"type":"response.web_search_call.completed","item_id":"ws_from_upstream"}`), + []byte(`data: {"type":"response.output_item.done","item":{"id":"ws_from_upstream","type":"web_search_call","status":"completed","action":{"type":"search","query":"search weather"}}}`), + []byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2}}}`), + } + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + outputText := string(bytes.Join(outputs, nil)) + + if strings.Count(outputText, `"type":"server_tool_use"`) != 1 { + t.Fatalf("expected exactly one server_tool_use block, got output:\n%s", outputText) + } + if !strings.Contains(outputText, `"tool_use_id":"ws_from_upstream"`) { + t.Fatalf("expected web_search_tool_result to reuse fallback tool_use_id:\n%s", outputText) + } +} + func TestConvertCodexResponseToClaude_ShortensLongToolUseIDs(t *testing.T) { longCallID := "call_" + strings.Repeat("a", 62) if len(longCallID) <= 64 { @@ -649,6 +718,63 @@ func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) { } } +func TestConvertCodexResponseToClaudeNonStream_WebSearchCallEmitsServerToolBlocks(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`) + response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex-spark","stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"search weather"}},{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + parsed := gjson.ParseBytes(out) + types := []string{} + parsed.Get("content").ForEach(func(_, value gjson.Result) bool { + types = append(types, value.Get("type").String()) + return true + }) + for _, want := range []string{"server_tool_use", "web_search_tool_result", "text"} { + found := false + for _, got := range types { + if got == want { + found = true + break + } + } + if !found { + found = strings.Contains(string(out), `"type":"`+want+`"`) + } + if !found { + t.Fatalf("missing content type %s in %s", want, string(out)) + } + } + if parsed.Get("content.0.input.query").String() != "search weather" { + if !strings.Contains(string(out), "search weather") { + t.Fatalf("expected web search query in non-stream output: %s", string(out)) + } + } +} + +func TestConvertCodexResponseToClaudeNonStream_WebSearchStopReasonEndTurn(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`) + response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex-spark","stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"search weather"}},{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + parsed := gjson.ParseBytes(out) + if got := parsed.Get("stop_reason").String(); got != "end_turn" { + t.Fatalf("stop_reason = %q, want end_turn when only server web_search and text are present", got) + } +} + +func TestConvertCodexResponseToClaudeNonStream_WebSearchDedupesEmptyOpenPageItems(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"q"}]}`) + response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex-spark","stop_reason":"stop","usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"open_page"}},{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"weather"}},{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + if strings.Count(string(out), `"type":"server_tool_use"`) != 1 { + t.Fatalf("expected one server_tool_use after dedupe, got %s", string(out)) + } + if !strings.Contains(string(out), "weather") { + t.Fatalf("expected populated query item to be kept: %s", string(out)) + } +} + func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) { tests := []struct { name string diff --git a/internal/translator/codex/claude/codex_claude_response_web_search.go b/internal/translator/codex/claude/codex_claude_response_web_search.go new file mode 100644 index 000000000..1f9c59a7c --- /dev/null +++ b/internal/translator/codex/claude/codex_claude_response_web_search.go @@ -0,0 +1,189 @@ +package claude + +import ( + "encoding/json" + "fmt" + "strings" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte { + toolUseID := codexWebSearchToolUseID(params, root, item) + if toolUseID == "" { + return output + } + if params.WebSearchToolUseIDs == nil { + params.WebSearchToolUseIDs = make(map[string]struct{}) + } + query := codexWebSearchQuery(root, item) + alreadyStarted := false + if _, ok := params.WebSearchToolUseIDs[toolUseID]; ok { + alreadyStarted = true + if query == "" { + return output + } + } + + if !alreadyStarted { + output = append(output, finalizeCodexThinkingBlock(params)...) + template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + template, _ = sjson.SetBytes(template, "content_block.id", toolUseID) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) + } + + if query != "" { + partialJSON, _ := json.Marshal(map[string]string{"query": query}) + delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) + delta, _ = sjson.SetBytes(delta, "index", params.BlockIndex) + delta, _ = sjson.SetBytes(delta, "delta.partial_json", string(partialJSON)) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", delta, 2) + } + + if !alreadyStarted { + stop := []byte(`{"type":"content_block_stop","index":0}`) + stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2) + params.WebSearchToolUseIDs[toolUseID] = struct{}{} + params.BlockIndex++ + } + return output +} + +func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte { + toolUseID := codexWebSearchToolUseID(params, root, item) + if toolUseID == "" { + return output + } + output = appendCodexWebSearchServerToolUse(output, params, root, item) + if params.WebSearchToolResultIDs == nil { + params.WebSearchToolResultIDs = make(map[string]struct{}) + } + if _, ok := params.WebSearchToolResultIDs[toolUseID]; ok { + return output + } + if codexWebSearchQuery(root, item) == "" && len(codexWebSearchResultContent(root, item)) == 0 && item.Get("action").Exists() == false { + return output + } + + template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"","content":[]}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + template, _ = sjson.SetBytes(template, "content_block.tool_use_id", toolUseID) + if content := codexWebSearchResultContent(root, item); len(content) > 0 { + template, _ = sjson.SetRawBytes(template, "content_block.content", content) + } + output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) + + stop := []byte(`{"type":"content_block_stop","index":0}`) + stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2) + params.WebSearchToolResultIDs[toolUseID] = struct{}{} + params.BlockIndex++ + if toolUseID == params.LastWebSearchToolUseID { + params.LastWebSearchToolUseID = "" + } + return output +} + +func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) string { + for _, path := range []string{"id", "output_item_id", "call_id"} { + if value := strings.TrimSpace(item.Get(path).String()); value != "" { + return value + } + if value := strings.TrimSpace(root.Get(path).String()); value != "" { + return value + } + } + if params.LastWebSearchToolUseID != "" { + return params.LastWebSearchToolUseID + } + for _, path := range []string{"item_id"} { + if value := strings.TrimSpace(item.Get(path).String()); value != "" { + return value + } + if value := strings.TrimSpace(root.Get(path).String()); value != "" { + return value + } + } + id := fmt.Sprintf("web_search_%d", params.BlockIndex) + params.LastWebSearchToolUseID = id + return id +} + +func codexWebSearchQuery(root, item gjson.Result) string { + for _, path := range []string{"action.query", "query", "input.query"} { + if value := strings.TrimSpace(item.Get(path).String()); value != "" { + return value + } + if value := strings.TrimSpace(root.Get(path).String()); value != "" { + return value + } + } + return "" +} + +func codexWebSearchResultContent(root, item gjson.Result) []byte { + results := item.Get("results") + if !results.IsArray() { + results = root.Get("results") + } + if !results.IsArray() { + return nil + } + content := []byte(`[]`) + results.ForEach(func(_, result gjson.Result) bool { + url := strings.TrimSpace(result.Get("url").String()) + if url == "" { + return true + } + block := []byte(`{"type":"web_search_result","title":"","url":"","page_age":null}`) + block, _ = sjson.SetBytes(block, "url", url) + title := strings.TrimSpace(result.Get("title").String()) + if title == "" { + title = url + } + block, _ = sjson.SetBytes(block, "title", title) + content, _ = sjson.SetRawBytes(content, "-1", block) + return true + }) + return content +} + +func appendCodexWebSearchNonStreamContent(out []byte, item gjson.Result, seen map[string]struct{}) []byte { + id := strings.TrimSpace(item.Get("id").String()) + if id == "" { + return out + } + if seen == nil { + seen = make(map[string]struct{}) + } + if _, ok := seen[id]; ok { + return out + } + emptyRoot := gjson.Result{} + query := codexWebSearchQuery(emptyRoot, item) + resultContent := codexWebSearchResultContent(emptyRoot, item) + if query == "" && len(resultContent) == 0 { + return out + } + + useBlock := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`) + useBlock, _ = sjson.SetBytes(useBlock, "id", id) + if query != "" { + input, _ := json.Marshal(map[string]string{"query": query}) + useBlock, _ = sjson.SetRawBytes(useBlock, "input", input) + } + out, _ = sjson.SetRawBytes(out, "content.-1", useBlock) + + resultBlock := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`) + resultBlock, _ = sjson.SetBytes(resultBlock, "tool_use_id", id) + if len(resultContent) > 0 { + resultBlock, _ = sjson.SetRawBytes(resultBlock, "content", resultContent) + } + out, _ = sjson.SetRawBytes(out, "content.-1", resultBlock) + seen[id] = struct{}{} + return out +}