diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index d638eec0793..abde3183276 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -169,57 +169,38 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", finishReason) } else if dataType == "response.output_item.added" { itemResult := rootResult.Get("item") - if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" { + if !itemResult.Exists() || !isCodexToolCallItemType(itemResult.Get("type").String()) { return [][]byte{} } - // Increment index for this new function call item. - (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++ - (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = false - (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = true - - functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) - - // Restore original tool name if it was shortened. - name := itemResult.Get("name").String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig - } - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", "") + p := (*param).(*ConvertCliToOpenAIParams) + beginToolCallStream(p) + functionCallItemTemplate := buildToolCallAnnouncementTemplate(p, itemResult, originalRequestRawJSON) template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) - - } else if dataType == "response.function_call_arguments.delta" { - (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = true + template = appendToolCallDeltaToTemplate(template, functionCallItemTemplate) - deltaValue := rootResult.Get("delta").String() - functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", deltaValue) + } else if dataType == "response.function_call_arguments.delta" || dataType == "response.custom_tool_call_input.delta" { + p := (*param).(*ConvertCliToOpenAIParams) + p.HasReceivedArgumentsDelta = true - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + functionCallItemTemplate := buildToolCallArgumentsDeltaTemplate(p, rootResult.Get("delta").String()) + template = appendToolCallDeltaToTemplate(template, functionCallItemTemplate) - } else if dataType == "response.function_call_arguments.done" { + } else if dataType == "response.function_call_arguments.done" || dataType == "response.custom_tool_call_input.done" { if (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta { // Arguments were already streamed via delta events; nothing to emit. return [][]byte{} } // Fallback: no delta events were received, emit the full arguments as a single chunk. - fullArgs := rootResult.Get("arguments").String() - functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs) - - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + fullArgsField := "arguments" + if dataType == "response.custom_tool_call_input.done" { + fullArgsField = "input" + } + p := (*param).(*ConvertCliToOpenAIParams) + functionCallItemTemplate := buildToolCallArgumentsDeltaTemplate(p, rootResult.Get(fullArgsField).String()) + template = appendToolCallDeltaToTemplate(template, functionCallItemTemplate) } else if dataType == "response.output_item.done" { itemResult := rootResult.Get("item") @@ -262,36 +243,23 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) return [][]byte{template} } - if itemType != "function_call" { + if !isCodexToolCallItemType(itemType) { return [][]byte{} } - if (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced { + p := (*param).(*ConvertCliToOpenAIParams) + if p.HasToolCallAnnounced { // Tool call was already announced via output_item.added; skip emission. - (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = false + p.HasToolCallAnnounced = false return [][]byte{} } // Fallback path: model skipped output_item.added, so emit complete tool call now. - (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++ - - functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) - - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) - - // Restore original tool name if it was shortened. - name := itemResult.Get("name").String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig - } - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name) - - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String()) + p.FunctionCallIndex++ + functionCallItemTemplate := buildToolCallAnnouncementTemplate(p, itemResult, originalRequestRawJSON) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", toolCallArgumentsFromCodexItem(itemResult)) template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") - template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + template = appendToolCallDeltaToTemplate(template, functionCallItemTemplate) } else { return [][]byte{} @@ -401,8 +369,8 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } } } - case "function_call": - // Handle function call content + case "function_call", "custom_tool_call": + // Handle function and custom tool call content. functionCallTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() { @@ -410,16 +378,11 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } if nameResult := outputItem.Get("name"); nameResult.Exists() { - n := nameResult.String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[n]; ok { - n = orig - } - functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", n) + functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", resolveToolCallName(outputItem, originalRequestRawJSON)) } - if argsResult := outputItem.Get("arguments"); argsResult.Exists() { - functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", argsResult.String()) + if args := toolCallArgumentsFromCodexItem(outputItem); args != "" { + functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", args) } toolCalls = append(toolCalls, functionCallTemplate) @@ -485,6 +448,54 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original return template } +func isCodexToolCallItemType(itemType string) bool { + return itemType == "function_call" || itemType == "custom_tool_call" +} + +func beginToolCallStream(p *ConvertCliToOpenAIParams) { + p.FunctionCallIndex++ + p.HasReceivedArgumentsDelta = false + p.HasToolCallAnnounced = true +} + +func resolveToolCallName(itemResult gjson.Result, originalRequestRawJSON []byte) string { + name := itemResult.Get("name").String() + rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) + if orig, ok := rev[name]; ok { + name = orig + } + return name +} + +func toolCallArgumentsFromCodexItem(itemResult gjson.Result) string { + if itemResult.Get("type").String() == "custom_tool_call" { + return itemResult.Get("input").String() + } + return itemResult.Get("arguments").String() +} + +func buildToolCallAnnouncementTemplate(p *ConvertCliToOpenAIParams, itemResult gjson.Result, originalRequestRawJSON []byte) []byte { + functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", p.FunctionCallIndex) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", resolveToolCallName(itemResult, originalRequestRawJSON)) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", "") + return functionCallItemTemplate +} + +func buildToolCallArgumentsDeltaTemplate(p *ConvertCliToOpenAIParams, deltaValue string) []byte { + functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", p.FunctionCallIndex) + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", deltaValue) + return functionCallItemTemplate +} + +func appendToolCallDeltaToTemplate(template []byte, toolCallItem []byte) []byte { + template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) + template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", toolCallItem) + return template +} + // buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name // from the original OpenAI-style request JSON using the same shortening logic. func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string { diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go index 3e31d178a07..0308f44768f 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go @@ -150,6 +150,80 @@ func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageIma } } +func TestConvertCodexResponseToOpenAI_CustomToolCallStream(t *testing.T) { + ctx := context.Background() + var param any + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.5"}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected no output for response.created, got %d chunks", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 announcement chunk, got %d", len(out)) + } + if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "ApplyPatch" { + t.Fatalf("expected tool name ApplyPatch, got %q; chunk=%s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.id").String(); got != "call_apply" { + t.Fatalf("expected call id call_apply, got %q", got) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.delta","delta":"*** Begin Patch\n"}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 arguments delta chunk, got %d", len(out)) + } + if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "*** Begin Patch\n" { + t.Fatalf("expected arguments delta, got %q; chunk=%s", got, string(out[0])) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"*** Begin Patch\n"}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected output_item.done to be suppressed after announcement, got %d", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 completion chunk, got %d", len(out)) + } + if got := gjson.GetBytes(out[0], "choices.0.finish_reason").String(); got != "tool_calls" { + t.Fatalf("expected finish_reason tool_calls, got %q; chunk=%s", got, string(out[0])) + } +} + +func TestConvertCodexResponseToOpenAI_CustomToolCallInputDoneFallback(t *testing.T) { + ctx := context.Background() + var param any + + ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`), ¶m) + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.done","input":"*** Begin Patch\n"}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 fallback arguments chunk, got %d", len(out)) + } + if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "*** Begin Patch\n" { + t.Fatalf("expected fallback arguments, got %q; chunk=%s", got, string(out[0])) + } +} + +func TestConvertCodexResponseToOpenAINonStream_CustomToolCall(t *testing.T) { + ctx := context.Background() + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"*** Begin Patch\n"}]}}`) + out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil) + + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "ApplyPatch" { + t.Fatalf("expected tool name ApplyPatch, got %q; resp=%s", got, string(out)) + } + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != "*** Begin Patch\n" { + t.Fatalf("expected tool arguments, got %q; resp=%s", got, string(out)) + } + if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" { + t.Fatalf("expected finish_reason tool_calls, got %q; resp=%s", got, string(out)) + } +} + func TestConvertCodexResponseToOpenAI_NonStreamMultiMessageEmptyTrailingKeepsContent(t *testing.T) { ctx := context.Background() raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15},"output":[` +