From a34dfed3780ec5ab654183148460020a635450b6 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 24 Mar 2026 19:12:52 +0100 Subject: [PATCH 1/6] fix: preserve Claude thinking signatures in Codex translator --- .../codex/claude/codex_claude_response.go | 121 ++++++++----- .../claude/codex_claude_response_test.go | 160 ++++++++++++++++++ 2 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 internal/translator/codex/claude/codex_claude_response_test.go diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index b436cd3f..0ddd0845 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -26,6 +26,9 @@ type ConvertCodexResponseToClaudeParams struct { HasToolCall bool BlockIndex int HasReceivedArgumentsDelta bool + ThinkingBlockOpen bool + ThinkingStopPending bool + ThinkingSignature string } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -44,7 +47,7 @@ type ConvertCodexResponseToClaudeParams struct { // // Returns: // - [][]byte: A slice of Claude Code-compatible JSON responses -func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { +func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &ConvertCodexResponseToClaudeParams{ HasToolCall: false, @@ -52,7 +55,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } } - // log.Debugf("rawJSON: %s", string(rawJSON)) if !bytes.HasPrefix(rawJSON, dataTag) { return [][]byte{} } @@ -60,9 +62,18 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output := make([]byte, 0, 512) rootResult := gjson.ParseBytes(rawJSON) + params := (*param).(*ConvertCodexResponseToClaudeParams) + if params.ThinkingBlockOpen && params.ThinkingStopPending { + switch rootResult.Get("type").String() { + case "response.content_part.added", "response.completed": + output = append(output, finalizeCodexThinkingBlock(params)...) + } + } + typeResult := rootResult.Get("type") typeStr := typeResult.String() var template []byte + if typeStr == "response.created" { template = []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0},"content":[],"stop_reason":null}}`) template, _ = sjson.SetBytes(template, "message.model", rootResult.Get("response.model").String()) @@ -70,43 +81,44 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2) } else if typeStr == "response.reasoning_summary_part.added" { - template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.ThinkingBlockOpen = true + params.ThinkingStopPending = false + params.ThinkingSignature = "" output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) } else if typeStr == "response.reasoning_summary_text.delta" { template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.thinking", rootResult.Get("delta").String()) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if typeStr == "response.reasoning_summary_part.done" { - template = []byte(`{"type":"content_block_stop","index":0}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) - (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ - - output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) - + params.ThinkingStopPending = true + if params.ThinkingSignature != "" { + output = append(output, finalizeCodexThinkingBlock(params)...) + } } else if typeStr == "response.content_part.added" { template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) } else if typeStr == "response.output_text.delta" { template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String()) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if typeStr == "response.content_part.done" { template = []byte(`{"type":"content_block_stop","index":0}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) - (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) } else if typeStr == "response.completed" { template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall + p := params.HasToolCall stopReason := rootResult.Get("response.stop_reason").String() if p { template, _ = sjson.SetBytes(template, "delta.stop_reason", "tool_use") @@ -128,13 +140,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() if itemType == "function_call" { - (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall = true - (*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta = false + output = append(output, finalizeCodexThinkingBlock(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", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "content_block.id", util.SanitizeClaudeToolID(itemResult.Get("call_id").String())) { - // Restore original tool name if shortened name := itemResult.Get("name").String() rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) if orig, ok := rev[name]; ok { @@ -146,37 +158,40 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa 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", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) + } else if itemType == "reasoning" { + params.ThinkingSignature = itemResult.Get("encrypted_content").String() + if params.ThinkingStopPending { + output = append(output, finalizeCodexThinkingBlock(params)...) + } } } else if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() if itemType == "function_call" { template = []byte(`{"type":"content_block_stop","index":0}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) - (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) + } else if itemType == "reasoning" { + params.ThinkingSignature = itemResult.Get("encrypted_content").String() + output = append(output, finalizeCodexThinkingBlock(params)...) } } else if typeStr == "response.function_call_arguments.delta" { - (*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta = true + params.HasReceivedArgumentsDelta = true template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) - template, _ = sjson.SetBytes(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.partial_json", rootResult.Get("delta").String()) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if typeStr == "response.function_call_arguments.done" { - // Some models (e.g. gpt-5.3-codex-spark) send function call arguments - // in a single "done" event without preceding "delta" events. - // Emit the full arguments as a single input_json_delta so the - // downstream Claude client receives the complete tool input. - // When delta events were already received, skip to avoid duplicating arguments. - if !(*param).(*ConvertCodexResponseToClaudeParams).HasReceivedArgumentsDelta { + 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", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.partial_json", args) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) @@ -191,15 +206,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa // This function processes the complete Codex response and transforms it into a single Claude Code-compatible // JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all // the information into a single response that matches the Claude Code API format. -// -// Parameters: -// - ctx: The context for the request, used for cancellation and timeout handling -// - modelName: The name of the model being used for the response (unused in current implementation) -// - rawJSON: The raw JSON response from the Codex API -// - param: A pointer to a parameter object for the conversion (unused in current implementation) -// -// Returns: -// - []byte: A Claude Code-compatible JSON response containing all message content and metadata func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte { revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) @@ -230,6 +236,7 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original switch item.Get("type").String() { case "reasoning": thinkingBuilder := strings.Builder{} + signature := item.Get("encrypted_content").String() if summary := item.Get("summary"); summary.Exists() { if summary.IsArray() { summary.ForEach(func(_, part gjson.Result) bool { @@ -260,9 +267,10 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } } } - if thinkingBuilder.Len() > 0 { - block := []byte(`{"type":"thinking","thinking":""}`) + if thinkingBuilder.Len() > 0 || signature != "" { + block := []byte(`{"type":"thinking","thinking":"","signature":""}`) block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) + block, _ = sjson.SetBytes(block, "signature", signature) out, _ = sjson.SetRawBytes(out, "content.-1", block) } case "message": @@ -371,6 +379,31 @@ func buildReverseMapFromClaudeOriginalShortToOriginal(original []byte) map[strin return rev } -func ClaudeTokenCount(ctx context.Context, count int64) []byte { +func ClaudeTokenCount(_ context.Context, count int64) []byte { return translatorcommon.ClaudeInputTokensJSON(count) } + +func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if !params.ThinkingBlockOpen { + return nil + } + + output := make([]byte, 0, 256) + if params.ThinkingSignature != "" { + signatureDelta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", params.BlockIndex) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", params.ThinkingSignature) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", signatureDelta, 2) + } + + contentBlockStop := []byte(`{"type":"content_block_stop","index":0}`) + contentBlockStop, _ = sjson.SetBytes(contentBlockStop, "index", params.BlockIndex) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", contentBlockStop, 2) + + params.BlockIndex++ + params.ThinkingBlockOpen = false + params.ThinkingStopPending = false + params.ThinkingSignature = "" + + return output +} diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go new file mode 100644 index 00000000..d903dcf7 --- /dev/null +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -0,0 +1,160 @@ +package claude + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"model\":\"gpt-5\"}}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me think\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_123\"}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + startFound := false + signatureDeltaFound := false + stopFound := false + + for _, out := range outputs { + 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() == "thinking" { + startFound = true + if !data.Get("content_block.signature").Exists() { + t.Fatalf("thinking start block missing signature field: %s", line) + } + } + case "content_block_delta": + if data.Get("delta.type").String() == "signature_delta" { + signatureDeltaFound = true + if got := data.Get("delta.signature").String(); got != "enc_sig_123" { + t.Fatalf("unexpected signature delta: %q", got) + } + } + case "content_block_stop": + stopFound = true + } + } + } + + if !startFound { + t.Fatal("expected thinking content_block_start event") + } + if !signatureDeltaFound { + t.Fatal("expected signature_delta event for thinking block") + } + if !stopFound { + t.Fatal("expected content_block_stop event for thinking block") + } +} + +func TestConvertCodexResponseToClaude_StreamThinkingWithoutReasoningItemStillIncludesSignatureField(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me think\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + thinkingStartFound := false + thinkingStopFound := false + signatureDeltaFound := false + + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" { + thinkingStartFound = true + if !data.Get("content_block.signature").Exists() { + t.Fatalf("thinking start block missing signature field: %s", line) + } + } + if data.Get("type").String() == "content_block_stop" && data.Get("index").Int() == 0 { + thinkingStopFound = true + } + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "signature_delta" { + signatureDeltaFound = true + } + } + } + + if !thinkingStartFound { + t.Fatal("expected thinking content_block_start event") + } + if !thinkingStopFound { + t.Fatal("expected thinking content_block_stop event") + } + if signatureDeltaFound { + t.Fatal("did not expect signature_delta without encrypted_content") + } +} + +func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + response := []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_123", + "model":"gpt-5", + "usage":{"input_tokens":10,"output_tokens":20}, + "output":[ + { + "type":"reasoning", + "encrypted_content":"enc_sig_nonstream", + "summary":[{"type":"summary_text","text":"internal reasoning"}] + }, + { + "type":"message", + "content":[{"type":"output_text","text":"final answer"}] + } + ] + } + }`) + + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + parsed := gjson.ParseBytes(out) + + thinking := parsed.Get("content.0") + if thinking.Get("type").String() != "thinking" { + t.Fatalf("expected first content block to be thinking, got %s", thinking.Raw) + } + if got := thinking.Get("signature").String(); got != "enc_sig_nonstream" { + t.Fatalf("expected signature to be preserved, got %q", got) + } + if got := thinking.Get("thinking").String(); got != "internal reasoning" { + t.Fatalf("unexpected thinking text: %q", got) + } +} From 76b53d6b5b6c7cc48b174d2cfcf611b4f5ccefce Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 24 Mar 2026 19:34:11 +0100 Subject: [PATCH 2/6] fix: finalize pending thinking block before next summary part --- .../codex/claude/codex_claude_response.go | 3 ++ .../claude/codex_claude_response_test.go | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 0ddd0845..4f027543 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -81,6 +81,9 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2) } else if typeStr == "response.reasoning_summary_part.added" { + if params.ThinkingBlockOpen && params.ThinkingStopPending { + output = append(output, finalizeCodexThinkingBlock(params)...) + } template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) params.ThinkingBlockOpen = true diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index d903dcf7..5a25057c 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -121,6 +121,48 @@ func TestConvertCodexResponseToClaude_StreamThinkingWithoutReasoningItemStillInc } } +func TestConvertCodexResponseToClaude_StreamThinkingFinalizesPendingBlockBeforeNextSummaryPart(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"First part\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + startCount := 0 + stopCount := 0 + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" { + startCount++ + } + if data.Get("type").String() == "content_block_stop" { + stopCount++ + } + } + } + + if startCount != 2 { + t.Fatalf("expected 2 thinking block starts, got %d", startCount) + } + if stopCount != 1 { + t.Fatalf("expected pending thinking block to be finalized before second start, got %d stops", stopCount) + } +} + func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"messages":[]}`) From c31ae2f3b598e228ca4058984504cde278d513e5 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 24 Mar 2026 20:08:23 +0100 Subject: [PATCH 3/6] fix: retain previously captured thinking signature on new summary part --- internal/translator/codex/claude/codex_claude_response.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 4f027543..798089d0 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -88,7 +88,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa template, _ = sjson.SetBytes(template, "index", params.BlockIndex) params.ThinkingBlockOpen = true params.ThinkingStopPending = false - params.ThinkingSignature = "" output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) } else if typeStr == "response.reasoning_summary_text.delta" { From 73b22ec29b26e6fbb682df0d873e4e538ecfbd1f Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Wed, 25 Mar 2026 07:44:21 +0100 Subject: [PATCH 4/6] fix: omit empty signature field from thinking blocks Emit signature only when non-empty in both streaming content_block_start and non-streaming thinking blocks. Avoids turning 'missing signature' into 'empty/invalid signature' which Claude clients may reject. --- internal/translator/codex/claude/codex_claude_response.go | 8 +++++--- .../translator/codex/claude/codex_claude_response_test.go | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 798089d0..4557606f 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -84,7 +84,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if params.ThinkingBlockOpen && params.ThinkingStopPending { output = append(output, finalizeCodexThinkingBlock(params)...) } - template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`) + template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) params.ThinkingBlockOpen = true params.ThinkingStopPending = false @@ -270,9 +270,11 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } } if thinkingBuilder.Len() > 0 || signature != "" { - block := []byte(`{"type":"thinking","thinking":"","signature":""}`) + block := []byte(`{"type":"thinking","thinking":""}`) block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) - block, _ = sjson.SetBytes(block, "signature", signature) + if signature != "" { + block, _ = sjson.SetBytes(block, "signature", signature) + } out, _ = sjson.SetRawBytes(out, "content.-1", block) } case "message": diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index 5a25057c..f436711e 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -40,8 +40,8 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing case "content_block_start": if data.Get("content_block.type").String() == "thinking" { startFound = true - if !data.Get("content_block.signature").Exists() { - t.Fatalf("thinking start block missing signature field: %s", line) + if data.Get("content_block.signature").Exists() { + t.Fatalf("thinking start block should NOT have signature field when signature is unknown: %s", line) } } case "content_block_delta": @@ -97,8 +97,8 @@ func TestConvertCodexResponseToClaude_StreamThinkingWithoutReasoningItemStillInc data := gjson.Parse(strings.TrimPrefix(line, "data: ")) if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" { thinkingStartFound = true - if !data.Get("content_block.signature").Exists() { - t.Fatalf("thinking start block missing signature field: %s", line) + if data.Get("content_block.signature").Exists() { + t.Fatalf("thinking start block should NOT have signature field without encrypted_content: %s", line) } } if data.Get("type").String() == "content_block_stop" && data.Get("index").Int() == 0 { From 66eb12294a5f96269a1d478149d5fc0ba1e44234 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Wed, 25 Mar 2026 07:52:32 +0100 Subject: [PATCH 5/6] fix: clear stale thinking signature when no block is open --- internal/translator/codex/claude/codex_claude_response.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 4557606f..4db4c9fc 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -389,6 +389,7 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte { func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if !params.ThinkingBlockOpen { + params.ThinkingSignature = "" return nil } From 5fc2bd393eb9a360476d9db1762f2b69441bb7e4 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Sat, 28 Mar 2026 14:41:25 +0100 Subject: [PATCH 6/6] fix: retain codex thinking signature until item done --- .../codex/claude/codex_claude_response.go | 7 +- .../claude/codex_claude_response_test.go | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 4db4c9fc..708194e6 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -179,8 +179,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) } else if itemType == "reasoning" { - params.ThinkingSignature = itemResult.Get("encrypted_content").String() + if signature := itemResult.Get("encrypted_content").String(); signature != "" { + params.ThinkingSignature = signature + } output = append(output, finalizeCodexThinkingBlock(params)...) + params.ThinkingSignature = "" } } else if typeStr == "response.function_call_arguments.delta" { params.HasReceivedArgumentsDelta = true @@ -389,7 +392,6 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte { func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if !params.ThinkingBlockOpen { - params.ThinkingSignature = "" return nil } @@ -408,7 +410,6 @@ func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []by params.BlockIndex++ params.ThinkingBlockOpen = false params.ThinkingStopPending = false - params.ThinkingSignature = "" return output } diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index f436711e..a8d4d189 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -163,6 +163,86 @@ func TestConvertCodexResponseToClaude_StreamThinkingFinalizesPendingBlockBeforeN } } +func TestConvertCodexResponseToClaude_StreamThinkingRetainsSignatureAcrossMultipartReasoning(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_multipart\"}}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"First part\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Second part\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\"}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + signatureDeltaCount := 0 + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "signature_delta" { + signatureDeltaCount++ + if got := data.Get("delta.signature").String(); got != "enc_sig_multipart" { + t.Fatalf("unexpected signature delta: %q", got) + } + } + } + } + + if signatureDeltaCount != 2 { + t.Fatalf("expected signature_delta for both multipart thinking blocks, got %d", signatureDeltaCount) + } +} + +func TestConvertCodexResponseToClaude_StreamThinkingUsesEarlyCapturedSignatureWhenDoneOmitsIt(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_early\"}}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me think\"}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\"}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + signatureDeltaCount := 0 + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "signature_delta" { + signatureDeltaCount++ + if got := data.Get("delta.signature").String(); got != "enc_sig_early" { + t.Fatalf("unexpected signature delta: %q", got) + } + } + } + } + + if signatureDeltaCount != 1 { + t.Fatalf("expected signature_delta from early-captured signature, got %d", signatureDeltaCount) + } +} + func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"messages":[]}`)