fix(thinking): respect final summary authority

This commit is contained in:
sususu
2026-07-31 13:28:14 +08:00
parent 0c2ec7da23
commit c4dcd8703a
6 changed files with 202 additions and 5 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
helps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
@@ -16,8 +17,10 @@ import (
)
type configuredThinkingExecutor struct {
seenModel string
resolved bool
seenModel string
resolved bool
translateRequest bool
translatedBody []byte
}
func (*configuredThinkingExecutor) Identifier() string { return "claude" }
@@ -27,6 +30,10 @@ func (e *configuredThinkingExecutor) Execute(_ context.Context, _ *cliproxyauth.
modelInfo, resolved := cliproxyauth.ResolvedAPIKeyModelInfo(req)
e.resolved = resolved && modelInfo != nil
body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`)
if e.translateRequest {
body = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatClaude, req.Model, req.Payload, opts.Stream)
e.translatedBody = append(e.translatedBody[:0], body...)
}
out, err := helps.ApplyRequestThinking(body, req, opts, opts.SourceFormat.String(), "claude", "claude")
return cliproxyexecutor.Response{Payload: out}, err
}
@@ -54,6 +61,83 @@ func (*configuredThinkingExecutor) HttpRequest(context.Context, *cliproxyauth.Au
return nil, nil
}
func TestApplyRequestThinkingUsesExactClaudeModeForSummaryOnlyRequest(t *testing.T) {
manager := cliproxyauth.NewManager(nil, nil, nil)
manager.SetConfig(&internalconfig.Config{
SDKConfig: internalconfig.SDKConfig{ForceModelPrefix: true},
ClaudeKey: []internalconfig.ClaudeKey{{
APIKey: "summary-selected-key",
Prefix: "summary-tenant",
Models: []internalconfig.ClaudeModel{{
Name: "summary-shared-upstream",
Alias: "summary-public-model",
Thinking: &registry.ThinkingSupport{
Min: 1024,
Max: 16000,
},
}},
}},
})
executor := &configuredThinkingExecutor{translateRequest: true}
manager.RegisterExecutor(executor)
auth := &cliproxyauth.Auth{
ID: "summary-selected-auth",
Provider: "claude",
Prefix: "summary-tenant",
Attributes: map[string]string{
cliproxyauth.AttributeAuthKind: cliproxyauth.AuthKindAPIKey,
cliproxyauth.AttributeAPIKey: "summary-selected-key",
cliproxyauth.AttributeSource: "config:claude[0]",
},
}
modelRegistry := registry.GetGlobalRegistry()
modelRegistry.RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{
ID: "summary-tenant/summary-public-model", Type: "claude",
}})
modelRegistry.RegisterClient("summary-unrelated-auth", auth.Provider, []*registry.ModelInfo{{
ID: "summary-shared-upstream", Type: "claude",
Thinking: &registry.ThinkingSupport{Levels: []string{"high"}},
}})
t.Cleanup(func() {
modelRegistry.UnregisterClient(auth.ID)
modelRegistry.UnregisterClient("summary-unrelated-auth")
})
if registered, errRegister := manager.Register(t.Context(), auth); errRegister != nil {
t.Fatalf("Register() error = %v", errRegister)
} else if registered == nil {
t.Fatal("Register() returned nil auth")
}
original := []byte(`{"model":"summary-tenant/summary-public-model","reasoning":{"summary":"auto"},"input":"hi"}`)
response, errExecute := manager.Execute(t.Context(), []string{"claude"}, cliproxyexecutor.Request{
Model: "summary-tenant/summary-public-model",
Payload: original,
Format: sdktranslator.FormatOpenAIResponse,
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatOpenAIResponse,
OriginalRequest: original,
})
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if got := gjson.GetBytes(executor.translatedBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("pre-executor thinking.type = %q, want global adaptive trigger; body=%s", got, executor.translatedBody)
}
if got := gjson.GetBytes(response.Payload, "thinking.type").String(); got != "enabled" {
t.Fatalf("thinking.type = %q, want exact manual mode; body=%s", got, response.Payload)
}
if got := gjson.GetBytes(response.Payload, "thinking.budget_tokens").Int(); got != 1024 {
t.Fatalf("thinking.budget_tokens = %d, want exact minimum 1024; body=%s", got, response.Payload)
}
if got := gjson.GetBytes(response.Payload, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, response.Payload)
}
if gjson.GetBytes(response.Payload, "output_config.effort").Exists() {
t.Fatalf("manual thinking retained adaptive effort: %s", response.Payload)
}
}
func TestApplyRequestThinkingUsesSelectedPrefixedAPIKeyModel(t *testing.T) {
manager := cliproxyauth.NewManager(nil, nil, nil)
manager.SetConfig(&internalconfig.Config{

View File

@@ -284,6 +284,17 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
"provider": providerFormat,
"model": modelInfo.ID,
}).Debug("thinking: no config found, passthrough |")
if modelInfoResolved && providerFormat == "claude" && fromFormat != providerFormat && ExtractSummaryConfig(sourceBody, fromFormat).Mode == SummaryEnabled {
// Registry translation can only see aggregate model capabilities. For a
// cross-protocol summary-only request it may have activated adaptive
// thinking solely to make display valid. The selected API-key model is
// authoritative at execution time, so discard that inferred activation
// when the exact model supports only manual extended thinking. Use the
// source intent here even if a target normalizer removed display; in that
// case the inferred amount must disappear with it. Explicit native Claude
// thinking never reaches this cross-protocol branch.
body = stripInferredClaudeSummaryActivation(body, modelInfo)
}
return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
}
if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) {

View File

@@ -114,6 +114,26 @@ func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t *
}
}
func TestApplyThinkingWithModelInfoAndSummaryDropsInferredClaudeModeWhenSummaryRemoved(t *testing.T) {
modelInfo := &registry.ModelInfo{
ID: "private-manual-claude",
Type: "claude",
Thinking: &registry.ThinkingSupport{Min: 1024, Max: 16000},
}
out, err := thinking.ApplyThinkingWithModelInfoAndSummary(
[]byte(`{"model":"private-manual-claude","max_tokens":32000,"thinking":{"type":"adaptive"}}`),
[]byte(`{"reasoning":{"summary":"auto"}}`),
"private-manual-claude", "openai-response", "claude", "claude", modelInfo,
thinking.SummaryConfig{},
)
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", err)
}
if gjson.GetBytes(out, "thinking").Exists() {
t.Fatalf("removed summary retained globally inferred adaptive thinking: %s", out)
}
}
func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *testing.T) {
modelInfo := &registry.ModelInfo{
ID: "private-claude",

View File

@@ -441,6 +441,34 @@ func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
}
}
// stripInferredClaudeSummaryActivation removes a globally inferred adaptive
// mode when the selected API-key model supports only manual extended thinking.
// The exact model-aware summary pass can then activate enabled thinking with a
// valid budget, or leave thinking absent when max_tokens cannot accommodate it.
func stripInferredClaudeSummaryActivation(body []byte, modelInfo *registry.ModelInfo) []byte {
if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) > 0 || modelInfo.Thinking.Min <= 0 {
return body
}
if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()), "adaptive") {
return body
}
for _, path := range []string{
"thinking.type",
"thinking.budget_tokens",
"thinking.display",
"output_config.effort",
} {
body, _ = sjson.DeleteBytes(body, path)
}
for _, path := range []string{"thinking", "output_config"} {
if object := gjson.GetBytes(body, path); object.Exists() && object.IsObject() && len(object.Map()) == 0 {
body, _ = sjson.DeleteBytes(body, path)
}
}
return body
}
func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte {
modelInfo := resolvedModelInfo
if modelInfo == nil {

View File

@@ -57,8 +57,6 @@ func (r *Registry) SetPluginHooks(hooks PluginHooks) {
// "model" field is still updated to match the resolved model name so that
// client-side prefixes (e.g. "copilot/gpt-5-mini") are not leaked upstream.
func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {
summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String())
r.mu.RLock()
var fn RequestTransform
if byTarget, ok := r.requests[from]; ok {
@@ -69,6 +67,7 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt
body := rawJSON
if fn != nil {
summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String())
body = fn(model, body, stream)
body = thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig)
if hooks != nil {
@@ -93,8 +92,10 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt
}
// Plugin request normalizers canonicalize the source before a plugin request
// translator gets a chance to handle a missing native route.
// translator gets a chance to handle a missing native route. Extract summary
// intent from that normalized source so a normalizer can remove or rewrite it.
body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream)
summaryConfig := thinking.ExtractSummaryConfig(body, from.String())
if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok {
body = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig)
}

View File

@@ -178,6 +178,59 @@ func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing
}
}
func TestRegistryTranslateRequestPluginNormalizerOwnsSourceSummaryIntent(t *testing.T) {
tests := []struct {
name string
normalize func([]byte) []byte
wantExists bool
want bool
}{
{
name: "removed summary remains absent",
normalize: func(body []byte) []byte {
out, _ := sjson.DeleteBytes(body, "reasoning.summary")
return out
},
},
{
name: "disabled summary replaces enabled intent",
normalize: func(body []byte) []byte {
out, _ := sjson.SetBytes(body, "reasoning.summary", nil)
return out
},
wantExists: true,
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registry := NewRegistry()
hooks := &fakePluginHooks{
normalizeRequest: test.normalize,
requestTranslateBody: []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`),
requestTranslateOK: true,
}
registry.SetPluginHooks(hooks)
out := registry.TranslateRequest(
FormatOpenAIResponse,
FormatGemini,
"gemini-3.6-flash",
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
false,
)
result := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts")
if result.Exists() != test.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out)
}
if test.wantExists && result.Bool() != test.want {
t.Fatalf("includeThoughts = %v, want %v; body=%s", result.Bool(), test.want, out)
}
})
}
}
func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) {
registry := NewRegistry()
registry.Register(FormatOpenAIResponse, FormatGemini, func(_ string, _ []byte, _ bool) []byte {