mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-10 00:08:44 +00:00
fix: preserve Claude thinking signatures in Codex translator
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
160
internal/translator/codex/claude/codex_claude_response_test.go
Normal file
160
internal/translator/codex/claude/codex_claude_response_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user