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
34 changes: 34 additions & 0 deletions internal/runtime/executor/openai_compat_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
translated = normalizeOpenAICompatClaudeSampling(translated, originalPayloadSource, from)
if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth), baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
Expand Down Expand Up @@ -342,6 +343,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
translated = normalizeOpenAICompatClaudeSampling(translated, originalPayloadSource, from)
if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth), baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
Expand Down Expand Up @@ -572,6 +574,38 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}

// Claude clients may send top_k together with extended/adaptive thinking. The
// OpenAI-compatible translation can otherwise preserve that knob all the way
// to a Claude-backed compatibility route, where Anthropic rejects the pair.
// Run this after payload overrides so they cannot reintroduce the invalid
// combination. Non-Claude callers and non-thinking Claude requests are kept.
func normalizeOpenAICompatClaudeSampling(body, source []byte, from sdktranslator.Format) []byte {
if !sourceFormatEqual(from, sdktranslator.FormatClaude) {
return body
}
thinkingActive := false
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(source, "thinking.type").String())) {
case "enabled", "adaptive", "auto":
thinkingActive = true
Comment on lines +587 to +589

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect final thinking overrides before dropping top_k

When a Claude request initially enables thinking but a model suffix such as (none) or a payload override disables it, ApplyRequestThinking and the override stage produce a final non-thinking request, yet this switch still marks thinking active from the pre-override source and deletes top_k. This silently changes a valid sampling configuration even though the upstream-invalid combination no longer exists; determine activity from the finalized canonical/transformed body so the established suffix and override precedence is preserved.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

}
if !thinkingActive {
for _, path := range []string{"output_config.effort", "reasoning_effort", "reasoning.effort"} {
value := strings.ToLower(strings.TrimSpace(gjson.GetBytes(source, path).String()))
if value == "" {
value = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String()))
}
if value != "" && value != "none" {
thinkingActive = true
break
}
}
}
if thinkingActive {
body, _ = sjson.DeleteBytes(body, "top_k")
}
return body
}

func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName

Expand Down
94 changes: 94 additions & 0 deletions internal/runtime/executor/openai_compat_executor_sampling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package executor

import (
"testing"

sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)

func TestNormalizeOpenAICompatClaudeSampling(t *testing.T) {
tests := []struct {
name string
from sdktranslator.Format
source string
translated string
wantTopK bool
}{
{
name: "claude adaptive thinking drops top_k",
from: sdktranslator.FormatClaude,
source: `{"thinking":{"type":"adaptive"},"top_k":40}`,
translated: `{"reasoning_effort":"max","temperature":1,"top_k":40}`,
},
{
name: "claude enabled thinking drops top_k",
from: sdktranslator.FormatClaude,
source: `{"thinking":{"type":"enabled","budget_tokens":1024},"top_k":40}`,
translated: `{"reasoning_effort":"low","top_k":40}`,
},
{
name: "claude effort activates thinking after translation and drops top_k",
from: sdktranslator.FormatClaude,
source: `{"output_config":{"effort":"max"},"top_k":40}`,
translated: `{"reasoning_effort":"max","top_k":40}`,
},
{
name: "translated reasoning effort drops top_k",
from: sdktranslator.FormatClaude,
source: `{"messages":[],"top_k":40}`,
translated: `{"reasoning_effort":"high","top_k":40}`,
},
{
name: "claude auto thinking drops top_k",
from: sdktranslator.FormatClaude,
source: `{"thinking":{"type":"auto"},"top_k":40}`,
translated: `{"top_k":40}`,
},
{
name: "claude reasoning effort drops top_k",
from: sdktranslator.FormatClaude,
source: `{"reasoning":{"effort":"high"},"top_k":40}`,
translated: `{"top_k":40}`,
},
{
name: "explicit none effort keeps top_k",
from: sdktranslator.FormatClaude,
source: `{"output_config":{"effort":"none"},"top_k":40}`,
translated: `{"reasoning_effort":"none","top_k":40}`,
wantTopK: true,
},
{
name: "translated active effort overrides source disabled sentinel",
from: sdktranslator.FormatClaude,
source: `{"thinking":{"type":"disabled"},"top_k":40}`,
translated: `{"reasoning_effort":"high","top_k":40}`,
},
{
name: "claude without thinking keeps top_k",
from: sdktranslator.FormatClaude,
source: `{"messages":[]}`,
translated: `{"top_k":40}`,
wantTopK: true,
},
{
name: "non-claude source keeps top_k",
from: sdktranslator.FormatOpenAI,
source: `{"thinking":{"type":"adaptive"}}`,
translated: `{"top_k":40}`,
wantTopK: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeOpenAICompatClaudeSampling([]byte(tt.translated), []byte(tt.source), tt.from)
if exists := gjson.GetBytes(got, "top_k").Exists(); exists != tt.wantTopK {
t.Fatalf("top_k exists = %v, want %v; body=%s", exists, tt.wantTopK, got)
}
if temperature := gjson.GetBytes(got, "temperature"); temperature.Exists() && temperature.Int() != 1 {
t.Fatalf("temperature changed unexpectedly: body=%s", got)
}
})
}
}
Loading