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/executor_payload_optimization_test.go b/internal/runtime/executor/executor_payload_optimization_test.go index c60b934ff..342e80d05 100644 --- a/internal/runtime/executor/executor_payload_optimization_test.go +++ b/internal/runtime/executor/executor_payload_optimization_test.go @@ -40,8 +40,8 @@ func TestNormalizeKimiToolMessageLinksPreservesLargeArguments(t *testing.T) { if got := gjson.GetBytes(output, "messages.1.tool_call_id").String(); got != "call_1" { t.Fatalf("tool_call_id = %q, want call_1", got) } - if got := gjson.GetBytes(output, "messages.0.reasoning_content").String(); got != "lookup" { - t.Fatalf("reasoning_content = %q, want lookup", got) + if got := gjson.GetBytes(output, "messages.0.reasoning_content").String(); got != "[reasoning unavailable]" { + t.Fatalf("reasoning_content = %q, want [reasoning unavailable]", got) } } diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go new file mode 100644 index 000000000..5ac374881 --- /dev/null +++ b/internal/runtime/executor/helps/carry_over.go @@ -0,0 +1,307 @@ +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 become empty after the move. +// 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 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(): + newPart := []byte(`{"type":"text","text":""}`) + newPart, _ = sjson.SetBytes(newPart, "text", carryOverText) + + items := [][]byte{newPart} + c.ForEach(func(_, part gjson.Result) bool { + if part.IsObject() { + items = append(items, []byte(part.Raw)) + } + 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 registry translation +// so plugin NormalizeRequest hooks still see the translated OpenAI-shaped payload. +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) + if len(keptParts) == 0 { + updated, _ = sjson.SetRawBytes(updated, "content", []byte("[]")) + } else { + 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(): + newPart := []byte(`{"type":"text","text":""}`) + newPart, _ = sjson.SetBytes(newPart, "text", carryOverText) + + items := [][]byte{newPart} + system.ForEach(func(_, part gjson.Result) bool { + if part.IsObject() { + items = append(items, []byte(part.Raw)) + } + 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..bd8b4c57e --- /dev/null +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -0,0 +1,385 @@ +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_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_SkipsCarryOverForKimi(t *testing.T) { + cfg := &config.Config{ + Translator: config.TranslatorConfig{ + CarryOverThinkingInSystem: true, + }, + } + + payload := []byte(`{ + "model": "kimi-k3", + "messages": [ + {"role": "assistant", "content": "visible", "reasoning_content": "prior reasoning"} + ] + }`) + + out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, cfg, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAI, "kimi-k3", payload, false, false) + + if gjson.GetBytes(out, "messages.0.role").String() == "system" { + t.Fatalf("carry-over should not move reasoning to system for Kimi, got %s", string(out)) + } + if got := gjson.GetBytes(out, "messages.0.reasoning_content").String(); got != "prior reasoning" { + t.Fatalf("Kimi should keep canonical reasoning_content, got %q; output: %s", got, 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..948e6c0e1 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -6,6 +6,7 @@ import ( multiagentv2 "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" openaichatclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions" responsesclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" @@ -69,6 +70,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 && !isKimiReasoningTarget(model) { + working := payload + if from == sdktranslator.FormatClaude { + // Extract unsigned assistant thinking into the top-level system + // field before registry translation so plugin NormalizeRequest + // hooks and summary-config logic still run. Signed thinking stays + // in place and maps to reasoning_content via the registry. + 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 { @@ -103,6 +121,14 @@ func HasCodexMultiAgentV2NamespaceConflict(payload []byte) bool { return multiagentv2.HasCodexMultiAgentV2NamespaceConflict(payload) } +// isKimiReasoningTarget reports whether the requested OpenAI-shaped target +// already carries a canonical reasoning_content field, so carrying prior +// reasoning into the system message would move it out of the canonical +// container. +func isKimiReasoningTarget(model string) bool { + return sigcompat.SignatureProviderFromModelName(model) == sigcompat.SignatureProviderKimi +} + // OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and // reports whether the collaboration namespace was renamed for upstream use. func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) { diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 22a26b509..2a729761b 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -617,26 +617,9 @@ func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) return latest } - content := msg.Get("content") - if content.Type == gjson.String { - if text := strings.TrimSpace(content.String()); text != "" { - return text - } - } - if content.IsArray() { - parts := make([]string, 0, len(content.Array())) - for _, item := range content.Array() { - text := strings.TrimSpace(item.Get("text").String()) - if text == "" { - continue - } - parts = append(parts, text) - } - if len(parts) > 0 { - return strings.Join(parts, "\n") - } - } - + // Do not use visible assistant content as hidden reasoning_content. + // reasoning_content must come from canonical reasoning sources only; + // fabricating it from content risks self-reflection loops on replay. return kimiReasoningUnavailable } diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go index fd806b1e7..0ef30603f 100644 --- a/internal/runtime/executor/kimi_executor_test.go +++ b/internal/runtime/executor/kimi_executor_test.go @@ -466,7 +466,7 @@ func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *te } } -func TestNormalizeKimiToolMessageLinks_DoesNotReuseUnavailableReasoning(t *testing.T) { +func TestNormalizeKimiToolMessageLinks_DoesNotBackfillReasoningFromContent(t *testing.T) { body := []byte(`{ "messages":[ {"role":"assistant","reasoning_content":"[reasoning unavailable]"}, @@ -480,8 +480,8 @@ func TestNormalizeKimiToolMessageLinks_DoesNotReuseUnavailableReasoning(t *testi } got := gjson.GetBytes(out, "messages.1.reasoning_content").String() - if got != "current summary" { - t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "current summary") + if got != "[reasoning unavailable]" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "[reasoning unavailable]") } } @@ -505,7 +505,7 @@ func TestNormalizeKimiToolMessageLinks_UnavailableReasoningDoesNotOverridePrevio } } -func TestNormalizeKimiToolMessageLinks_ReplacesUnavailableReasoningContent(t *testing.T) { +func TestNormalizeKimiToolMessageLinks_KeepsUnavailableReasoningContent(t *testing.T) { body := []byte(`{ "messages":[ {"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"[reasoning unavailable]"} @@ -518,12 +518,12 @@ func TestNormalizeKimiToolMessageLinks_ReplacesUnavailableReasoningContent(t *te } got := gjson.GetBytes(out, "messages.0.reasoning_content").String() - if got != "assistant summary" { - t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary") + if got != "[reasoning unavailable]" { + t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "[reasoning unavailable]") } } -func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) { +func TestNormalizeKimiToolMessageLinks_DoesNotUseContentAsReasoningFallback(t *testing.T) { body := []byte(`{ "messages":[ {"role":"assistant","content":[{"type":"text","text":"first line"},{"type":"text","text":"second line"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]} @@ -536,12 +536,12 @@ func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing } got := gjson.GetBytes(out, "messages.0.reasoning_content").String() - if got != "first line\nsecond line" { - t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "first line\nsecond line") + if got != "[reasoning unavailable]" { + t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "[reasoning unavailable]") } } -func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) { +func TestNormalizeKimiToolMessageLinks_DoesNotReplaceEmptyReasoningWithContent(t *testing.T) { body := []byte(`{ "messages":[ {"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":""} @@ -554,8 +554,8 @@ func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing. } got := gjson.GetBytes(out, "messages.0.reasoning_content").String() - if got != "assistant summary" { - t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary") + if got != "[reasoning unavailable]" { + t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "[reasoning unavailable]") } } diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 42f2783c3..33f771df4 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -38,6 +38,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Model mapping out, _ = sjson.SetBytes(out, "model", modelName) + targetProvider := sigcompat.SignatureProviderFromModelName(modelName) // Max tokens if maxTokens := root.Get("max_tokens"); maxTokens.Exists() { @@ -174,7 +175,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream case "thinking": // Only map thinking to reasoning_content for assistant messages (security: prevent injection) if role == "assistant" { - if !shouldMapClaudeThinkingToGPTReasoning(part, preserveThinkingBlocks) { + if !shouldMapClaudeThinkingToReasoning(part, preserveThinkingBlocks, targetProvider, modelName) { return true } thinkingText := thinking.GetThinkingText(part) @@ -369,12 +370,17 @@ func normalizeObjectSchemaProperties(schema any) any { } } -func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBlocks ...bool) bool { - preserveThinking := len(preserveThinkingBlocks) > 0 && preserveThinkingBlocks[0] - if preserveThinking { +func shouldMapClaudeThinkingToReasoning(part gjson.Result, preserveThinkingBlocks bool, targetProvider sigcompat.SignatureProvider, modelName string) bool { + if preserveThinkingBlocks { return true } + if targetProvider == sigcompat.SignatureProviderKimi { + rawSignature := part.Get("signature").String() + decision := sigcompat.DecideSignatureCompatibilityForModel(targetProvider, modelName, rawSignature, sigcompat.SignatureBlockKindClaudeThinking) + return decision.Action == sigcompat.SignatureActionPreserve || decision.Action == sigcompat.SignatureActionDropSignature + } + signature := part.Get("signature") if !signature.Exists() || strings.TrimSpace(signature.String()) == "" { return false diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index 4b698bf85..af8722977 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -347,6 +347,62 @@ func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *test } } +func TestConvertClaudeRequestToOpenAI_KimiDegradesForeignSignedThinking(t *testing.T) { + tests := []struct { + name string + signature string + wantReasoning string + }{ + { + name: "unsigned thinking maps to reasoning_content", + signature: "", + wantReasoning: "provider state", + }, + { + name: "Claude signature degrades to reasoning_content", + signature: "claude#EjQ=", + wantReasoning: "provider state", + }, + { + name: "Gemini signature degrades to reasoning_content", + signature: "gemini#EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA", + wantReasoning: "provider state", + }, + { + name: "unknown signature degrades to reasoning_content", + signature: "not-a-provider-signature", + wantReasoning: "provider state", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "provider state", "signature": "` + tt.signature + `"}, + {"type": "text", "text": "visible answer"} + ] + }] + }` + + result := ConvertClaudeRequestToOpenAI("kimi-k3", []byte(inputJSON), false) + assistantMsg := gjson.GetBytes(result, "messages.0") + if !assistantMsg.Get("reasoning_content").Exists() { + t.Fatalf("reasoning_content should exist for Kimi target. Output: %s", string(result)) + } + if got := assistantMsg.Get("reasoning_content").String(); got != tt.wantReasoning { + t.Fatalf("reasoning_content = %q, want %q. Output: %s", got, tt.wantReasoning, string(result)) + } + if got := assistantMsg.Get("content.0.text").String(); got != "visible answer" { + t.Fatalf("visible content = %q, want visible answer. Output: %s", got, string(result)) + } + }) + } +} + func validGPTChatReasoningSignature() string { raw := make([]byte, 1+8+16+16+32) raw[0] = 0x80