diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -62,6 +62,7 @@ {name: "Chat effort enables Interactions summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, {name: "Responses concise summary maps to Interactions auto", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"concise"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, {name: "Native Claude summarized enables Gemini summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, body: `{"model":"claude-opus-5","thinking":{"type":"adaptive","display":"summarized"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Claude auto compatibility budget keeps Gemini summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, {name: "Native Gemini disabled omits Claude summary", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":false}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "omitted", wantExists: true}, {name: "Native Gemini absent summary leaves Claude display absent", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, {name: "Native Interactions auto enables Gemini summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, @@ -168,6 +169,43 @@ } if test.to == sdktranslator.FormatClaude && gjson.GetBytes(out, "thinking.type").String() == "disabled" && gjson.GetBytes(out, "thinking.display").Exists() { t.Fatalf("disabled Claude thinking retained display: %s", out) + } + }) + } +} + +func TestGeminiSummaryOnlyProducesValidClaudeThinking(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("gemini-summary-only-claude-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + tests := []struct { + name string + model string + wantType string + wantBudget int64 + }{ + {name: "adaptive model", model: "claude-sonnet-4-6-model", wantType: "adaptive"}, + {name: "manual model", model: "claude-budget-model", wantType: "enabled", wantBudget: 1024}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"` + test.model + `","generationConfig":{"thinkingConfig":{"includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`) + out := sdktranslator.TranslateRequest(sdktranslator.FormatGemini, sdktranslator.FormatClaude, test.model, body, false) + if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType { + t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } + budget := gjson.GetBytes(out, "thinking.budget_tokens") + if test.wantBudget > 0 { + if budget.Int() != test.wantBudget { + t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", budget.Int(), test.wantBudget, out) + } + } else if budget.Exists() { + t.Fatalf("adaptive model retained budget_tokens: %s", out) } }) } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -95,6 +95,14 @@ return config } } + if config, ok := firstSummaryBoolConfig(body, []string{ + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + "generation_config.thinkingConfig.include_thoughts", + "generation_config.thinkingConfig.includeThoughts", + }); ok { + return config + } } return SummaryConfig{} @@ -213,10 +221,14 @@ return true case "enabled": // This runs before ApplyThinking normalizes the request, so a missing - // budget_tokens is an unfinished body rather than inactive thinking. - // Only an explicit non-positive budget means thinking is off. + // budget_tokens is an unfinished body rather than inactive thinking. CPA + // also accepts -1 as its compatibility representation for auto thinking. budget := gjson.GetBytes(body, "thinking.budget_tokens") - return budget.Type != gjson.Number || budget.Int() > 0 + if budget.Type != gjson.Number { + return true + } + value := budget.Int() + return value == -1 || value > 0 default: return false } diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -44,11 +44,19 @@ // absent budget must not be read as inactive thinking. {name: "claude enabled display without budget is valid", format: "claude", body: `{"thinking":{"type":"enabled","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "claude enabled display with zero budget is invalid", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized"}}`, wantMode: SummaryUnspecified}, + {name: "claude auto compatibility budget summarized", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "claude auto compatibility budget omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"omitted"}}`, wantMode: SummaryDisabled}, {name: "gemini include true", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "gemini include false", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, {name: "antigravity include true", format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions auto", format: "interactions", body: `{"generation_config":{"thinking_summaries":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions none", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"}}`, wantMode: SummaryDisabled}, + {name: "interactions nested snake include false", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "interactions nested camel include true", format: "interactions", body: `{"generation_config":{"thinking_config":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions camel config snake include true", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"include_thoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions camel config camel include false", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "interactions enum wins over include alias", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none","thinking_config":{"include_thoughts":true}}}`, wantMode: SummaryDisabled}, + {name: "interactions string include alias is invalid", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":"false"}}}`, wantMode: SummaryUnspecified}, {name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified}, {name: "interactions boolean is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":true}}`, wantMode: SummaryUnspecified}, {name: "gemini string bool is invalid", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}}`, wantMode: SummaryUnspecified}, diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -70,25 +70,35 @@ body := rawJSON if fn != nil { body = fn(model, body, stream) - } else { - if model != "" && gjson.GetBytes(body, "model").String() != model { - if updated, err := sjson.SetBytes(body, "model", model); err != nil { - log.Warnf("translator: failed to normalize model in request fallback: %v", err) - } else { - body = updated - } + body = thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) + if hooks != nil { + // Request normalizers run after native translation and own the final + // provider payload, including any summary field they remove. + body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) } + return body } - if hooks != nil { - body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) - if fn == nil { - if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok { - body = translated - } + if model != "" && gjson.GetBytes(body, "model").String() != model { + if updated, err := sjson.SetBytes(body, "model", model); err != nil { + log.Warnf("translator: failed to normalize model in request fallback: %v", err) + } else { + body = updated } } - return thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) + if hooks == nil { + // No translation occurred. Preserve the documented fallback shape instead + // of mixing target-protocol summary fields into the source payload. + return body + } + + // Plugin request normalizers canonicalize the source before a plugin request + // translator gets a chance to handle a missing native route. + body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) + if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok { + body = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig) + } + return body } // HasRequestTransformer indicates whether a request translator exists. diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -1,9 +1,11 @@ package translator import ( + "bytes" "testing" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { @@ -128,5 +130,76 @@ out := registry.TranslateRequest(FormatClaude, FormatClaude, "claude-opus-5", body, true) if gjson.GetBytes(out, "thinking.display").Exists() { t.Fatalf("native Claude request without display gained one: %s", out) + } +} + +func TestRegistryTranslateRequestDoesNotMixSummaryIntoFallback(t *testing.T) { + registry := NewRegistry() + body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`) + out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false) + if !bytes.Equal(out, body) { + t.Fatalf("missing translator changed fallback body: got %s, want %s", out, body) + } + if gjson.GetBytes(out, "generationConfig").Exists() { + t.Fatalf("missing translator mixed Gemini fields into Responses body: %s", out) + } +} + +func TestRegistryTranslateRequestPluginMissDoesNotMixSummary(t *testing.T) { + registry := NewRegistry() + hooks := &fakePluginHooks{requestTranslateOK: false} + registry.SetPluginHooks(hooks) + body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`) + out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false) + if !bytes.Equal(out, body) { + t.Fatalf("plugin translation miss changed fallback body: got %s, want %s", out, body) + } + if gjson.GetBytes(out, "generationConfig").Exists() { + t.Fatalf("plugin translation miss mixed Gemini fields into Responses body: %s", out) + } +} + +func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing.T) { + registry := NewRegistry() + hooks := &fakePluginHooks{ + 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, + ) + if !gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("plugin-translated request lost canonical summary: %s", out) + } +} + +func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatGemini, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`) + }, ResponseTransform{}) + hooks := &fakePluginHooks{normalizeRequest: func(body []byte) []byte { + if !gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("normalizer did not receive canonical enabled summary: %s", body) + } + out, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.includeThoughts") + return out + }} + registry.SetPluginHooks(hooks) + + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatGemini, + "gemini-3.6-flash", + []byte(`{"reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`), + false, + ) + if gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Exists() { + t.Fatalf("summary post-processing overrode request normalizer: %s", out) } } diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -17,7 +17,27 @@ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" ) + +func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.T) { + executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) + req := cliproxyexecutor.Request{ + Model: "gemini-3.6-flash", + Payload: []byte(`{"model":"gemini-3.6-flash","input":"hi"}`), + } + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + OriginalRequest: []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`), + } + payload, _, err := executor.translateRequest(context.Background(), req, opts, false) + if err != nil { + t.Fatalf("translateRequest() error = %v", err) + } + if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("original request summary intent was lost: %s", payload) + } +} func TestAIStudioExecutorExecuteStartsTTFTBeforeRelayWait(t *testing.T) { const authID = "aistudio-ttft-auth" diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -217,10 +217,6 @@ out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget) } } - } else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.SetBytes(out, "thinking.type", "enabled") - } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.SetBytes(out, "thinking.type", "enabled") } } }