Skip to content

Commit 4b68103

Browse files
committed
feat(translator): add reasoning signature handling and tests for Claude-OpenAI conversions
- Introduced support for processing `encrypted_content` reasoning signatures in request and response translations. - Updated `ConvertOpenAIResponsesRequestToClaude` and `ConvertClaudeResponseToOpenAIResponses` to handle reasoning signatures and summaries. - Added tests to validate signature preservation and correct reasoning content transformation in both streaming and non-streaming scenarios. - Refactored processing logic to ensure reasoning content flushing before user messages.
1 parent 2cbb8c7 commit 4b68103

4 files changed

Lines changed: 315 additions & 8 deletions

File tree

internal/translator/claude/openai/responses/claude_openai-responses_request.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
168168
}
169169

170170
// input array processing
171+
var pendingReasoningParts []string
172+
flushPendingReasoning := func() {
173+
if len(pendingReasoningParts) == 0 {
174+
return
175+
}
176+
asst := []byte(`{"role":"assistant","content":[]}`)
177+
for _, partJSON := range pendingReasoningParts {
178+
asst, _ = sjson.SetRawBytes(asst, "content.-1", []byte(partJSON))
179+
}
180+
out, _ = sjson.SetRawBytes(out, "messages.-1", asst)
181+
pendingReasoningParts = nil
182+
}
183+
171184
if input := root.Get("input"); input.Exists() && input.IsArray() {
172185
input.ForEach(func(_, item gjson.Result) bool {
173186
if extractedFromSystem && strings.EqualFold(item.Get("role").String(), "system") {
@@ -279,10 +292,26 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
279292
}
280293
}
281294

295+
hasReasoningParts := false
296+
if len(pendingReasoningParts) > 0 {
297+
if role == "assistant" {
298+
if len(partsJSON) == 0 && textAggregate.Len() > 0 {
299+
contentPart := []byte(`{"type":"text","text":""}`)
300+
contentPart, _ = sjson.SetBytes(contentPart, "text", textAggregate.String())
301+
partsJSON = append(partsJSON, string(contentPart))
302+
}
303+
partsJSON = append(append([]string{}, pendingReasoningParts...), partsJSON...)
304+
pendingReasoningParts = nil
305+
hasReasoningParts = true
306+
} else {
307+
flushPendingReasoning()
308+
}
309+
}
310+
282311
if len(partsJSON) > 0 {
283312
msg := []byte(`{"role":"","content":[]}`)
284313
msg, _ = sjson.SetBytes(msg, "role", role)
285-
if len(partsJSON) == 1 && !hasImage && !hasFile {
314+
if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts {
286315
// Preserve legacy behavior for single text content
287316
msg, _ = sjson.DeleteBytes(msg, "content")
288317
textPart := gjson.Parse(partsJSON[0])
@@ -300,6 +329,11 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
300329
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
301330
}
302331

332+
case "reasoning":
333+
if thinkingPart := convertResponsesReasoningToClaudeThinking(item); len(thinkingPart) > 0 {
334+
pendingReasoningParts = append(pendingReasoningParts, string(thinkingPart))
335+
}
336+
303337
case "function_call":
304338
// Map to assistant tool_use
305339
callID := item.Get("call_id").String()
@@ -320,10 +354,15 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
320354
}
321355

322356
asst := []byte(`{"role":"assistant","content":[]}`)
357+
for _, partJSON := range pendingReasoningParts {
358+
asst, _ = sjson.SetRawBytes(asst, "content.-1", []byte(partJSON))
359+
}
360+
pendingReasoningParts = nil
323361
asst, _ = sjson.SetRawBytes(asst, "content.-1", toolUse)
324362
out, _ = sjson.SetRawBytes(out, "messages.-1", asst)
325363

326364
case "function_call_output":
365+
flushPendingReasoning()
327366
// Map to user tool_result
328367
callID := item.Get("call_id").String()
329368
outputStr := item.Get("output").String()
@@ -338,6 +377,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
338377
return true
339378
})
340379
}
380+
flushPendingReasoning()
341381

342382
includedToolNames := map[string]struct{}{}
343383
toolNameMap := map[string]string{}
@@ -398,6 +438,34 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
398438
return out
399439
}
400440

441+
func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte {
442+
signature := item.Get("encrypted_content").String()
443+
if signature == "" {
444+
return nil
445+
}
446+
447+
thinkingText := responsesReasoningSummaryText(item)
448+
thinkingPart := []byte(`{"type":"thinking","thinking":"","signature":""}`)
449+
thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", thinkingText)
450+
thinkingPart, _ = sjson.SetBytes(thinkingPart, "signature", signature)
451+
return thinkingPart
452+
}
453+
454+
func responsesReasoningSummaryText(item gjson.Result) string {
455+
var builder strings.Builder
456+
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
457+
summary.ForEach(func(_, part gjson.Result) bool {
458+
if text := part.Get("text"); text.Exists() {
459+
builder.WriteString(text.String())
460+
} else if part.Type == gjson.String {
461+
builder.WriteString(part.String())
462+
}
463+
return true
464+
})
465+
}
466+
return builder.String()
467+
}
468+
401469
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
402470
toolType := strings.TrimSpace(tool.Get("type").String())
403471
switch toolType {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package responses
2+
3+
import (
4+
"testing"
5+
6+
"github.com/tidwall/gjson"
7+
)
8+
9+
func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) {
10+
signature := "claude_sig_request"
11+
raw := []byte(`{
12+
"model":"claude-test",
13+
"input":[
14+
{
15+
"type":"reasoning",
16+
"encrypted_content":"` + signature + `",
17+
"summary":[{"type":"summary_text","text":"internal reasoning"}]
18+
},
19+
{
20+
"type":"message",
21+
"role":"assistant",
22+
"content":[{"type":"output_text","text":"visible answer"}]
23+
},
24+
{
25+
"type":"message",
26+
"role":"user",
27+
"content":[{"type":"input_text","text":"continue"}]
28+
}
29+
]
30+
}`)
31+
32+
out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
33+
root := gjson.ParseBytes(out)
34+
35+
assistant := root.Get("messages.0")
36+
if got := assistant.Get("role").String(); got != "assistant" {
37+
t.Fatalf("first message role = %q, want assistant. Output: %s", got, string(out))
38+
}
39+
if got := assistant.Get("content.0.type").String(); got != "thinking" {
40+
t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
41+
}
42+
if got := assistant.Get("content.0.signature").String(); got != signature {
43+
t.Fatalf("thinking signature = %q, want %q", got, signature)
44+
}
45+
if got := assistant.Get("content.0.thinking").String(); got != "internal reasoning" {
46+
t.Fatalf("thinking text = %q, want internal reasoning", got)
47+
}
48+
if got := assistant.Get("content.1.type").String(); got != "text" {
49+
t.Fatalf("second content type = %q, want text. Output: %s", got, string(out))
50+
}
51+
if got := assistant.Get("content.1.text").String(); got != "visible answer" {
52+
t.Fatalf("assistant text = %q, want visible answer", got)
53+
}
54+
if got := root.Get("messages.1.role").String(); got != "user" {
55+
t.Fatalf("second message role = %q, want user. Output: %s", got, string(out))
56+
}
57+
}
58+
59+
func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBeforeUser(t *testing.T) {
60+
signature := "claude_sig_only"
61+
raw := []byte(`{
62+
"model":"claude-test",
63+
"input":[
64+
{
65+
"type":"reasoning",
66+
"encrypted_content":"` + signature + `",
67+
"summary":[]
68+
},
69+
{
70+
"type":"message",
71+
"role":"user",
72+
"content":[{"type":"input_text","text":"continue"}]
73+
}
74+
]
75+
}`)
76+
77+
out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
78+
root := gjson.ParseBytes(out)
79+
80+
thinking := root.Get("messages.0.content.0")
81+
if got := thinking.Get("type").String(); got != "thinking" {
82+
t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
83+
}
84+
if got := thinking.Get("signature").String(); got != signature {
85+
t.Fatalf("thinking signature = %q, want %q", got, signature)
86+
}
87+
if got := thinking.Get("thinking").String(); got != "" {
88+
t.Fatalf("thinking text = %q, want empty", got)
89+
}
90+
if got := root.Get("messages.1.role").String(); got != "user" {
91+
t.Fatalf("second message role = %q, want user. Output: %s", got, string(out))
92+
}
93+
}

internal/translator/claude/openai/responses/claude_openai-responses_response.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type claudeToResponsesState struct {
3232
ReasoningActive bool
3333
ReasoningItemID string
3434
ReasoningBuf strings.Builder
35+
ReasoningSignature string
3536
ReasoningPartAdded bool
3637
ReasoningIndex int
3738
// usage aggregation
@@ -89,6 +90,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
8990
st.CurrentMsgID = ""
9091
st.CurrentFCID = ""
9192
st.ReasoningItemID = ""
93+
st.ReasoningSignature = ""
9294
st.ReasoningIndex = 0
9395
st.ReasoningPartAdded = false
9496
st.FuncArgsBuf = make(map[int]*strings.Builder)
@@ -163,11 +165,16 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
163165
st.ReasoningActive = true
164166
st.ReasoningIndex = idx
165167
st.ReasoningBuf.Reset()
168+
st.ReasoningSignature = ""
169+
if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" {
170+
st.ReasoningSignature = signature.String()
171+
}
166172
st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
167-
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
173+
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
168174
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
169175
item, _ = sjson.SetBytes(item, "output_index", idx)
170176
item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID)
177+
item, _ = sjson.SetBytes(item, "item.encrypted_content", st.ReasoningSignature)
171178
out = append(out, emitEvent("response.output_item.added", item))
172179
// add a summary part placeholder
173180
part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
@@ -220,6 +227,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
220227
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
221228
}
222229
}
230+
} else if dt == "signature_delta" {
231+
if st.ReasoningActive {
232+
if signature := d.Get("signature"); signature.Exists() && signature.String() != "" {
233+
st.ReasoningSignature = signature.String()
234+
}
235+
}
223236
}
224237
case "content_block_stop":
225238
idx := int(root.Get("index").Int())
@@ -277,6 +290,17 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
277290
partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
278291
partDone, _ = sjson.SetBytes(partDone, "part.text", full)
279292
out = append(out, emitEvent("response.reasoning_summary_part.done", partDone))
293+
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`)
294+
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
295+
itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID)
296+
itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex)
297+
itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature)
298+
if full != "" {
299+
summary := []byte(`{"type":"summary_text","text":""}`)
300+
summary, _ = sjson.SetBytes(summary, "text", full)
301+
itemDone, _ = sjson.SetRawBytes(itemDone, "item.summary.-1", summary)
302+
}
303+
out = append(out, emitEvent("response.output_item.done", itemDone))
280304
st.ReasoningActive = false
281305
st.ReasoningPartAdded = false
282306
}
@@ -367,10 +391,15 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
367391
// Build response.output from aggregated state
368392
outputsWrapper := []byte(`{"arr":[]}`)
369393
// reasoning item (if any)
370-
if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded {
371-
item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
394+
if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded || st.ReasoningSignature != "" {
395+
item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
372396
item, _ = sjson.SetBytes(item, "id", st.ReasoningItemID)
373-
item, _ = sjson.SetBytes(item, "summary.0.text", st.ReasoningBuf.String())
397+
item, _ = sjson.SetBytes(item, "encrypted_content", st.ReasoningSignature)
398+
if st.ReasoningBuf.Len() > 0 {
399+
summary := []byte(`{"type":"summary_text","text":""}`)
400+
summary, _ = sjson.SetBytes(summary, "text", st.ReasoningBuf.String())
401+
item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
402+
}
374403
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
375404
}
376405
// assistant message item (if any text)
@@ -476,6 +505,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
476505
reasoningBuf strings.Builder
477506
reasoningActive bool
478507
reasoningItemID string
508+
reasoningSig string
479509
inputTokens int64
480510
outputTokens int64
481511
)
@@ -525,6 +555,10 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
525555
case "thinking":
526556
reasoningActive = true
527557
reasoningItemID = fmt.Sprintf("rs_%s_%d", responseID, idx)
558+
reasoningSig = ""
559+
if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" {
560+
reasoningSig = signature.String()
561+
}
528562
}
529563

530564
case "content_block_delta":
@@ -552,6 +586,12 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
552586
reasoningBuf.WriteString(t.String())
553587
}
554588
}
589+
case "signature_delta":
590+
if reasoningActive {
591+
if signature := d.Get("signature"); signature.Exists() && signature.String() != "" {
592+
reasoningSig = signature.String()
593+
}
594+
}
555595
}
556596

557597
case "content_block_stop":
@@ -637,10 +677,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
637677

638678
// Build output array
639679
outputsWrapper := []byte(`{"arr":[]}`)
640-
if reasoningBuf.Len() > 0 {
641-
item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
680+
if reasoningBuf.Len() > 0 || reasoningSig != "" {
681+
item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
642682
item, _ = sjson.SetBytes(item, "id", reasoningItemID)
643-
item, _ = sjson.SetBytes(item, "summary.0.text", reasoningBuf.String())
683+
item, _ = sjson.SetBytes(item, "encrypted_content", reasoningSig)
684+
if reasoningBuf.Len() > 0 {
685+
summary := []byte(`{"type":"summary_text","text":""}`)
686+
summary, _ = sjson.SetBytes(summary, "text", reasoningBuf.String())
687+
item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
688+
}
644689
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
645690
}
646691
if currentMsgID != "" || textBuf.Len() > 0 {

0 commit comments

Comments
 (0)