Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
9 changes: 9 additions & 0 deletions internal/config/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
307 changes: 307 additions & 0 deletions internal/runtime/executor/helps/carry_over.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading