feat(translator): defer Codex function call starts until function name is available

- Updated `ConvertCodexResponseToClaude` to delay emitting `function_call` start events until the `name` field is resolved.
- Introduced `pendingCodexFunctionCall` for buffering incomplete function calls.
- Added tests to ensure proper behavior for deferred starts, including argument buffering and finalization.

Closes: #3471
This commit is contained in:
Luis Pater
2026-06-20 00:11:12 +08:00
parent 51aa5ba925
commit 34639c3cf9
2 changed files with 203 additions and 39 deletions
@@ -23,18 +23,28 @@ var (
// ConvertCodexResponseToClaudeParams holds parameters for response conversion.
type ConvertCodexResponseToClaudeParams struct {
HasToolCall bool
HasToolCall bool
BlockIndex int
HasReceivedArgumentsDelta bool
HasTextDelta bool
TextBlockOpen bool
ThinkingBlockOpen bool
ThinkingStopPending bool
ThinkingSignature string
ThinkingSummarySeen bool
WebSearchToolUseIDs map[string]struct{}
WebSearchToolResultIDs map[string]struct{}
LastWebSearchToolUseID string
PendingFunctionCalls map[string]*pendingCodexFunctionCall
LastPendingFunctionCallKey string
}
type pendingCodexFunctionCall struct {
CallID string
Arguments string
BlockIndex int
HasReceivedArgumentsDelta bool
HasTextDelta bool
TextBlockOpen bool
ThinkingBlockOpen bool
ThinkingStopPending bool
ThinkingSignature string
ThinkingSummarySeen bool
WebSearchToolUseIDs map[string]struct{}
WebSearchToolResultIDs map[string]struct{}
LastWebSearchToolUseID string
StartEmitted bool
}
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
@@ -145,24 +155,26 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, stopCodexTextBlock(params)...)
params.HasToolCall = true
params.HasReceivedArgumentsDelta = false
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(itemResult.Get("call_id").String())))
{
name := itemResult.Get("name").String()
rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
name = orig
callID := itemResult.Get("call_id").String()
name := itemResult.Get("name").String()
key := codexFunctionCallKey(rootResult, itemResult)
if name == "" {
if params.PendingFunctionCalls == nil {
params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{}
}
template, _ = sjson.SetBytes(template, "content_block.name", name)
params.PendingFunctionCalls[key] = &pendingCodexFunctionCall{
CallID: callID,
BlockIndex: params.BlockIndex,
}
params.LastPendingFunctionCallKey = key
params.BlockIndex++
break
}
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
delete(params.PendingFunctionCalls, key)
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, params.BlockIndex)
output = appendCodexFunctionCallArgumentDelta(output, "", params.BlockIndex)
case "reasoning":
params.ThinkingSummarySeen = false
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
@@ -207,11 +219,36 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, stopCodexTextBlock(params)...)
params.HasTextDelta = true
case "function_call":
template = []byte(`{"type":"content_block_stop","index":0}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
params.BlockIndex++
key := codexFunctionCallKey(rootResult, itemResult)
if pending, pendingKey := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted {
name := itemResult.Get("name").String()
if name == "" {
return [][]byte{output}
}
callID := pending.CallID
if callID == "" {
callID = itemResult.Get("call_id").String()
}
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, pending.BlockIndex)
pending.StartEmitted = true
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
args := pending.Arguments
if args == "" {
args = itemResult.Get("arguments").String()
}
if args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, pending.BlockIndex)
}
output = appendCodexFunctionCallStop(output, pending.BlockIndex)
delete(params.PendingFunctionCalls, pendingKey)
if params.LastPendingFunctionCallKey == pendingKey {
params.LastPendingFunctionCallKey = ""
}
} else {
output = appendCodexFunctionCallStop(output, params.BlockIndex)
params.BlockIndex++
}
case "reasoning":
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
params.ThinkingSignature = signature
@@ -227,20 +264,28 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult)
}
case "response.function_call_arguments.delta":
params.HasReceivedArgumentsDelta = true
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "delta.partial_json", rootResult.Get("delta").String())
delta := rootResult.Get("delta").String()
key := codexArgumentsFunctionCallKey(params, rootResult)
if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted {
pending.HasReceivedArgumentsDelta = true
pending.Arguments += delta
break
}
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
params.HasReceivedArgumentsDelta = true
output = appendCodexFunctionCallArgumentDelta(output, delta, params.BlockIndex)
case "response.function_call_arguments.done":
key := codexArgumentsFunctionCallKey(params, rootResult)
if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted {
if !pending.HasReceivedArgumentsDelta {
pending.Arguments = rootResult.Get("arguments").String()
}
break
}
if !params.HasReceivedArgumentsDelta {
if args := rootResult.Get("arguments").String(); args != "" {
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
template, _ = sjson.SetBytes(template, "delta.partial_json", args)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
output = appendCodexFunctionCallArgumentDelta(output, args, params.BlockIndex)
}
}
}
@@ -458,6 +503,69 @@ func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) [
return out
}
func codexFunctionCallKey(rootResult, itemResult gjson.Result) string {
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
return "output:" + outputIndex.Raw
}
if callID := itemResult.Get("call_id").String(); callID != "" {
return "call:" + callID
}
return "last"
}
func codexArgumentsFunctionCallKey(params *ConvertCodexResponseToClaudeParams, rootResult gjson.Result) string {
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
return "output:" + outputIndex.Raw
}
return params.LastPendingFunctionCallKey
}
func pendingCodexFunctionCallForKey(params *ConvertCodexResponseToClaudeParams, key string) (*pendingCodexFunctionCall, string) {
if params == nil || params.PendingFunctionCalls == nil {
return nil, ""
}
if key != "" {
if pending, ok := params.PendingFunctionCalls[key]; ok {
return pending, key
}
}
if params.LastPendingFunctionCallKey != "" {
if pending, ok := params.PendingFunctionCalls[params.LastPendingFunctionCallKey]; ok {
return pending, params.LastPendingFunctionCallKey
}
}
return nil, ""
}
func appendCodexFunctionCallStart(output []byte, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte {
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID)))
template, _ = sjson.SetBytes(template, "content_block.name", resolveCodexClaudeToolUseName(originalRequestRawJSON, name))
return translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
}
func appendCodexFunctionCallArgumentDelta(output []byte, partialJSON string, blockIndex int) []byte {
template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
template, _ = sjson.SetBytes(template, "delta.partial_json", partialJSON)
return translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
}
func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte {
template := []byte(`{"type":"content_block_stop","index":0}`)
template, _ = sjson.SetBytes(template, "index", blockIndex)
return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
}
func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string {
rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
return orig
}
return name
}
func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) {
if !usage.Exists() || usage.Type == gjson.Null {
return 0, 0, 0
@@ -531,6 +531,62 @@ func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostS
}
}
func TestConvertCodexResponseToClaude_StreamFunctionCallDefersStartUntilDoneName(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"web_search","description":"search"}]}`)
var param any
_ = ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`), &param)
addedOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1"},"output_index":1}`), &param)
argumentsOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"example\"}","output_index":1}`), &param)
doneOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"example\"}"},"output_index":1}`), &param)
if bytes.Contains(bytes.Join(addedOutputs, nil), []byte(`"content_block_start"`)) {
t.Fatalf("function_call without name must not emit content_block_start: %q", addedOutputs)
}
if bytes.Contains(bytes.Join(argumentsOutputs, nil), []byte(`"input_json_delta"`)) {
t.Fatalf("arguments must be buffered until the tool name is available: %q", argumentsOutputs)
}
var toolStartCount int
var toolStopCount int
var argumentDeltas []string
for _, out := range doneOutputs {
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() != "tool_use" {
continue
}
toolStartCount++
if got := data.Get("content_block.name").String(); got != "web_search" {
t.Fatalf("unexpected tool_use name %q in %s", got, data.Raw)
}
case "content_block_delta":
if data.Get("delta.type").String() == "input_json_delta" {
argumentDeltas = append(argumentDeltas, data.Get("delta.partial_json").String())
}
case "content_block_stop":
toolStopCount++
}
}
}
if toolStartCount != 1 {
t.Fatalf("expected one deferred tool_use start, got %d in %q", toolStartCount, doneOutputs)
}
if len(argumentDeltas) != 1 || argumentDeltas[0] != `{"query":"example"}` {
t.Fatalf("unexpected buffered argument deltas: %v", argumentDeltas)
}
if toolStopCount != 1 {
t.Fatalf("expected one deferred tool_use stop, got %d in %q", toolStopCount, doneOutputs)
}
}
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)