Skip to content

Commit a2b97ff

Browse files
committed
Merge upstream/main: codex reasoning signatures, X-Amp-Thread-Id session affinity, usage helpers
2 parents 5422cb4 + 34027da commit a2b97ff

9 files changed

Lines changed: 479 additions & 22 deletions

File tree

internal/runtime/executor/helps/usage_helpers.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,26 @@ func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) {
4949
}
5050

5151
func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string, detail usage.Detail) {
52-
if r == nil {
52+
record, ok := r.buildAdditionalModelRecord(model, detail)
53+
if !ok {
5354
return
5455
}
56+
usage.PublishRecord(ctx, record)
57+
}
58+
59+
func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.Detail) (usage.Record, bool) {
60+
if r == nil {
61+
return usage.Record{}, false
62+
}
5563
model = strings.TrimSpace(model)
5664
if model == "" {
57-
return
65+
return usage.Record{}, false
5866
}
5967
detail = normalizeUsageDetailTotal(detail)
60-
usage.PublishRecord(ctx, r.buildRecordForModel(model, detail, false))
68+
if !hasNonZeroTokenUsage(detail) {
69+
return usage.Record{}, false
70+
}
71+
return r.buildRecordForModel(model, detail, false), true
6172
}
6273

6374
func (r *UsageReporter) PublishFailure(ctx context.Context) {
@@ -93,6 +104,14 @@ func normalizeUsageDetailTotal(detail usage.Detail) usage.Detail {
93104
return detail
94105
}
95106

107+
func hasNonZeroTokenUsage(detail usage.Detail) bool {
108+
return detail.InputTokens != 0 ||
109+
detail.OutputTokens != 0 ||
110+
detail.ReasoningTokens != 0 ||
111+
detail.CachedTokens != 0 ||
112+
detail.TotalTokens != 0
113+
}
114+
96115
// ensurePublished guarantees that a usage record is emitted exactly once.
97116
// It is safe to call multiple times; only the first call wins due to once.Do.
98117
// This is used to ensure request counting even when upstream responses do not

internal/runtime/executor/helps/usage_helpers_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,21 @@ func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) {
6262
t.Fatalf("latency = %v, want <= 3s", record.Latency)
6363
}
6464
}
65+
66+
func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) {
67+
reporter := &UsageReporter{
68+
provider: "codex",
69+
model: "gpt-5.4",
70+
requestedAt: time.Now(),
71+
}
72+
73+
if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{}); ok {
74+
t.Fatalf("expected all-zero token usage to be skipped")
75+
}
76+
if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{InputTokens: 2}); !ok {
77+
t.Fatalf("expected non-zero input token usage to be recorded")
78+
}
79+
if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{CachedTokens: 2}); !ok {
80+
t.Fatalf("expected non-zero cached token usage to be recorded")
81+
}
82+
}

internal/translator/codex/claude/codex_claude_request.go

Lines changed: 52 additions & 0 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"
@@ -120,6 +121,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
120121
hasContent = true
121122
}
122123

124+
appendReasoningContent := func(part gjson.Result) {
125+
if messageRole != "assistant" {
126+
return
127+
}
128+
129+
signature := part.Get("signature").String()
130+
if !isFernetLikeReasoningSignature(signature) {
131+
return
132+
}
133+
134+
flushMessage()
135+
reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`)
136+
reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
137+
template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem)
138+
}
139+
123140
messageContentsResult := messageResult.Get("content")
124141
if messageContentsResult.IsArray() {
125142
messageContentResults := messageContentsResult.Array()
@@ -130,6 +147,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
130147
switch contentType {
131148
case "text":
132149
appendTextContent(messageContentResult.Get("text").String())
150+
case "thinking":
151+
appendReasoningContent(messageContentResult)
133152
case "image":
134153
sourceResult := messageContentResult.Get("source")
135154
if sourceResult.Exists() {
@@ -318,6 +337,39 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
318337
return template
319338
}
320339

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+
321373
// shortenNameIfNeeded applies a simple shortening rule for a single name.
322374
func shortenNameIfNeeded(name string) string {
323375
const limit = 64

internal/translator/codex/claude/codex_claude_request_test.go

Lines changed: 143 additions & 0 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"
@@ -133,3 +135,144 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
133135
})
134136
}
135137
}
138+
139+
func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
140+
signature := validCodexReasoningSignature()
141+
inputJSON := `{
142+
"model": "claude-3-opus",
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)
166+
resultJSON := gjson.ParseBytes(result)
167+
inputs := resultJSON.Get("input").Array()
168+
if len(inputs) != 3 {
169+
t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
170+
}
171+
172+
reasoning := inputs[0]
173+
if got := reasoning.Get("type").String(); got != "reasoning" {
174+
t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result))
175+
}
176+
if got := reasoning.Get("encrypted_content").String(); got != signature {
177+
t.Fatalf("encrypted_content = %q, want %q", got, signature)
178+
}
179+
if got := reasoning.Get("summary").Raw; got != "[]" {
180+
t.Fatalf("summary = %s, want []", got)
181+
}
182+
if got := reasoning.Get("content").Raw; got != "null" {
183+
t.Fatalf("content = %s, want null", got)
184+
}
185+
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))
189+
}
190+
if got := assistantMessage.Get("content.0.type").String(); got != "output_text" {
191+
t.Fatalf("assistant content type = %q, want output_text", got)
192+
}
193+
if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" {
194+
t.Fatalf("assistant text = %q, want visible answer", got)
195+
}
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))
198+
}
199+
}
200+
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+
},
250+
}
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+
})
259+
}
260+
}
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)