Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -401,25 +369,20 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
}
}
}
case "function_call":
// Handle function call content
case "function_call", "custom_tool_call":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve custom tool-call type across the chat round trip

When this branch exposes a custom_tool_call as a Chat Completions tool_calls[].type:"function", the next client turn has no way to indicate that the tool result belongs to a custom Responses call; the existing request translator still hard-codes every role:"tool" message to function_call_output in internal/translator/codex/openai/chat-completions/codex_openai_request.go:122-128. In the ApplyPatch/custom-tool flow, the model call is now surfaced to the client, but the result is sent back upstream as a function output instead of custom_tool_call_output, so the custom call cannot be continued correctly unless the call_id/type is preserved and used on the request side.

Useful? React with 👍 / 👎.

// Handle function and custom tool call content.
functionCallTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)

if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", callIdResult.String())
}

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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}`), &param)
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":""}}`), &param)
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"}`), &param)
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"}}`), &param)
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}}}`), &param)
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":""}}`), &param)

out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.custom_tool_call_input.done","input":"*** Begin Patch\n"}`), &param)
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":[` +
Expand Down
Loading