Skip to content

Commit 34027da

Browse files
authored
Merge pull request router-for-me#3088 from sususu98/codex-claude-reasoning-signature
Preserve Codex reasoning signatures for Claude
2 parents d85e13b + c523101 commit 34027da

4 files changed

Lines changed: 332 additions & 74 deletions

File tree

internal/translator/codex/claude/codex_claude_request.go

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package claude
77

88
import (
9+
"encoding/base64"
910
"fmt"
1011
"strconv"
1112
"strings"
@@ -125,21 +126,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
125126
return
126127
}
127128

128-
thinkingText := thinking.GetThinkingText(part)
129129
signature := part.Get("signature").String()
130-
if strings.TrimSpace(thinkingText) == "" && signature == "" {
130+
if !isFernetLikeReasoningSignature(signature) {
131131
return
132132
}
133133

134+
flushMessage()
134135
reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`)
135-
if signature != "" {
136-
reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
137-
}
138-
if strings.TrimSpace(thinkingText) != "" {
139-
summary := []byte(`{"type":"summary_text","text":""}`)
140-
summary, _ = sjson.SetBytes(summary, "text", thinkingText)
141-
reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary.-1", summary)
142-
}
136+
reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
143137
template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem)
144138
}
145139

@@ -154,7 +148,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
154148
case "text":
155149
appendTextContent(messageContentResult.Get("text").String())
156150
case "thinking":
157-
flushMessage()
158151
appendReasoningContent(messageContentResult)
159152
case "image":
160153
sourceResult := messageContentResult.Get("source")
@@ -344,6 +337,39 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
344337
return template
345338
}
346339

340+
// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape
341+
// observed in OpenAI reasoning signatures. It does not authenticate source or payload type.
342+
func isFernetLikeReasoningSignature(signature string) bool {
343+
const (
344+
fernetVersionLen = 1
345+
fernetTimestamp = 8
346+
fernetIV = 16
347+
fernetHMAC = 32
348+
aesBlockSize = 16
349+
)
350+
351+
signature = strings.TrimSpace(signature)
352+
if !strings.HasPrefix(signature, "gAAAA") {
353+
return false
354+
}
355+
356+
decoded, err := base64.URLEncoding.DecodeString(signature)
357+
if err != nil {
358+
decoded, err = base64.RawURLEncoding.DecodeString(signature)
359+
if err != nil {
360+
return false
361+
}
362+
}
363+
364+
minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC
365+
if len(decoded) < minLen || decoded[0] != 0x80 {
366+
return false
367+
}
368+
369+
ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC
370+
return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0
371+
}
372+
347373
// shortenNameIfNeeded applies a simple shortening rule for a single name.
348374
func shortenNameIfNeeded(name string) string {
349375
const limit = 64

internal/translator/codex/claude/codex_claude_request_test.go

Lines changed: 121 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package claude
22

33
import (
4+
"encoding/base64"
5+
"strings"
46
"testing"
57

68
"github.com/tidwall/gjson"
@@ -134,74 +136,143 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
134136
}
135137
}
136138

137-
func TestConvertClaudeRequestToCodex_ThinkingSignatureToEncryptedContent(t *testing.T) {
138-
result := ConvertClaudeRequestToCodex("test-model", []byte(`{
139+
func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
140+
signature := validCodexReasoningSignature()
141+
inputJSON := `{
139142
"model": "claude-3-opus",
140-
"messages": [{
141-
"role": "assistant",
142-
"content": [
143-
{"type": "thinking", "thinking": "Internal reasoning.", "signature": "sig_123"},
144-
{"type": "text", "text": "Visible answer."}
145-
]
146-
}]
147-
}`), false)
143+
"messages": [
144+
{
145+
"role": "assistant",
146+
"content": [
147+
{
148+
"type": "thinking",
149+
"thinking": "visible summary must not be replayed",
150+
"signature": "` + signature + `"
151+
},
152+
{
153+
"type": "text",
154+
"text": "visible answer"
155+
}
156+
]
157+
},
158+
{
159+
"role": "user",
160+
"content": "continue"
161+
}
162+
]
163+
}`
164+
165+
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
148166
resultJSON := gjson.ParseBytes(result)
149167
inputs := resultJSON.Get("input").Array()
150-
151-
if len(inputs) != 2 {
152-
t.Fatalf("got %d input items, want 2. Output: %s", len(inputs), string(result))
168+
if len(inputs) != 3 {
169+
t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
153170
}
154171

155172
reasoning := inputs[0]
156173
if got := reasoning.Get("type").String(); got != "reasoning" {
157-
t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result))
174+
t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result))
158175
}
159-
if got := reasoning.Get("encrypted_content").String(); got != "sig_123" {
160-
t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_123", string(result))
176+
if got := reasoning.Get("encrypted_content").String(); got != signature {
177+
t.Fatalf("encrypted_content = %q, want %q", got, signature)
161178
}
162-
if got := reasoning.Get("summary.0.type").String(); got != "summary_text" {
163-
t.Fatalf("summary.0.type = %q, want %q. Output: %s", got, "summary_text", string(result))
179+
if got := reasoning.Get("summary").Raw; got != "[]" {
180+
t.Fatalf("summary = %s, want []", got)
164181
}
165-
if got := reasoning.Get("summary.0.text").String(); got != "Internal reasoning." {
166-
t.Fatalf("summary.0.text = %q, want %q. Output: %s", got, "Internal reasoning.", string(result))
182+
if got := reasoning.Get("content").Raw; got != "null" {
183+
t.Fatalf("content = %s, want null", got)
167184
}
168185

169-
message := inputs[1]
170-
if got := message.Get("type").String(); got != "message" {
171-
t.Fatalf("input[1].type = %q, want %q. Output: %s", got, "message", string(result))
186+
assistantMessage := inputs[1]
187+
if got := assistantMessage.Get("role").String(); got != "assistant" {
188+
t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result))
172189
}
173-
if got := message.Get("role").String(); got != "assistant" {
174-
t.Fatalf("input[1].role = %q, want %q. Output: %s", got, "assistant", string(result))
190+
if got := assistantMessage.Get("content.0.type").String(); got != "output_text" {
191+
t.Fatalf("assistant content type = %q, want output_text", got)
175192
}
176-
if got := message.Get("content.0.type").String(); got != "output_text" {
177-
t.Fatalf("content.0.type = %q, want %q. Output: %s", got, "output_text", string(result))
193+
if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" {
194+
t.Fatalf("assistant text = %q, want visible answer", got)
178195
}
179-
if got := message.Get("content.0.text").String(); got != "Visible answer." {
180-
t.Fatalf("content.0.text = %q, want %q. Output: %s", got, "Visible answer.", string(result))
196+
if strings.Contains(string(result), "visible summary must not be replayed") {
197+
t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result))
181198
}
182199
}
183200

184-
func TestConvertClaudeRequestToCodex_ThinkingSignatureWithoutText(t *testing.T) {
185-
result := ConvertClaudeRequestToCodex("test-model", []byte(`{
186-
"model": "claude-3-opus",
187-
"messages": [{
188-
"role": "assistant",
189-
"content": [{"type": "thinking", "thinking": "", "signature": "sig_empty_text"}]
190-
}]
191-
}`), false)
192-
resultJSON := gjson.ParseBytes(result)
193-
inputs := resultJSON.Get("input").Array()
194-
195-
if len(inputs) != 1 {
196-
t.Fatalf("got %d input items, want 1. Output: %s", len(inputs), string(result))
197-
}
198-
if got := inputs[0].Get("type").String(); got != "reasoning" {
199-
t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result))
200-
}
201-
if got := inputs[0].Get("encrypted_content").String(); got != "sig_empty_text" {
202-
t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_empty_text", string(result))
201+
func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) {
202+
tests := []struct {
203+
name string
204+
inputJSON string
205+
}{
206+
{
207+
name: "Ignore user thinking even with Codex-shaped signature",
208+
inputJSON: `{
209+
"model": "claude-3-opus",
210+
"messages": [
211+
{
212+
"role": "user",
213+
"content": [
214+
{
215+
"type": "thinking",
216+
"thinking": "user supplied thinking",
217+
"signature": "` + validCodexReasoningSignature() + `"
218+
},
219+
{
220+
"type": "text",
221+
"text": "hello"
222+
}
223+
]
224+
}
225+
]
226+
}`,
227+
},
228+
{
229+
name: "Ignore Anthropic native signature",
230+
inputJSON: `{
231+
"model": "claude-3-opus",
232+
"messages": [
233+
{
234+
"role": "assistant",
235+
"content": [
236+
{
237+
"type": "thinking",
238+
"thinking": "anthropic thinking",
239+
"signature": "Eo8Canthropic-state"
240+
},
241+
{
242+
"type": "text",
243+
"text": "visible answer"
244+
}
245+
]
246+
}
247+
]
248+
}`,
249+
},
203250
}
204-
if got := len(inputs[0].Get("summary").Array()); got != 0 {
205-
t.Fatalf("summary length = %d, want 0. Output: %s", got, string(result))
251+
252+
for _, tt := range tests {
253+
t.Run(tt.name, func(t *testing.T) {
254+
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
255+
if got := countRequestInputItemsByType(result, "reasoning"); got != 0 {
256+
t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result))
257+
}
258+
})
206259
}
207260
}
261+
262+
func countRequestInputItemsByType(result []byte, itemType string) int {
263+
count := 0
264+
gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool {
265+
if item.Get("type").String() == itemType {
266+
count++
267+
}
268+
return true
269+
})
270+
return count
271+
}
272+
273+
func validCodexReasoningSignature() string {
274+
raw := make([]byte, 1+8+16+16+32)
275+
raw[0] = 0x80
276+
raw[8] = 1
277+
return base64.URLEncoding.EncodeToString(raw)
278+
}

internal/translator/codex/claude/codex_claude_response.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type ConvertCodexResponseToClaudeParams struct {
3131
ThinkingBlockOpen bool
3232
ThinkingStopPending bool
3333
ThinkingSignature string
34+
ThinkingSummarySeen bool
3435
}
3536

3637
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
@@ -86,12 +87,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
8687
if params.ThinkingBlockOpen && params.ThinkingStopPending {
8788
output = append(output, finalizeCodexThinkingBlock(params)...)
8889
}
89-
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
90-
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
91-
params.ThinkingBlockOpen = true
92-
params.ThinkingStopPending = false
93-
94-
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
90+
params.ThinkingSummarySeen = true
91+
output = append(output, startCodexThinkingBlock(params)...)
9592
} else if typeStr == "response.reasoning_summary_text.delta" {
9693
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
9794
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
@@ -100,9 +97,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
10097
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
10198
} else if typeStr == "response.reasoning_summary_part.done" {
10299
params.ThinkingStopPending = true
103-
if params.ThinkingSignature != "" {
104-
output = append(output, finalizeCodexThinkingBlock(params)...)
105-
}
106100
} else if typeStr == "response.content_part.added" {
107101
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
108102
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
@@ -169,10 +163,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
169163

170164
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
171165
} else if itemType == "reasoning" {
166+
params.ThinkingSummarySeen = false
172167
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
173-
if params.ThinkingStopPending {
174-
output = append(output, finalizeCodexThinkingBlock(params)...)
175-
}
176168
}
177169
} else if typeStr == "response.output_item.done" {
178170
itemResult := rootResult.Get("item")
@@ -229,8 +221,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
229221
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
230222
params.ThinkingSignature = signature
231223
}
232-
output = append(output, finalizeCodexThinkingBlock(params)...)
224+
if params.ThinkingSummarySeen {
225+
output = append(output, finalizeCodexThinkingBlock(params)...)
226+
} else {
227+
output = append(output, finalizeCodexSignatureOnlyThinkingBlock(params)...)
228+
}
233229
params.ThinkingSignature = ""
230+
params.ThinkingSummarySeen = false
234231
}
235232
} else if typeStr == "response.function_call_arguments.delta" {
236233
params.HasReceivedArgumentsDelta = true
@@ -437,6 +434,29 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte {
437434
return translatorcommon.ClaudeInputTokensJSON(count)
438435
}
439436

437+
func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
438+
if params.ThinkingBlockOpen {
439+
return nil
440+
}
441+
442+
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
443+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
444+
params.ThinkingBlockOpen = true
445+
params.ThinkingStopPending = false
446+
447+
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
448+
}
449+
450+
func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
451+
if params.ThinkingSignature == "" {
452+
return nil
453+
}
454+
455+
output := startCodexThinkingBlock(params)
456+
output = append(output, finalizeCodexThinkingBlock(params)...)
457+
return output
458+
}
459+
440460
func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
441461
if !params.ThinkingBlockOpen {
442462
return nil

0 commit comments

Comments
 (0)