diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index afc2900e7..239c3e4d1 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -6,6 +6,7 @@ package claude import ( + "encoding/base64" "fmt" "strconv" "strings" @@ -125,21 +126,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return } - thinkingText := thinking.GetThinkingText(part) signature := part.Get("signature").String() - if strings.TrimSpace(thinkingText) == "" && signature == "" { + if !isFernetLikeReasoningSignature(signature) { return } + flushMessage() reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`) - if signature != "" { - reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) - } - if strings.TrimSpace(thinkingText) != "" { - summary := []byte(`{"type":"summary_text","text":""}`) - summary, _ = sjson.SetBytes(summary, "text", thinkingText) - reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary.-1", summary) - } + reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem) } @@ -154,7 +148,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) case "text": appendTextContent(messageContentResult.Get("text").String()) case "thinking": - flushMessage() appendReasoningContent(messageContentResult) case "image": sourceResult := messageContentResult.Get("source") @@ -344,6 +337,39 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return template } +// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape +// observed in OpenAI reasoning signatures. It does not authenticate source or payload type. +func isFernetLikeReasoningSignature(signature string) bool { + const ( + fernetVersionLen = 1 + fernetTimestamp = 8 + fernetIV = 16 + fernetHMAC = 32 + aesBlockSize = 16 + ) + + signature = strings.TrimSpace(signature) + if !strings.HasPrefix(signature, "gAAAA") { + return false + } + + decoded, err := base64.URLEncoding.DecodeString(signature) + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return false + } + } + + minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC + if len(decoded) < minLen || decoded[0] != 0x80 { + return false + } + + ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC + return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 +} + // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { const limit = 64 diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 21df206e1..85d10267f 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -1,6 +1,8 @@ package claude import ( + "encoding/base64" + "strings" "testing" "github.com/tidwall/gjson" @@ -134,74 +136,143 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { } } -func TestConvertClaudeRequestToCodex_ThinkingSignatureToEncryptedContent(t *testing.T) { - result := ConvertClaudeRequestToCodex("test-model", []byte(`{ +func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) { + signature := validCodexReasoningSignature() + inputJSON := `{ "model": "claude-3-opus", - "messages": [{ - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "Internal reasoning.", "signature": "sig_123"}, - {"type": "text", "text": "Visible answer."} - ] - }] - }`), false) + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "visible summary must not be replayed", + "signature": "` + signature + `" + }, + { + "type": "text", + "text": "visible answer" + } + ] + }, + { + "role": "user", + "content": "continue" + } + ] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) resultJSON := gjson.ParseBytes(result) inputs := resultJSON.Get("input").Array() - - if len(inputs) != 2 { - t.Fatalf("got %d input items, want 2. Output: %s", len(inputs), string(result)) + if len(inputs) != 3 { + t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result)) } reasoning := inputs[0] if got := reasoning.Get("type").String(); got != "reasoning" { - t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) + t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result)) } - if got := reasoning.Get("encrypted_content").String(); got != "sig_123" { - t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_123", string(result)) + if got := reasoning.Get("encrypted_content").String(); got != signature { + t.Fatalf("encrypted_content = %q, want %q", got, signature) } - if got := reasoning.Get("summary.0.type").String(); got != "summary_text" { - t.Fatalf("summary.0.type = %q, want %q. Output: %s", got, "summary_text", string(result)) + if got := reasoning.Get("summary").Raw; got != "[]" { + t.Fatalf("summary = %s, want []", got) } - if got := reasoning.Get("summary.0.text").String(); got != "Internal reasoning." { - t.Fatalf("summary.0.text = %q, want %q. Output: %s", got, "Internal reasoning.", string(result)) + if got := reasoning.Get("content").Raw; got != "null" { + t.Fatalf("content = %s, want null", got) } - message := inputs[1] - if got := message.Get("type").String(); got != "message" { - t.Fatalf("input[1].type = %q, want %q. Output: %s", got, "message", string(result)) + assistantMessage := inputs[1] + if got := assistantMessage.Get("role").String(); got != "assistant" { + t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result)) } - if got := message.Get("role").String(); got != "assistant" { - t.Fatalf("input[1].role = %q, want %q. Output: %s", got, "assistant", string(result)) + if got := assistantMessage.Get("content.0.type").String(); got != "output_text" { + t.Fatalf("assistant content type = %q, want output_text", got) } - if got := message.Get("content.0.type").String(); got != "output_text" { - t.Fatalf("content.0.type = %q, want %q. Output: %s", got, "output_text", string(result)) + if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" { + t.Fatalf("assistant text = %q, want visible answer", got) } - if got := message.Get("content.0.text").String(); got != "Visible answer." { - t.Fatalf("content.0.text = %q, want %q. Output: %s", got, "Visible answer.", string(result)) + if strings.Contains(string(result), "visible summary must not be replayed") { + t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result)) } } -func TestConvertClaudeRequestToCodex_ThinkingSignatureWithoutText(t *testing.T) { - result := ConvertClaudeRequestToCodex("test-model", []byte(`{ - "model": "claude-3-opus", - "messages": [{ - "role": "assistant", - "content": [{"type": "thinking", "thinking": "", "signature": "sig_empty_text"}] - }] - }`), false) - resultJSON := gjson.ParseBytes(result) - inputs := resultJSON.Get("input").Array() +func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) { + tests := []struct { + name string + inputJSON string + }{ + { + name: "Ignore user thinking even with Codex-shaped signature", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "thinking", + "thinking": "user supplied thinking", + "signature": "` + validCodexReasoningSignature() + `" + }, + { + "type": "text", + "text": "hello" + } + ] + } + ] + }`, + }, + { + name: "Ignore Anthropic native signature", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "anthropic thinking", + "signature": "Eo8Canthropic-state" + }, + { + "type": "text", + "text": "visible answer" + } + ] + } + ] + }`, + }, + } - if len(inputs) != 1 { - t.Fatalf("got %d input items, want 1. Output: %s", len(inputs), string(result)) - } - if got := inputs[0].Get("type").String(); got != "reasoning" { - t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) - } - if got := inputs[0].Get("encrypted_content").String(); got != "sig_empty_text" { - t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_empty_text", string(result)) - } - if got := len(inputs[0].Get("summary").Array()); got != 0 { - t.Fatalf("summary length = %d, want 0. Output: %s", got, string(result)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false) + if got := countRequestInputItemsByType(result, "reasoning"); got != 0 { + t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result)) + } + }) } } + +func countRequestInputItemsByType(result []byte, itemType string) int { + count := 0 + gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == itemType { + count++ + } + return true + }) + return count +} + +func validCodexReasoningSignature() string { + raw := make([]byte, 1+8+16+16+32) + raw[0] = 0x80 + raw[8] = 1 + return base64.URLEncoding.EncodeToString(raw) +} diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 388b907ae..e48a56f8b 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -31,6 +31,7 @@ type ConvertCodexResponseToClaudeParams struct { ThinkingBlockOpen bool ThinkingStopPending bool ThinkingSignature string + ThinkingSummarySeen bool } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -86,12 +87,8 @@ 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":""}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - params.ThinkingBlockOpen = true - params.ThinkingStopPending = false - - output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) + params.ThinkingSummarySeen = true + output = append(output, startCodexThinkingBlock(params)...) } 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", params.BlockIndex) @@ -100,9 +97,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if typeStr == "response.reasoning_summary_part.done" { 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", params.BlockIndex) @@ -169,10 +163,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if itemType == "reasoning" { + params.ThinkingSummarySeen = false 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") @@ -229,8 +221,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if signature := itemResult.Get("encrypted_content").String(); signature != "" { params.ThinkingSignature = signature } - output = append(output, finalizeCodexThinkingBlock(params)...) + if params.ThinkingSummarySeen { + output = append(output, finalizeCodexThinkingBlock(params)...) + } else { + output = append(output, finalizeCodexSignatureOnlyThinkingBlock(params)...) + } params.ThinkingSignature = "" + params.ThinkingSummarySeen = false } } else if typeStr == "response.function_call_arguments.delta" { params.HasReceivedArgumentsDelta = true @@ -437,6 +434,29 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte { return translatorcommon.ClaudeInputTokensJSON(count) } +func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if params.ThinkingBlockOpen { + return nil + } + + 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 + + return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2) +} + +func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if params.ThinkingSignature == "" { + return nil + } + + output := startCodexThinkingBlock(params) + output = append(output, finalizeCodexThinkingBlock(params)...) + return output +} + func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if !params.ThinkingBlockOpen { return nil diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index c36c9edb6..bbd71da08 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -243,6 +243,147 @@ func TestConvertCodexResponseToClaude_StreamThinkingUsesEarlyCapturedSignatureWh } } +func TestConvertCodexResponseToClaude_StreamThinkingUsesFinalDoneSignature(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_initial\"}}"), + []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_final\"}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + signatureDeltaCount := 0 + events := []string{} + 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" { + events = append(events, "thinking_start") + } + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "thinking_delta" { + events = append(events, "thinking_delta") + } + if data.Get("type").String() == "content_block_stop" && data.Get("index").Int() == 0 { + events = append(events, "thinking_stop") + } + if data.Get("type").String() != "content_block_delta" || data.Get("delta.type").String() != "signature_delta" { + continue + } + events = append(events, "signature_delta") + signatureDeltaCount++ + if got := data.Get("delta.signature").String(); got != "enc_sig_final" { + t.Fatalf("signature delta = %q, want final done signature", got) + } + } + } + + if signatureDeltaCount != 1 { + t.Fatalf("expected one signature_delta, got %d", signatureDeltaCount) + } + if got, want := strings.Join(events, ","), "thinking_start,thinking_delta,signature_delta,thinking_stop"; got != want { + t.Fatalf("thinking event order = %s, want %s", got, want) + } +} + +func TestConvertCodexResponseToClaude_StreamSignatureOnlyReasoningEmitsThinkingSignature(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.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_initial\"}}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_only\"}}"), + []byte("data: {\"type\":\"response.content_part.added\"}"), + []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + thinkingStartFound := false + thinkingDeltaFound := false + signatureDeltaFound := false + thinkingStopFound := false + textStartIndex := int64(-1) + events := []string{} + + 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" { + events = append(events, "thinking_start") + thinkingStartFound = true + if got := data.Get("index").Int(); got != 0 { + t.Fatalf("thinking block index = %d, want 0", got) + } + } + if data.Get("content_block.type").String() == "text" { + events = append(events, "text_start") + textStartIndex = data.Get("index").Int() + } + case "content_block_delta": + switch data.Get("delta.type").String() { + case "thinking_delta": + thinkingDeltaFound = true + case "signature_delta": + events = append(events, "signature_delta") + signatureDeltaFound = true + if got := data.Get("index").Int(); got != 0 { + t.Fatalf("signature delta index = %d, want 0", got) + } + if got := data.Get("delta.signature").String(); got != "enc_sig_only" { + t.Fatalf("unexpected signature delta: %q", got) + } + } + case "content_block_stop": + if data.Get("index").Int() == 0 { + events = append(events, "thinking_stop") + thinkingStopFound = true + } + } + } + } + + if !thinkingStartFound { + t.Fatal("expected signature-only reasoning to start a thinking block") + } + if thinkingDeltaFound { + t.Fatal("did not expect thinking_delta when upstream omitted summary text") + } + if !signatureDeltaFound { + t.Fatal("expected signature_delta from encrypted_content-only reasoning") + } + if !thinkingStopFound { + t.Fatal("expected signature-only thinking block to stop") + } + if textStartIndex != 1 { + t.Fatalf("text block index = %d, want 1 after signature-only thinking block", textStartIndex) + } + if got, want := strings.Join(events, ","), "thinking_start,signature_delta,thinking_stop,text_start"; got != want { + t.Fatalf("signature-only event order = %s, want %s", got, want) + } +} + func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"messages":[]}`)