From abfb9cdc4303cdf3e96f39dca1c8491a18721089 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:19:24 +0300 Subject: [PATCH 1/4] feat(executor): carry prior reasoning into system instructions Add config-gated carryOverThinkingInSystem that moves previous assistant reasoning_content into a labeled system message for OpenAI chat targets that lack a canonical thought field. Defaults off to preserve protocol purity. --- internal/config/config.go | 3 + internal/config/config_types.go | 9 + internal/runtime/executor/helps/carry_over.go | 168 ++++++++++++ .../runtime/executor/helps/carry_over_test.go | 249 ++++++++++++++++++ .../executor/helps/codex_multi_agent_v2.go | 11 + 5 files changed, 440 insertions(+) create mode 100644 internal/runtime/executor/helps/carry_over.go create mode 100644 internal/runtime/executor/helps/carry_over_test.go 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..62bc8bc13 --- /dev/null +++ b/internal/runtime/executor/helps/carry_over.go @@ -0,0 +1,168 @@ +package helps + +import ( + "fmt" + "strings" + + 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 +} 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..ea030d052 --- /dev/null +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -0,0 +1,249 @@ +package helps + +import ( + "context" + "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_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)) + } +} diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 4e2209f86..f2036adb9 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -69,6 +69,17 @@ 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 { + var translated []byte + if from == sdktranslator.FormatClaude { + // Preserve unsigned thinking blocks as reasoning_content so they + // can be moved to a system message instead of being dropped. + translated = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat(model, payload, stream) + } else { + translated = TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) + } + return CarryOverThinkingToSystem(translated) + } return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) } if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { From 191b4666f07dd3bc4b75c3414601bcd28d563cc9 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:51:30 +0300 Subject: [PATCH 2/4] refactor(helps): route claude carry-over through registry Extract unsigned assistant thinking into the source system field before registry translation so plugin NormalizeRequest hooks run. Signed thinking with compatible signatures stays in place and maps to reasoning_content as before. --- internal/runtime/executor/helps/carry_over.go | 139 ++++++++++++++++++ .../runtime/executor/helps/carry_over_test.go | 112 ++++++++++++++ .../executor/helps/codex_multi_agent_v2.go | 20 ++- 3 files changed, 264 insertions(+), 7 deletions(-) diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index 62bc8bc13..589bd9770 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -4,6 +4,8 @@ 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" @@ -166,3 +168,140 @@ func mergeCarryOverIntoSystemMessage(msg []byte, carryOverText string) []byte { 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 a Claude-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 index ea030d052..9fbb035cc 100644 --- a/internal/runtime/executor/helps/carry_over_test.go +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -2,6 +2,7 @@ package helps import ( "context" + "encoding/base64" "strings" "testing" @@ -247,3 +248,114 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_DisabledByDefault(t *testi 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 f2036adb9..faef01537 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -70,15 +70,21 @@ func sameByteSlice(a, b []byte) bool { 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 { - var translated []byte + working := payload if from == sdktranslator.FormatClaude { - // Preserve unsigned thinking blocks as reasoning_content so they - // can be moved to a system message instead of being dropped. - translated = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat(model, payload, stream) - } else { - translated = TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) + // 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) } - return CarryOverThinkingToSystem(translated) + 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) } From ac92fdda8e9a8fb090a4eff7c8b826dcd30fecec Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 02:41:19 +0300 Subject: [PATCH 3/4] feat(translator,executor): preserve Kimi reasoning as canonical reasoning_content - openai_claude_request.go: target-aware thinking mapping; Kimi degrades signed/unsigned Claude thinking to reasoning_content, GPT keeps strict. - codex_multi_agent_v2.go: skip CarryOverThinkingToSystem for Kimi targets so prior reasoning stays in canonical reasoning_content. - carry_over.go: fix plugin-payload doc comment. Tests pin Kimi degradation and carry-over skip. --- internal/runtime/executor/helps/carry_over.go | 2 +- .../runtime/executor/helps/carry_over_test.go | 24 ++++++++ .../executor/helps/codex_multi_agent_v2.go | 11 +++- .../openai/claude/openai_claude_request.go | 14 +++-- .../claude/openai_claude_request_test.go | 56 +++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index 589bd9770..5ac374881 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -173,7 +173,7 @@ func mergeCarryOverIntoSystemMessage(msg []byte, carryOverText string) []byte { // 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 a Claude-shaped payload. +// 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 diff --git a/internal/runtime/executor/helps/carry_over_test.go b/internal/runtime/executor/helps/carry_over_test.go index 9fbb035cc..bd8b4c57e 100644 --- a/internal/runtime/executor/helps/carry_over_test.go +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -228,6 +228,30 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_RespectsCompat(t *testing. } } +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 diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index faef01537..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,7 +70,7 @@ 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 { + 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 @@ -120,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/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 From 6a2710589deba9f7e67a5f7d6c17db954b1c0be7 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 03:38:49 +0300 Subject: [PATCH 4/4] fix(executor): do not fabricate Kimi reasoning from visible content fallbackAssistantReasoning previously copied assistant content into reasoning_content for tool-call messages when no prior reasoning existed. That fabricates hidden reasoning from visible text and risks self-reflection loops on replay. It now returns [reasoning unavailable] unless a usable prior reasoning_content exists. Decision: reports/kimi-fallback-reasoning-decision.md --- .../executor_payload_optimization_test.go | 4 ++-- internal/runtime/executor/kimi_executor.go | 23 +++--------------- .../runtime/executor/kimi_executor_test.go | 24 +++++++++---------- 3 files changed, 17 insertions(+), 34 deletions(-) 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/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]") } }