mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
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:
@@ -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"}}`), ¶m)
|
||||
addedOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1"},"output_index":1}`), ¶m)
|
||||
argumentsOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"example\"}","output_index":1}`), ¶m)
|
||||
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}`), ¶m)
|
||||
|
||||
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":[]}`)
|
||||
|
||||
Reference in New Issue
Block a user