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.
This commit is contained in:
sususu98
2026-06-16 23:07:08 +08:00
committed by GitHub
parent 2884a67ed0
commit 30dc2e7f34
3 changed files with 327 additions and 0 deletions

View File

@@ -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()

View File

@@ -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, &param)...)
}
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, &param)...)
}
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

View File

@@ -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
}