mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(thinking): respect final summary authority
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user