diff --git a/config.example.yaml b/config.example.yaml index 786f14559..af9bf6a06 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -908,3 +908,16 @@ nonstream-keepalive-interval: 0 # params: # JSON paths (gjson/sjson syntax) to remove from the payload # - "generationConfig.thinkingConfig.thinkingBudget" # - "generationConfig.responseJsonSchema" + +# Optional translator configuration +# translator: +# # When true, prior assistant thinking/reasoning content is carried over into +# # a labeled "Prior assistant reasoning (unverified context)" system +# # instruction when translating to a target protocol (e.g. plain OpenAI chat +# # completions) that has no canonical thought field. Default is false. +# # +# # This is a fallback for non-canonical targets: the most recent 3 blocks are +# # kept, each capped at 4000 runes, and older or truncated content is marked. +# # Canonical compatibility targets (isCompat / reasoning_content) are not +# # affected. +# carry-over-thinking-in-system: false diff --git a/internal/config/config.go b/internal/config/config.go index 0d8fb234f..109e37d92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -180,6 +180,9 @@ type Config struct { // Payload defines default and override rules for provider payload parameters. Payload PayloadConfig `yaml:"payload" json:"payload"` + // Translator controls cross-format request translation behavior. + Translator TranslatorConfig `yaml:"translator" json:"translator"` + // IncognitoBrowser opens OAuth URLs in an incognito/private browser window. IncognitoBrowser bool `yaml:"incognito-browser" json:"incognito-browser"` diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 39970e93a..e4b725872 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -366,6 +366,15 @@ type PayloadModelRule struct { NotExist []string `yaml:"not-exist" json:"not-exist"` } +// TranslatorConfig controls cross-format request translation behavior. +type TranslatorConfig struct { + // CarryOverThinkingInSystem moves prior assistant reasoning/thinking into a + // labeled system instruction when the target protocol has no canonical thought + // field (e.g. plain OpenAI chat completions). Default false preserves strict + // protocol behavior. + CarryOverThinkingInSystem bool `yaml:"carry-over-thinking-in-system" json:"carry-over-thinking-in-system"` +} + // CloakConfig configures request cloaking for non-Claude-Code clients. // Cloaking disguises API requests to appear as originating from the official Claude Code CLI. type CloakConfig struct { diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go new file mode 100644 index 000000000..2ed05dd4f --- /dev/null +++ b/internal/runtime/executor/helps/carry_over.go @@ -0,0 +1,312 @@ +package helps + +import ( + "fmt" + "strings" + + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + carryOverLabel = "Prior assistant reasoning (unverified context)" + carryOverMaxBlocks = 3 + carryOverMaxBlockSize = 4000 +) + +// CarryOverThinkingToSystem extracts reasoning_content from assistant messages +// in an OpenAI Chat Completions payload and rewrites it as a labeled system +// instruction. It drops assistant messages that are empty after reasoning is +// removed, including assistant messages that were already empty. OpenAI rejects +// empty assistant messages, so removing them is intentional. Existing first +// system message is extended; otherwise a new one is inserted. +// +// The function does not add reasoning to response bodies; it is only for +// request bodies being sent to a target without a canonical thought field. +func CarryOverThinkingToSystem(payload []byte) []byte { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload + } + + messages := gjson.GetBytes(payload, "messages") + if !messages.Exists() || !messages.IsArray() { + return payload + } + + var reasoningBlocks []string + keptMessages := make([][]byte, 0, len(messages.Array())) + + messages.ForEach(func(_, msg gjson.Result) bool { + role := msg.Get("role").String() + + var reasoning string + if role == "assistant" { + if rc := msg.Get("reasoning_content"); rc.Exists() && rc.Type == gjson.String { + reasoning = rc.String() + } + } + + if reasoning != "" { + reasoningBlocks = append(reasoningBlocks, reasoning) + } + + updated := []byte(msg.Raw) + if msg.Get("reasoning_content").Exists() { + updated, _ = sjson.DeleteBytes(updated, "reasoning_content") + } + + if role == "assistant" && !assistantMessageHasContent(updated) { + return true + } + + keptMessages = append(keptMessages, updated) + return true + }) + + if len(reasoningBlocks) == 0 { + return payload + } + + systemText := carryOverLabel + ":\n\n" + formatCarryOverText(reasoningBlocks) + + if len(keptMessages) > 0 && gjson.GetBytes(keptMessages[0], "role").String() == "system" { + keptMessages[0] = mergeCarryOverIntoSystemMessage(keptMessages[0], systemText) + } else { + systemMsg := []byte(`{"role":"system","content":""}`) + systemMsg, _ = sjson.SetBytes(systemMsg, "content", systemText) + keptMessages = append([][]byte{systemMsg}, keptMessages...) + } + + return translatorcommon.SetRawArrayItems(payload, "messages", keptMessages) +} + +func assistantMessageHasContent(msg []byte) bool { + if gjson.GetBytes(msg, "tool_calls").IsArray() && len(gjson.GetBytes(msg, "tool_calls").Array()) > 0 { + return true + } + + c := gjson.GetBytes(msg, "content") + if !c.Exists() || c.Type == gjson.Null { + return false + } + + if c.Type == gjson.String { + return strings.TrimSpace(c.String()) != "" + } + + if c.IsArray() && len(c.Array()) > 0 { + for _, part := range c.Array() { + if part.Get("type").String() == "text" { + if strings.TrimSpace(part.Get("text").String()) != "" { + return true + } + } else if part.Get("type").Exists() { + return true + } + } + } + + return false +} + +func formatCarryOverText(blocks []string) string { + omitted := 0 + if len(blocks) > carryOverMaxBlocks { + omitted = len(blocks) - carryOverMaxBlocks + blocks = blocks[len(blocks)-carryOverMaxBlocks:] + } + + var parts []string + if omitted > 0 { + parts = append(parts, fmt.Sprintf("[... %d older reasoning block(s) omitted; showing the most recent %d.]", omitted, carryOverMaxBlocks)) + } + + for i, block := range blocks { + if i > 0 || omitted > 0 { + parts = append(parts, "") + } + + runes := []rune(block) + if len(runes) > carryOverMaxBlockSize { + block = string(runes[:carryOverMaxBlockSize]) + "\n\n... [reasoning truncated]" + } + parts = append(parts, block) + } + + return strings.Join(parts, "\n") +} + +func newCarryOverTextPart(text string) []byte { + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + return part +} + +func mergeCarryOverIntoSystemMessage(msg []byte, carryOverText string) []byte { + c := gjson.GetBytes(msg, "content") + + switch { + case !c.Exists() || c.Type == gjson.Null: + msg, _ = sjson.SetBytes(msg, "content", carryOverText) + + case c.Type == gjson.String: + merged := carryOverText + "\n\n" + c.String() + msg, _ = sjson.SetBytes(msg, "content", merged) + + case c.IsArray(): + items := [][]byte{newCarryOverTextPart(carryOverText)} + c.ForEach(func(_, part gjson.Result) bool { + switch { + case part.IsObject(): + items = append(items, []byte(part.Raw)) + case part.Type == gjson.String: + items = append(items, newCarryOverTextPart(part.String())) + } + return true + }) + + msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(items)) + + default: + msg, _ = sjson.SetBytes(msg, "content", carryOverText) + } + + return msg +} + +// carryOverClaudeSource extracts unsigned assistant thinking blocks from a +// Claude request and rewrites them as a top-level system instruction. Signed +// thinking with a compatible signature is left in place so the normal registry +// path can map it to reasoning_content. This runs before native translation so +// unsigned thinking is not dropped; plugin NormalizeRequest hooks then run on +// the translated provider payload and own the final OpenAI-shaped request. +func carryOverClaudeSource(payload []byte) []byte { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload + } + + messages := gjson.GetBytes(payload, "messages") + if !messages.Exists() || !messages.IsArray() { + return payload + } + + var blocks []string + keptMessages := make([][]byte, 0, len(messages.Array())) + + messages.ForEach(func(_, msg gjson.Result) bool { + role := msg.Get("role").String() + if role != "assistant" { + keptMessages = append(keptMessages, []byte(msg.Raw)) + return true + } + + content := msg.Get("content") + if !content.IsArray() { + keptMessages = append(keptMessages, []byte(msg.Raw)) + return true + } + + var keptParts [][]byte + hasToolUse := false + extractedFromThis := false + + content.ForEach(func(_, part gjson.Result) bool { + partType := part.Get("type").String() + if partType == "tool_use" { + hasToolUse = true + } + if partType != "thinking" { + if part.IsObject() { + keptParts = append(keptParts, []byte(part.Raw)) + } + return true + } + + text := thinking.GetThinkingText(part) + if strings.TrimSpace(text) == "" { + return true + } + + if isUnsignedClaudeThinking(part) { + extractedFromThis = true + blocks = append(blocks, text) + return true + } + + keptParts = append(keptParts, []byte(part.Raw)) + return true + }) + + if !extractedFromThis { + keptMessages = append(keptMessages, []byte(msg.Raw)) + return true + } + + if len(keptParts) == 0 && !hasToolUse { + // assistant turn was only unsigned thinking; drop it + return true + } + + updated := []byte(msg.Raw) + updated, _ = sjson.SetRawBytes(updated, "content", translatorcommon.JoinRawArray(keptParts)) + keptMessages = append(keptMessages, updated) + return true + }) + + if len(blocks) == 0 { + return payload + } + + systemText := carryOverLabel + ":\n\n" + formatCarryOverText(blocks) + payload = injectClaudeCarryOverSystem(payload, systemText) + return translatorcommon.SetRawArrayItems(payload, "messages", keptMessages) +} + +func isUnsignedClaudeThinking(part gjson.Result) bool { + sig := part.Get("signature").String() + if strings.TrimSpace(sig) == "" { + return true + } + _, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, sig) + return !ok +} + +func injectClaudeCarryOverSystem(payload []byte, carryOverText string) []byte { + system := gjson.GetBytes(payload, "system") + + switch { + case !system.Exists() || system.Type == gjson.Null: + payload, _ = sjson.SetBytes(payload, "system", carryOverText) + + case system.Type == gjson.String: + var merged string + if strings.TrimSpace(system.String()) != "" { + merged = carryOverText + "\n\n" + system.String() + } else { + merged = carryOverText + } + payload, _ = sjson.SetBytes(payload, "system", merged) + + case system.IsArray(): + items := [][]byte{newCarryOverTextPart(carryOverText)} + system.ForEach(func(_, part gjson.Result) bool { + switch { + case part.IsObject(): + items = append(items, []byte(part.Raw)) + case part.Type == gjson.String: + items = append(items, newCarryOverTextPart(part.String())) + } + return true + }) + + payload, _ = sjson.SetRawBytes(payload, "system", translatorcommon.JoinRawArray(items)) + + default: + payload, _ = sjson.SetBytes(payload, "system", carryOverText) + } + + return payload +} diff --git a/internal/runtime/executor/helps/carry_over_test.go b/internal/runtime/executor/helps/carry_over_test.go new file mode 100644 index 000000000..8691fcfa4 --- /dev/null +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -0,0 +1,380 @@ +package helps + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCarryOverThinkingToSystem_MovesReasoningToSystemMessage(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "answer", "reasoning_content": "I should be helpful."} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + if gjson.GetBytes(out, "messages.0.role").String() != "system" { + t.Fatalf("expected first message to be system, got %s", gjson.GetBytes(out, "messages.0.role").String()) + } + content := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(content, carryOverLabel) { + t.Fatalf("expected system content to contain %q, got %q", carryOverLabel, content) + } + if !strings.Contains(content, "I should be helpful") { + t.Fatalf("expected system content to contain reasoning, got %q", content) + } + + if gjson.GetBytes(out, "messages.2.reasoning_content").Exists() { + t.Fatalf("reasoning_content should be removed from assistant message") + } + + assistant := gjson.GetBytes(out, "messages.2") + if assistant.Get("role").String() != "assistant" || assistant.Get("content").String() != "answer" { + t.Fatalf("assistant message should be preserved, got %s", assistant.Raw) + } +} + +func TestCarryOverThinkingToSystem_DropsEmptyAssistantMessage(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "", "reasoning_content": "only reasoning"} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + if gjson.GetBytes(out, "messages.#").Int() != 2 { + t.Fatalf("expected 2 messages, got %d: %s", gjson.GetBytes(out, "messages.#").Int(), string(out)) + } + if gjson.GetBytes(out, "messages.1.role").String() != "user" { + t.Fatalf("user message should remain second") + } +} + +func TestCarryOverThinkingToSystem_KeepsAssistantWithToolCalls(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "assistant", "content": "", "tool_calls": [{"id":"1","type":"function"}], "reasoning_content": "tool planning"} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + if gjson.GetBytes(out, "messages.#").Int() != 2 { + t.Fatalf("expected 2 messages, got %s", string(out)) + } + if !gjson.GetBytes(out, "messages.1.tool_calls").Exists() { + t.Fatalf("tool_calls should be preserved") + } + if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("reasoning_content should be removed") + } +} + +func TestCarryOverThinkingToSystem_MergesIntoExistingSystemMessage(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "assistant", "reasoning_content": "thinking", "content": "hi"} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + content := gjson.GetBytes(out, "messages.0.content").String() + if !strings.HasPrefix(content, carryOverLabel) { + t.Fatalf("expected carry-over label at start of system content, got %q", content) + } + if !strings.Contains(content, "You are a helpful assistant.") { + t.Fatalf("expected original system content to be preserved, got %q", content) + } +} + +func TestCarryOverThinkingToSystem_MergesIntoExistingSystemMessageArray(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "system", "content": [{"type":"text","text":"base"}]}, + {"role": "assistant", "reasoning_content": "thinking", "content": "hi"} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + firstType := gjson.GetBytes(out, "messages.0.content.0.type").String() + if firstType != "text" { + t.Fatalf("expected first content part to be text, got %q", firstType) + } + if !strings.Contains(gjson.GetBytes(out, "messages.0.content.0.text").String(), carryOverLabel) { + t.Fatalf("expected carry-over text in first content part, got %q", gjson.GetBytes(out, "messages.0.content.0.text").String()) + } + if gjson.GetBytes(out, "messages.0.content.1.text").String() != "base" { + t.Fatalf("expected original content part to be preserved, got %q", gjson.GetBytes(out, "messages.0.content.1.text").String()) + } +} + +func TestCarryOverThinkingToSystem_BoundsAndTruncates(t *testing.T) { + // Build 5 reasoning blocks + blocks := []string{"old1", "old2", "mid", "recent", "newest"} + var msgs []string + for _, b := range blocks { + msgs = append(msgs, `{"role":"assistant","content":"","reasoning_content":"`+b+`"}`) + } + input := []byte(`{"model":"test","messages":[` + strings.Join(msgs, ",") + `]}`) + + out := CarryOverThinkingToSystem(input) + + system := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(system, "older reasoning block(s) omitted") { + t.Fatalf("expected omission marker, got %q", system) + } + if strings.Contains(system, "old1") || strings.Contains(system, "old2") { + t.Fatalf("expected old1 and old2 to be omitted, got %q", system) + } + if !strings.Contains(system, "mid") || !strings.Contains(system, "recent") || !strings.Contains(system, "newest") { + t.Fatalf("expected mid, recent, newest to be present, got %q", system) + } +} + +func TestCarryOverThinkingToSystem_TruncatesLongBlock(t *testing.T) { + long := strings.Repeat("x", carryOverMaxBlockSize+50) + input := []byte(`{"model":"test","messages":[{"role":"assistant","content":"","reasoning_content":"` + long + `"}]}`) + + out := CarryOverThinkingToSystem(input) + + system := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(system, "... [reasoning truncated]") { + t.Fatalf("expected truncation marker, got %q", system) + } +} + +func TestCarryOverThinkingToSystem_PreservesStringArrayElements(t *testing.T) { + input := []byte(`{ + "model": "test", + "messages": [ + {"role": "system", "content": ["plain string", {"type":"text","text":"object part"}]}, + {"role": "assistant", "reasoning_content": "thinking", "content": "hi"} + ] + }`) + + out := CarryOverThinkingToSystem(input) + + if gjson.GetBytes(out, "messages.0.content.1.type").String() != "text" || gjson.GetBytes(out, "messages.0.content.1.text").String() != "plain string" { + t.Fatalf("expected plain string to be wrapped and preserved, got %s", string(out)) + } + if gjson.GetBytes(out, "messages.0.content.2.text").String() != "object part" { + t.Fatalf("expected object part to be preserved, got %s", string(out)) + } +} + +func TestCarryOverThinkingToSystem_NoReasoningLeavesPayloadUnchanged(t *testing.T) { + input := []byte(`{"model":"test","messages":[{"role":"user","content":"hello"}]}`) + out := CarryOverThinkingToSystem(input) + if string(out) != string(input) { + t.Fatalf("expected payload to be unchanged, got %s", string(out)) + } +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_CarryOverClaudeToOpenAI(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + claudePayload := []byte(`{ + "model": "claude-3-opus", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type":"text","text":"hi"},{"type":"thinking","thinking":"internal reasoning"}]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, false) + + firstRole := gjson.GetBytes(out, "messages.0.role").String() + if firstRole != "system" { + t.Fatalf("expected first message role to be system, got %q", firstRole) + } + if !strings.Contains(gjson.GetBytes(out, "messages.0.content").String(), "internal reasoning") { + t.Fatalf("expected reasoning in system message, got %s", string(out)) + } + if gjson.GetBytes(out, "messages.#").Int() != 3 { + t.Fatalf("expected 3 messages (system, user, assistant), got %d: %s", gjson.GetBytes(out, "messages.#").Int(), string(out)) + } + for _, msg := range gjson.GetBytes(out, "messages").Array() { + if msg.Get("reasoning_content").Exists() { + t.Fatalf("no message should have reasoning_content, got %s", msg.Raw) + } + } +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_RespectsCompat(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + claudePayload := []byte(`{ + "model": "claude-3-opus", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type":"text","text":"hi"},{"type":"thinking","thinking":"internal reasoning"}]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, true) + + if gjson.GetBytes(out, "messages.0.role").String() == "system" { + t.Fatalf("system carry-over should not happen when isCompat is true") + } + if !gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("expected canonical reasoning_content on compat path, got %s", string(out)) + } +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_DisabledByDefault(t *testing.T) { + cfg := &config.Config{} // default false + + claudePayload := []byte(`{ + "model": "claude-3-opus", + "messages": [ + {"role": "assistant", "content": [{"type":"thinking","thinking":"internal reasoning"},{"type":"text","text":"hi"}]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, false) + + if gjson.GetBytes(out, "messages.0.role").String() == "system" { + t.Fatalf("carry-over should not happen when disabled") + } + // Non-compat default drops unsigned thinking. + if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { + t.Fatalf("unsigned thinking should not become reasoning_content on default non-compat path, got %s", string(out)) + } +} + +func validGPTChatReasoningSignature() string { + raw := make([]byte, 1+8+16+16+32) + raw[0] = 0x80 + raw[8] = 1 + for i := 9; i < len(raw); i++ { + raw[i] = byte(i) + } + return base64.URLEncoding.EncodeToString(raw) +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_CarryOverKeepsSignedReasoningContent(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + sig := validGPTChatReasoningSignature() + claudePayload := []byte(`{ + "model": "claude-3-opus", + "messages": [ + {"role": "assistant", "content": [ + {"type":"thinking","thinking":"unsigned fallback reasoning"}, + {"type":"thinking","thinking":"signed canonical reasoning","signature":"` + sig + `"}, + {"type":"text","text":"hi"} + ]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, false) + + system := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(system, "unsigned fallback reasoning") { + t.Fatalf("expected unsigned reasoning in system message, got %s", string(out)) + } + if strings.Contains(system, "signed canonical reasoning") { + t.Fatalf("signed reasoning should stay as reasoning_content, got %s", string(out)) + } + + assistant := gjson.GetBytes(out, "messages.1") + if assistant.Get("role").String() != "assistant" { + t.Fatalf("expected assistant message, got %s", assistant.Raw) + } + if !strings.Contains(assistant.Get("reasoning_content").String(), "signed canonical reasoning") { + t.Fatalf("expected signed reasoning as reasoning_content, got %s", string(out)) + } + if assistant.Get("content.0.text").String() != "hi" { + t.Fatalf("expected assistant content to be preserved, got %s", string(out)) + } +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_CarryOverMergesWithSystem(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + claudePayload := []byte(`{ + "model": "claude-3-opus", + "system": "Base instructions", + "messages": [ + {"role": "assistant", "content": [{"type":"thinking","thinking":"prior reasoning"}]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, false) + + system := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(system, carryOverLabel) { + t.Fatalf("expected carry-over label in system message, got %q", system) + } + if !strings.Contains(system, "Base instructions") { + t.Fatalf("expected original system instructions to be preserved, got %q", system) + } + if !strings.Contains(system, "prior reasoning") { + t.Fatalf("expected prior reasoning in system message, got %q", system) + } +} + +func TestTranslateRequestWithAPIKeyModelCompatibility_CarryOverKeepsToolCalls(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + claudePayload := []byte(`{ + "model": "claude-3-opus", + "messages": [ + {"role": "assistant", "content": [ + {"type":"thinking","thinking":"tool planning"}, + {"type":"tool_use","id":"tu_1","name":"do_work","input":{"x":1}} + ]} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, "test", claudePayload, false, false) + + if gjson.GetBytes(out, "messages.#").Int() != 2 { + t.Fatalf("expected 2 messages, got %d: %s", gjson.GetBytes(out, "messages.#").Int(), string(out)) + } + system := gjson.GetBytes(out, "messages.0.content").String() + if !strings.Contains(system, "tool planning") { + t.Fatalf("expected tool planning in system message, got %q", system) + } + if !gjson.GetBytes(out, "messages.1.tool_calls").Exists() { + t.Fatalf("expected assistant tool_calls to be preserved, got %s", string(out)) + } +} diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 4e2209f86..a7665a748 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -69,6 +69,23 @@ func sameByteSlice(a, b []byte) bool { // request translators when a configured API-key model enables compatibility mode. func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) []byte { if !isCompat { + if cfg != nil && cfg.Translator.CarryOverThinkingInSystem && to == sdktranslator.FormatOpenAI { + working := payload + if from == sdktranslator.FormatClaude { + // Extract unsigned assistant thinking before native translation so + // it is not dropped. The registry then translates and runs plugin + // NormalizeRequest hooks on the final OpenAI-shaped payload. + // Signed thinking stays in place and maps to reasoning_content. + working = carryOverClaudeSource(working) + } + translated := TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, working, stream) + if from != sdktranslator.FormatClaude { + // Other sources (e.g. openai-response) may already expose prior + // reasoning as reasoning_content in the translated payload. + translated = CarryOverThinkingToSystem(translated) + } + return translated + } return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) } if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {