diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go index c1e0b3710..826c82e9d 100644 --- a/internal/runtime/executor/helps/model_capabilities_test.go +++ b/internal/runtime/executor/helps/model_capabilities_test.go @@ -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: ®istry.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: ®istry.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{ diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index e9e3d34d0..92e6161cd 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -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) { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index b056139e9..9c48c36d9 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -114,6 +114,26 @@ func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t * } } +func TestApplyThinkingWithModelInfoAndSummaryDropsInferredClaudeModeWhenSummaryRemoved(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-manual-claude", + Type: "claude", + Thinking: ®istry.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 := ®istry.ModelInfo{ ID: "private-claude", diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 72977ab44..34ae9010f 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -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 { diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 830d03554..6e9f0eed7 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -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) } diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go index 1b77b951f..16b03216d 100644 --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -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 {