From c75a7d6b24e531b7e8eb8594c65009c9bce0cad6 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:19:24 +0300 Subject: [PATCH 1/6] 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 0d97093d6ad237cfd0f6de2eef215cc8cf65781a Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:51:30 +0300 Subject: [PATCH 2/6] 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 2dbd70b1db2a3270b66d29174746ec47df8beb0c Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:54:34 +0300 Subject: [PATCH 3/6] fix(helps): wrap string elements when merging carry-over system Preserve string parts in system content arrays by wrapping them as {"type":"text","text":"..."} parts. Applied to both OpenAI-style message merge and Claude top-level system injection. --- internal/runtime/executor/helps/carry_over.go | 26 ++++++++++++------- .../runtime/executor/helps/carry_over_test.go | 19 ++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index 589bd9770..f15c727e9 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -137,6 +137,12 @@ func formatCarryOverText(blocks []string) string { 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") @@ -149,13 +155,13 @@ func mergeCarryOverIntoSystemMessage(msg []byte, carryOverText string) []byte { msg, _ = sjson.SetBytes(msg, "content", merged) case c.IsArray(): - newPart := []byte(`{"type":"text","text":""}`) - newPart, _ = sjson.SetBytes(newPart, "text", carryOverText) - - items := [][]byte{newPart} + items := [][]byte{newCarryOverTextPart(carryOverText)} c.ForEach(func(_, part gjson.Result) bool { - if part.IsObject() { + switch { + case part.IsObject(): items = append(items, []byte(part.Raw)) + case part.Type == gjson.String: + items = append(items, newCarryOverTextPart(part.String())) } return true }) @@ -286,13 +292,13 @@ func injectClaudeCarryOverSystem(payload []byte, carryOverText string) []byte { payload, _ = sjson.SetBytes(payload, "system", merged) case system.IsArray(): - newPart := []byte(`{"type":"text","text":""}`) - newPart, _ = sjson.SetBytes(newPart, "text", carryOverText) - - items := [][]byte{newPart} + items := [][]byte{newCarryOverTextPart(carryOverText)} system.ForEach(func(_, part gjson.Result) bool { - if part.IsObject() { + switch { + case part.IsObject(): items = append(items, []byte(part.Raw)) + case part.Type == gjson.String: + items = append(items, newCarryOverTextPart(part.String())) } return true }) diff --git a/internal/runtime/executor/helps/carry_over_test.go b/internal/runtime/executor/helps/carry_over_test.go index 9fbb035cc..8691fcfa4 100644 --- a/internal/runtime/executor/helps/carry_over_test.go +++ b/internal/runtime/executor/helps/carry_over_test.go @@ -161,6 +161,25 @@ func TestCarryOverThinkingToSystem_TruncatesLongBlock(t *testing.T) { } } +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) From 583f604bcfea0dd1bc2005b5501e64bdcae9b60b Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 01:55:44 +0300 Subject: [PATCH 4/6] docs(helps): align CarryOverThinkingToSystem doc comment with behavior Clarify that the function drops assistant messages that are empty after reasoning is removed, including pre-existing empty assistant messages. OpenAI rejects empty assistant messages, so this is intentional. --- internal/runtime/executor/helps/carry_over.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index f15c727e9..145f7105d 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -19,8 +19,10 @@ const ( // 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. +// 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. From a2d8af74f1c2660309460dfacbcee772c0681467 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 02:17:36 +0300 Subject: [PATCH 5/6] docs(helps): correct NormalizeRequest hook comment NormalizeRequest runs after native translation and owns the final provider payload, so the hook sees the translated OpenAI-shaped payload, not a Claude-shaped one. Update comments to match. --- internal/runtime/executor/helps/carry_over.go | 5 +++-- internal/runtime/executor/helps/codex_multi_agent_v2.go | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index 145f7105d..ad06dc8a8 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -180,8 +180,9 @@ func mergeCarryOverIntoSystemMessage(msg []byte, carryOverText string) []byte { // 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. +// 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 diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index faef01537..a7665a748 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -72,10 +72,10 @@ func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers h if cfg != nil && cfg.Translator.CarryOverThinkingInSystem && to == sdktranslator.FormatOpenAI { 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. + // 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) From c968ddf067e709063ba26cffec4dd7e72610a943 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 07:20:31 +0300 Subject: [PATCH 6/6] docs(config): document carry-over-thinking-in-system Add the new translator flag to config.example.yaml with name, default OFF, what it does, and a P2 trade-off note. Also remove the unreachable branch in carryOverClaudeSource after the drop guard; JoinRawArray already returns [] for an empty slice. --- config.example.yaml | 13 +++++++++++++ internal/runtime/executor/helps/carry_over.go | 6 +----- 2 files changed, 14 insertions(+), 5 deletions(-) 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/runtime/executor/helps/carry_over.go b/internal/runtime/executor/helps/carry_over.go index ad06dc8a8..2ed05dd4f 100644 --- a/internal/runtime/executor/helps/carry_over.go +++ b/internal/runtime/executor/helps/carry_over.go @@ -251,11 +251,7 @@ func carryOverClaudeSource(payload []byte) []byte { } updated := []byte(msg.Raw) - if len(keptParts) == 0 { - updated, _ = sjson.SetRawBytes(updated, "content", []byte("[]")) - } else { - updated, _ = sjson.SetRawBytes(updated, "content", translatorcommon.JoinRawArray(keptParts)) - } + updated, _ = sjson.SetRawBytes(updated, "content", translatorcommon.JoinRawArray(keptParts)) keptMessages = append(keptMessages, updated) return true })