Skip to content

Commit 30dc2e7

Browse files
authored
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.
1 parent 2884a67 commit 30dc2e7

3 files changed

Lines changed: 327 additions & 0 deletions

File tree

internal/translator/codex/claude/codex_claude_response.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ type ConvertCodexResponseToClaudeParams struct {
3232
ThinkingStopPending bool
3333
ThinkingSignature string
3434
ThinkingSummarySeen bool
35+
WebSearchToolUseIDs map[string]struct{}
36+
WebSearchToolResultIDs map[string]struct{}
37+
LastWebSearchToolUseID string
3538
}
3639

3740
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
@@ -120,6 +123,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
120123
params.BlockIndex++
121124

122125
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
126+
case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress":
127+
// Wait for populated web_search_call items on output_item.done.
123128
case "response.completed", "response.incomplete":
124129
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
125130
responseData := rootResult.Get("response")
@@ -163,6 +168,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
163168
case "reasoning":
164169
params.ThinkingSummarySeen = false
165170
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
171+
case "web_search_call":
172+
// Defer server_tool_use until output_item.done carries action/query.
166173
}
167174
case "response.output_item.done":
168175
itemResult := rootResult.Get("item")
@@ -227,6 +234,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
227234
}
228235
params.ThinkingSignature = ""
229236
params.ThinkingSummarySeen = false
237+
case "web_search_call":
238+
output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult)
230239
}
231240
case "response.function_call_arguments.delta":
232241
params.HasReceivedArgumentsDelta = true
@@ -311,6 +320,7 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
311320
}
312321

313322
hasToolCall := false
323+
webSearchSeen := make(map[string]struct{})
314324

315325
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
316326
output.ForEach(func(_, item gjson.Result) bool {
@@ -379,6 +389,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
379389
}
380390
}
381391
}
392+
case "web_search_call":
393+
out = appendCodexWebSearchNonStreamContent(out, item, webSearchSeen)
382394
case "function_call":
383395
hasToolCall = true
384396
name := item.Get("name").String()

internal/translator/codex/claude/codex_claude_response_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package claude
22

33
import (
4+
"bytes"
45
"context"
56
"strings"
67
"testing"
@@ -508,6 +509,74 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage
508509
}
509510
}
510511

512+
func TestConvertCodexResponseToClaude_StreamWebSearchCallEmitsClaudeServerToolBlocks(t *testing.T) {
513+
ctx := context.Background()
514+
originalRequest := []byte(`{
515+
"tools":[{"type":"web_search_20250305","name":"web_search"}],
516+
"messages":[{"role":"user","content":"search weather"}]
517+
}`)
518+
var param any
519+
520+
chunks := [][]byte{
521+
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4"}}`),
522+
[]byte(`data: {"type":"response.output_item.added","item":{"id":"ws_123","type":"web_search_call","status":"in_progress"}}`),
523+
[]byte(`data: {"type":"response.web_search_call.searching","item_id":"ws_123"}`),
524+
[]byte(`data: {"type":"response.web_search_call.completed","item_id":"ws_123"}`),
525+
[]byte(`data: {"type":"response.output_item.done","item":{"id":"ws_123","type":"web_search_call","status":"completed","action":{"type":"search","query":"search weather"}}}`),
526+
[]byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2}}}`),
527+
}
528+
var outputs [][]byte
529+
for _, chunk := range chunks {
530+
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
531+
}
532+
outputText := string(bytes.Join(outputs, nil))
533+
534+
for _, needle := range []string{
535+
`"type":"server_tool_use"`,
536+
`"id":"ws_123"`,
537+
`"type":"web_search_tool_result"`,
538+
`event: message_stop`,
539+
} {
540+
if !strings.Contains(outputText, needle) {
541+
t.Fatalf("stream output missing %s:\n%s", needle, outputText)
542+
}
543+
}
544+
serverToolIndex := strings.Index(outputText, `"type":"server_tool_use"`)
545+
resultIndex := strings.Index(outputText, `"type":"web_search_tool_result"`)
546+
if serverToolIndex < 0 || resultIndex < 0 || resultIndex < serverToolIndex {
547+
t.Fatalf("web_search_tool_result must follow server_tool_use:\n%s", outputText)
548+
}
549+
if !strings.Contains(outputText, `partial_json`) || !strings.Contains(outputText, "search weather") {
550+
t.Fatalf("expected web search query delta after populated output_item.done:\n%s", outputText)
551+
}
552+
}
553+
554+
func TestConvertCodexResponseToClaude_StreamWebSearchCallReusesFallbackToolUseID(t *testing.T) {
555+
ctx := context.Background()
556+
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`)
557+
var param any
558+
559+
chunks := [][]byte{
560+
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4"}}`),
561+
[]byte(`data: {"type":"response.output_item.added","item":{"type":"web_search_call","status":"in_progress"}}`),
562+
[]byte(`data: {"type":"response.web_search_call.completed","item_id":"ws_from_upstream"}`),
563+
[]byte(`data: {"type":"response.output_item.done","item":{"id":"ws_from_upstream","type":"web_search_call","status":"completed","action":{"type":"search","query":"search weather"}}}`),
564+
[]byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2}}}`),
565+
}
566+
var outputs [][]byte
567+
for _, chunk := range chunks {
568+
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
569+
}
570+
outputText := string(bytes.Join(outputs, nil))
571+
572+
if strings.Count(outputText, `"type":"server_tool_use"`) != 1 {
573+
t.Fatalf("expected exactly one server_tool_use block, got output:\n%s", outputText)
574+
}
575+
if !strings.Contains(outputText, `"tool_use_id":"ws_from_upstream"`) {
576+
t.Fatalf("expected web_search_tool_result to reuse fallback tool_use_id:\n%s", outputText)
577+
}
578+
}
579+
511580
func TestConvertCodexResponseToClaude_ShortensLongToolUseIDs(t *testing.T) {
512581
longCallID := "call_" + strings.Repeat("a", 62)
513582
if len(longCallID) <= 64 {
@@ -649,6 +718,63 @@ func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) {
649718
}
650719
}
651720

721+
func TestConvertCodexResponseToClaudeNonStream_WebSearchCallEmitsServerToolBlocks(t *testing.T) {
722+
ctx := context.Background()
723+
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`)
724+
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"}]}]}}`)
725+
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
726+
parsed := gjson.ParseBytes(out)
727+
types := []string{}
728+
parsed.Get("content").ForEach(func(_, value gjson.Result) bool {
729+
types = append(types, value.Get("type").String())
730+
return true
731+
})
732+
for _, want := range []string{"server_tool_use", "web_search_tool_result", "text"} {
733+
found := false
734+
for _, got := range types {
735+
if got == want {
736+
found = true
737+
break
738+
}
739+
}
740+
if !found {
741+
found = strings.Contains(string(out), `"type":"`+want+`"`)
742+
}
743+
if !found {
744+
t.Fatalf("missing content type %s in %s", want, string(out))
745+
}
746+
}
747+
if parsed.Get("content.0.input.query").String() != "search weather" {
748+
if !strings.Contains(string(out), "search weather") {
749+
t.Fatalf("expected web search query in non-stream output: %s", string(out))
750+
}
751+
}
752+
}
753+
754+
func TestConvertCodexResponseToClaudeNonStream_WebSearchStopReasonEndTurn(t *testing.T) {
755+
ctx := context.Background()
756+
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`)
757+
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"}]}]}}`)
758+
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
759+
parsed := gjson.ParseBytes(out)
760+
if got := parsed.Get("stop_reason").String(); got != "end_turn" {
761+
t.Fatalf("stop_reason = %q, want end_turn when only server web_search and text are present", got)
762+
}
763+
}
764+
765+
func TestConvertCodexResponseToClaudeNonStream_WebSearchDedupesEmptyOpenPageItems(t *testing.T) {
766+
ctx := context.Background()
767+
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"q"}]}`)
768+
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"}]}]}}`)
769+
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
770+
if strings.Count(string(out), `"type":"server_tool_use"`) != 1 {
771+
t.Fatalf("expected one server_tool_use after dedupe, got %s", string(out))
772+
}
773+
if !strings.Contains(string(out), "weather") {
774+
t.Fatalf("expected populated query item to be kept: %s", string(out))
775+
}
776+
}
777+
652778
func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) {
653779
tests := []struct {
654780
name string
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package claude
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
9+
"github.com/tidwall/gjson"
10+
"github.com/tidwall/sjson"
11+
)
12+
13+
func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
14+
toolUseID := codexWebSearchToolUseID(params, root, item)
15+
if toolUseID == "" {
16+
return output
17+
}
18+
if params.WebSearchToolUseIDs == nil {
19+
params.WebSearchToolUseIDs = make(map[string]struct{})
20+
}
21+
query := codexWebSearchQuery(root, item)
22+
alreadyStarted := false
23+
if _, ok := params.WebSearchToolUseIDs[toolUseID]; ok {
24+
alreadyStarted = true
25+
if query == "" {
26+
return output
27+
}
28+
}
29+
30+
if !alreadyStarted {
31+
output = append(output, finalizeCodexThinkingBlock(params)...)
32+
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`)
33+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
34+
template, _ = sjson.SetBytes(template, "content_block.id", toolUseID)
35+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
36+
}
37+
38+
if query != "" {
39+
partialJSON, _ := json.Marshal(map[string]string{"query": query})
40+
delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
41+
delta, _ = sjson.SetBytes(delta, "index", params.BlockIndex)
42+
delta, _ = sjson.SetBytes(delta, "delta.partial_json", string(partialJSON))
43+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", delta, 2)
44+
}
45+
46+
if !alreadyStarted {
47+
stop := []byte(`{"type":"content_block_stop","index":0}`)
48+
stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
49+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
50+
params.WebSearchToolUseIDs[toolUseID] = struct{}{}
51+
params.BlockIndex++
52+
}
53+
return output
54+
}
55+
56+
func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
57+
toolUseID := codexWebSearchToolUseID(params, root, item)
58+
if toolUseID == "" {
59+
return output
60+
}
61+
output = appendCodexWebSearchServerToolUse(output, params, root, item)
62+
if params.WebSearchToolResultIDs == nil {
63+
params.WebSearchToolResultIDs = make(map[string]struct{})
64+
}
65+
if _, ok := params.WebSearchToolResultIDs[toolUseID]; ok {
66+
return output
67+
}
68+
if codexWebSearchQuery(root, item) == "" && len(codexWebSearchResultContent(root, item)) == 0 && item.Get("action").Exists() == false {
69+
return output
70+
}
71+
72+
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"","content":[]}}`)
73+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
74+
template, _ = sjson.SetBytes(template, "content_block.tool_use_id", toolUseID)
75+
if content := codexWebSearchResultContent(root, item); len(content) > 0 {
76+
template, _ = sjson.SetRawBytes(template, "content_block.content", content)
77+
}
78+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
79+
80+
stop := []byte(`{"type":"content_block_stop","index":0}`)
81+
stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
82+
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
83+
params.WebSearchToolResultIDs[toolUseID] = struct{}{}
84+
params.BlockIndex++
85+
if toolUseID == params.LastWebSearchToolUseID {
86+
params.LastWebSearchToolUseID = ""
87+
}
88+
return output
89+
}
90+
91+
func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) string {
92+
for _, path := range []string{"id", "output_item_id", "call_id"} {
93+
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
94+
return value
95+
}
96+
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
97+
return value
98+
}
99+
}
100+
if params.LastWebSearchToolUseID != "" {
101+
return params.LastWebSearchToolUseID
102+
}
103+
for _, path := range []string{"item_id"} {
104+
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
105+
return value
106+
}
107+
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
108+
return value
109+
}
110+
}
111+
id := fmt.Sprintf("web_search_%d", params.BlockIndex)
112+
params.LastWebSearchToolUseID = id
113+
return id
114+
}
115+
116+
func codexWebSearchQuery(root, item gjson.Result) string {
117+
for _, path := range []string{"action.query", "query", "input.query"} {
118+
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
119+
return value
120+
}
121+
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
122+
return value
123+
}
124+
}
125+
return ""
126+
}
127+
128+
func codexWebSearchResultContent(root, item gjson.Result) []byte {
129+
results := item.Get("results")
130+
if !results.IsArray() {
131+
results = root.Get("results")
132+
}
133+
if !results.IsArray() {
134+
return nil
135+
}
136+
content := []byte(`[]`)
137+
results.ForEach(func(_, result gjson.Result) bool {
138+
url := strings.TrimSpace(result.Get("url").String())
139+
if url == "" {
140+
return true
141+
}
142+
block := []byte(`{"type":"web_search_result","title":"","url":"","page_age":null}`)
143+
block, _ = sjson.SetBytes(block, "url", url)
144+
title := strings.TrimSpace(result.Get("title").String())
145+
if title == "" {
146+
title = url
147+
}
148+
block, _ = sjson.SetBytes(block, "title", title)
149+
content, _ = sjson.SetRawBytes(content, "-1", block)
150+
return true
151+
})
152+
return content
153+
}
154+
155+
func appendCodexWebSearchNonStreamContent(out []byte, item gjson.Result, seen map[string]struct{}) []byte {
156+
id := strings.TrimSpace(item.Get("id").String())
157+
if id == "" {
158+
return out
159+
}
160+
if seen == nil {
161+
seen = make(map[string]struct{})
162+
}
163+
if _, ok := seen[id]; ok {
164+
return out
165+
}
166+
emptyRoot := gjson.Result{}
167+
query := codexWebSearchQuery(emptyRoot, item)
168+
resultContent := codexWebSearchResultContent(emptyRoot, item)
169+
if query == "" && len(resultContent) == 0 {
170+
return out
171+
}
172+
173+
useBlock := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
174+
useBlock, _ = sjson.SetBytes(useBlock, "id", id)
175+
if query != "" {
176+
input, _ := json.Marshal(map[string]string{"query": query})
177+
useBlock, _ = sjson.SetRawBytes(useBlock, "input", input)
178+
}
179+
out, _ = sjson.SetRawBytes(out, "content.-1", useBlock)
180+
181+
resultBlock := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
182+
resultBlock, _ = sjson.SetBytes(resultBlock, "tool_use_id", id)
183+
if len(resultContent) > 0 {
184+
resultBlock, _ = sjson.SetRawBytes(resultBlock, "content", resultContent)
185+
}
186+
out, _ = sjson.SetRawBytes(out, "content.-1", resultBlock)
187+
seen[id] = struct{}{}
188+
return out
189+
}

0 commit comments

Comments
 (0)