From b3046d29b9859c22c797b86cc99c52ac6ca1a2fc Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 21:53:30 +0800 Subject: [PATCH 1/9] feat(thinking): preserve cross-protocol summary visibility --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/antigravity_executor_execute.go | 4 +- .../executor/antigravity_executor_stream.go | 2 +- .../executor/antigravity_executor_tokens.go | 2 +- .../executor/claude_executor_execute.go | 3 - .../executor/claude_executor_request.go | 21 - .../executor/claude_executor_stream.go | 3 - .../runtime/executor/claude_executor_test.go | 39 -- .../runtime/executor/codex_openai_images.go | 2 +- .../runtime/executor/gemini_executor_test.go | 4 +- .../executor/helps/model_capabilities.go | 11 +- internal/runtime/executor/helps/thinking.go | 12 + internal/runtime/executor/kimi_executor.go | 4 +- internal/thinking/apply.go | 51 +- .../thinking/apply_configured_api_key_test.go | 33 ++ .../thinking/provider/antigravity/apply.go | 77 ++- internal/thinking/provider/claude/apply.go | 4 + internal/thinking/provider/gemini/apply.go | 77 +-- .../thinking/provider/interactions/apply.go | 67 ++- internal/thinking/strip.go | 4 +- internal/thinking/summary.go | 456 ++++++++++++++++++ internal/thinking/summary_test.go | 215 +++++++++ .../claude/antigravity_claude_request.go | 2 - .../claude/antigravity_claude_request_test.go | 4 +- .../interactions_antigravity_request.go | 19 +- .../antigravity_openai_request.go | 64 ++- .../antigravity_openai_request_test.go | 61 ++- .../codex/claude/codex_claude_request.go | 4 +- .../codex/gemini/codex_gemini_request.go | 4 +- .../interactions_codex_request.go | 24 +- .../chat-completions/codex_openai_request.go | 4 +- .../gemini/claude/gemini_claude_request.go | 2 - .../interactions_gemini_common.go | 19 +- .../chat-completions/gemini_openai_request.go | 2 - .../gemini_openai-responses_request.go | 2 - sdk/translator/registry.go | 5 +- sdk/translator/registry_summary_test.go | 127 +++++ test/summary_intent_translation_test.go | 226 +++++++++ test/thinking_conversion_test.go | 373 ++++++++++++-- 39 files changed, 1678 insertions(+), 357 deletions(-) create mode 100644 internal/runtime/executor/helps/thinking.go create mode 100644 internal/thinking/summary.go create mode 100644 internal/thinking/summary_test.go create mode 100644 sdk/translator/registry_summary_test.go create mode 100644 test/summary_intent_translation_test.go diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index ba8b006a..d2e78eca 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe 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 := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 471c9dc3..77bce648 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -68,7 +68,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -290,7 +290,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index b90fc84f..d0aa0725 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -63,7 +63,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 98d1d561..45867ee8 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -50,7 +50,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 8f84ec6e..0a57fad1 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -67,9 +67,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = normalizeClaudeSamplingForUpstream(body) - // Claude OAuth (and this executor's redact-thinking beta) returns signature-only - // thinking blocks unless display is set to "summarized". - body = ensureClaudeThinkingDisplay(body) // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) if countCacheControls(body) == 0 { diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 86874440..fdded306 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -78,27 +78,6 @@ func normalizeClaudeSamplingForUpstream(body []byte) []byte { return body } -// ensureClaudeThinkingDisplay defaults thinking.display to "summarized" when thinking -// is active and the client did not set display. Without this, Claude backends that -// enable redact-thinking return signature-only thinking blocks (empty thinking text). -// Explicit client values such as "omitted" are preserved. -func ensureClaudeThinkingDisplay(body []byte) []byte { - thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) - switch thinkingType { - case "enabled", "adaptive", "auto": - default: - return body - } - if display := strings.TrimSpace(gjson.GetBytes(body, "thinking.display").String()); display != "" { - return body - } - out, err := sjson.SetBytes(body, "thinking.display", "summarized") - if err != nil { - return body - } - return out -} - type compositeReadCloser struct { io.Reader closers []func() error diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 9167e056..83dc7cfb 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -67,9 +67,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = normalizeClaudeSamplingForUpstream(body) - // Claude OAuth (and this executor's redact-thinking beta) returns signature-only - // thinking blocks unless display is set to "summarized". - body = ensureClaudeThinkingDisplay(body) // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) if countCacheControls(body) == 0 { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 3e2946f7..45831b18 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -3110,45 +3110,6 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing. } } -func TestEnsureClaudeThinkingDisplay_SetsSummarizedWhenMissing(t *testing.T) { - payload := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { - t.Fatalf("thinking.display = %q, want summarized", got) - } - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive", got) - } -} - -func TestEnsureClaudeThinkingDisplay_PreservesExplicitValue(t *testing.T) { - payload := []byte(`{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" { - t.Fatalf("thinking.display = %q, want omitted", got) - } -} - -func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingDisabled(t *testing.T) { - payload := []byte(`{"thinking":{"type":"disabled"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if gjson.GetBytes(out, "thinking.display").Exists() { - t.Fatalf("thinking.display should not be set when thinking is disabled: %s", out) - } -} - -func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) { - payload := []byte(`{"messages":[{"role":"user","content":"hi"}]}`) - out := ensureClaudeThinkingDisplay(payload) - - if gjson.GetBytes(out, "thinking").Exists() { - t.Fatalf("thinking should remain absent: %s", out) - } -} - func TestPrependToFirstUserMessage_KeepsToolResultBlocksFirst(t *testing.T) { // A conversation that opens on an assistant tool_use makes the first user // message a tool_result carrier. Anthropic requires those blocks to stay at diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 6a514a6a..3251489e 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -674,7 +674,7 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe mainModel = codexOpenAIImagesMainModel } var errThinking error - out, errThinking = thinking.ApplyThinking(out, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) + out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) if errThinking != nil { return nil, errThinking } diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index 6a22e4e7..4b2a720f 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -671,8 +671,8 @@ func TestGeminiExecutorNativeInteractionsAppliesThinkingSuffix(t *testing.T) { if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" { t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody)) } - if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" { - t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody)) + if gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").Exists() { + t.Fatalf("thinking_summaries should be absent without explicit summary intent. Body: %s", string(upstreamBody)) } } diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index 8021561c..8bf6723d 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -9,12 +9,13 @@ import ( // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound an exact configured API-key model definition to this attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { + sourceBody := opts.OriginalRequest + if len(sourceBody) == 0 { + sourceBody = req.Payload + } if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { - sourceBody := opts.OriginalRequest - if len(sourceBody) == 0 { - sourceBody = req.Payload - } return thinking.ApplyThinkingWithModelInfo(body, sourceBody, req.Model, fromFormat, toFormat, provider, modelInfo) } - return thinking.ApplyThinking(body, req.Model, fromFormat, toFormat, provider) + summaryConfig := thinking.ExtractSummaryConfig(sourceBody, fromFormat) + return thinking.ApplyThinkingWithSummary(body, req.Model, fromFormat, toFormat, provider, summaryConfig) } diff --git a/internal/runtime/executor/helps/thinking.go b/internal/runtime/executor/helps/thinking.go new file mode 100644 index 00000000..49f3155c --- /dev/null +++ b/internal/runtime/executor/helps/thinking.go @@ -0,0 +1,12 @@ +package helps + +import "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + +// ApplyThinkingWithSourcePayload preserves summary visibility from the original +// client payload while applying thinking configuration to its translated target +// payload. A target representation alone can lose an explicit disabled summary +// before a model suffix changes Claude thinking from disabled to adaptive. +func ApplyThinkingWithSourcePayload(body, sourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { + summary := thinking.ExtractSummaryConfig(sourcePayload, fromFormat) + return thinking.ApplyThinkingWithSummary(body, model, fromFormat, toFormat, providerKey, summary) +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index ec270705..d3c88145 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -113,7 +113,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -222,7 +222,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 1d25de7e..c19369c7 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -162,16 +162,31 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // // Without suffix - uses body config // result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini", "gemini") func ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) { - return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false) + summaryConfig := ExtractSummaryConfig(body, toFormat) + return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig) +} + +// ApplyThinkingWithSummary applies canonical thinking effort while preserving +// summary visibility extracted from the original source request. Callers that +// translate before applying thinking must pass the source config explicitly: +// a target Claude body can temporarily lack display while disabled thinking is +// being rewritten by a model suffix. +func ApplyThinkingWithSummary(body []byte, model string, fromFormat string, toFormat string, providerKey string, summaryConfig SummaryConfig) ([]byte, error) { + return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig) } // ApplyThinkingWithModelInfo applies thinking with the exact configured model -// definition selected for an API-key execution attempt. +// definition selected for an API-key execution attempt while preserving summary +// visibility from the original source body. func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo) ([]byte, error) { - return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true) + summaryConfig := ExtractSummaryConfig(sourceBody, fromFormat) + if len(sourceBody) == 0 { + summaryConfig = ExtractSummaryConfig(body, toFormat) + } + return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig) } -func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool) ([]byte, error) { +func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool, summaryConfig SummaryConfig) ([]byte, error) { providerFormat := strings.ToLower(strings.TrimSpace(toFormat)) if modelInfoResolved && providerFormat == "openai-response" { providerFormat = "codex" @@ -184,6 +199,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if fromFormat == "" { fromFormat = providerFormat } + // Summary visibility is orthogonal to thinking effort. Keep the original + // source intent before a suffix-specific applier rewrites provider fields, + // then restore it after the canonical effort has been applied. // 1. Route check: Get provider applier applier := GetProviderApplier(providerFormat) if applier == nil { @@ -207,11 +225,11 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF // Unknown models are treated as user-defined so thinking config can still be applied. // The upstream service is responsible for validating the configuration. if IsUserDefinedModel(modelInfo) { - return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult) + return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult, summaryConfig) } if modelInfo.Thinking == nil { config := extractThinkingConfig(body, providerFormat) - if hasThinkingConfig(config) { + if hasThinkingConfig(config) || summaryConfig.Mode != SummaryUnspecified { log.WithFields(log.Fields{ "model": baseModel, "provider": providerFormat, @@ -259,7 +277,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") - return body, nil + return applySummaryConfigForModel(body, providerFormat, baseModel, modelInfo, summaryConfig), nil } if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { config.Level = mapConfiguredHighIntent(config.Level, modelInfo) @@ -296,8 +314,13 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "level": validated.Level, }).Debug("thinking: processed config to apply |") - // 6. Apply configuration using provider-specific applier - return applier.Apply(body, *validated, modelInfo) + // 6. Apply configuration using provider-specific applier, then restore the + // target summary intent that was explicit before suffix processing. + applied, err := applier.Apply(body, *validated, modelInfo) + if err != nil { + return applied, err + } + return applySummaryConfigForModel(applied, providerFormat, baseModel, modelInfo, summaryConfig), nil } func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { @@ -386,7 +409,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig { // applyUserDefinedModel applies thinking configuration for user-defined models // without ThinkingSupport validation. -func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult) ([]byte, error) { +func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { // Get model ID for logging modelID := "" if modelInfo != nil { @@ -427,7 +450,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "model": modelID, "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (no config) |") - return body, nil + return applySummaryConfigForModel(body, toFormat, modelID, modelInfo, summaryConfig), nil } applier := GetProviderApplier(toFormat) @@ -447,7 +470,11 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "budget": config.Budget, "level": config.Level, }).Debug("thinking: processed config to apply |") - return applier.Apply(body, config, modelInfo) + applied, err := applier.Apply(body, config, modelInfo) + if err != nil { + return applied, err + } + return applySummaryConfigForModel(applied, toFormat, modelID, modelInfo, summaryConfig), nil } func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index 81e908fb..5aa3ce9d 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -92,6 +92,39 @@ func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) } } +func TestApplyThinkingWithModelInfoAppliesSummaryOnlyClaudeVisibility(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-claude", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + } + for _, test := range []struct { + name string + source string + display string + }{ + {name: "enabled", source: `{"reasoning":{"summary":"auto"}}`, display: "summarized"}, + {name: "disabled", source: `{"reasoning":{"summary":null}}`, display: "omitted"}, + } { + t.Run(test.name, func(t *testing.T) { + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(test.source), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != test.display { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.display, out) + } + }) + } +} + func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "claude-upstream", diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index cb0659f1..968ee09d 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -98,19 +98,19 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig") - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) if config.Level != "" { result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) } - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } // Only handle ModeLevel - budget conversion should be done by upper layer @@ -120,17 +120,7 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level) - - // Respect user's explicit includeThoughts setting from original body; default to true if not set - // Support both camelCase and snake_case variants - includeThoughts := true - if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) { @@ -138,7 +128,8 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") budget := config.Budget @@ -146,46 +137,32 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, // Apply Claude-specific constraints first to get the final budget value if isClaude && modelInfo != nil { budget, result = a.normalizeClaudeBudget(budget, result, modelInfo) - // Check if budget was removed entirely + // Check if the thinking amount was removed entirely. Summary visibility is + // independent, so retain an explicit includeThoughts control if present. if budget == -2 { - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } } - // For ModeNone, always set includeThoughts to false regardless of user setting. - // This ensures that when user requests budget=0 (disable thinking output), - // the includeThoughts is correctly set to false even if budget is clamped to min. - if config.Mode == thinking.ModeNone { - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) - return result, nil - } - - // Determine includeThoughts: respect user's explicit setting from original body if provided - // Support both camelCase and snake_case variants - var includeThoughts bool - var userSetIncludeThoughts bool - if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } - - if !userSetIncludeThoughts { - // No explicit setting, use default logic based on mode - switch config.Mode { - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + return applyAntigravityIncludeThoughts(result, body), nil +} + +func applyAntigravityIncludeThoughts(result, original []byte) []byte { + for _, path := range []string{ + "request.generationConfig.thinkingConfig.includeThoughts", + "request.generationConfig.thinkingConfig.include_thoughts", + } { + switch value := gjson.GetBytes(original, path); value.Type { + case gjson.True: + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true) + return result + case gjson.False: + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) + return result } } - - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return result } // normalizeClaudeBudget applies Claude-specific constraints to thinking budget. diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index 140a8135..97f02849 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -87,6 +87,8 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + // Summary display only applies to an active thinking block. + result, _ = sjson.DeleteBytes(result, "thinking.display") result, _ = sjson.DeleteBytes(result, "output_config.effort") if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { result, _ = sjson.DeleteBytes(result, "output_config") @@ -231,6 +233,8 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + // Summary display only applies to an active thinking block. + result, _ = sjson.DeleteBytes(result, "thinking.display") result, _ = sjson.DeleteBytes(result, "output_config.effort") if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { result, _ = sjson.DeleteBytes(result, "output_config") diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index 92a8d7ec..c332e9ef 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -114,27 +114,27 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ( func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { // ModeNone semantics: - // - ModeNone + Budget=0: remove thinkingConfig to disable thinking - // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) - // ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0. + // - ModeNone + Budget=0: remove the thinking amount configuration. + // - ModeNone + Budget>0: clamp to the model's lowest supported amount. + // Summary visibility remains independent and is restored only when explicitly set. // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig") - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) if config.Level != "" { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) } - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } // Only handle ModeLevel - budget conversion should be done by upper layer @@ -144,17 +144,7 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level) - - // Respect user's explicit includeThoughts setting from original body; default to true if not set - // Support both camelCase and snake_case variants - includeThoughts := true - if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { @@ -162,43 +152,28 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") budget := config.Budget + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) + return applyGeminiIncludeThoughts(result, body), nil +} - // For ModeNone, always set includeThoughts to false regardless of user setting. - // This ensures that when user requests budget=0 (disable thinking output), - // the includeThoughts is correctly set to false even if budget is clamped to min. - if config.Mode == thinking.ModeNone { - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) - return result, nil - } - - // Determine includeThoughts: respect user's explicit setting from original body if provided - // Support both camelCase and snake_case variants - var includeThoughts bool - var userSetIncludeThoughts bool - if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } - - if !userSetIncludeThoughts { - // No explicit setting, use default logic based on mode - switch config.Mode { - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 +func applyGeminiIncludeThoughts(result, original []byte) []byte { + for _, path := range []string{ + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + } { + switch value := gjson.GetBytes(original, path); value.Type { + case gjson.True: + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true) + return result + case gjson.False: + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) + return result } } - - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return result } diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go index 2951b511..c644f5ad 100644 --- a/internal/thinking/provider/interactions/apply.go +++ b/internal/thinking/provider/interactions/apply.go @@ -34,11 +34,11 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * result := stripInteractionsThinkingFields(body) switch config.Mode { case thinking.ModeLevel: - return applyInteractionsLevel(result, body, string(config.Level), modelInfo, "auto"), nil + return applyInteractionsLevel(result, body, string(config.Level), modelInfo), nil case thinking.ModeBudget: - return applyInteractionsBudget(result, body, config.Budget, modelInfo, "auto"), nil + return applyInteractionsBudget(result, body, config.Budget, modelInfo), nil case thinking.ModeAuto: - return setInteractionsThinkingSummaries(result, body, "auto"), nil + return setInteractionsThinkingSummaries(result, body), nil case thinking.ModeNone: return applyInteractionsNone(result, body, config, modelInfo), nil default: @@ -46,38 +46,38 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * } } -func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo, summariesFallback string) []byte { +func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo) []byte { level, ok := thinking.ConvertBudgetToLevel(budget) if !ok { - return result + return setInteractionsThinkingSummaries(result, original) } switch level { - case string(thinking.LevelNone): - return setInteractionsThinkingSummaries(result, original, "none") - case string(thinking.LevelAuto): - return setInteractionsThinkingSummaries(result, original, "auto") + case string(thinking.LevelNone), string(thinking.LevelAuto): + // Thinking amount and summary visibility are independent. Interactions has + // no wire-level "none" thinking level, so preserve only explicit summary + // intent and otherwise let the target model use its documented default. + return setInteractionsThinkingSummaries(result, original) default: - return applyInteractionsLevel(result, original, level, modelInfo, summariesFallback) + return applyInteractionsLevel(result, original, level, modelInfo) } } -func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo, summariesFallback string) []byte { +func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo) []byte { level = normalizeInteractionsLevel(level, modelInfo) - if level == "" { - return result + if level != "" { + result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level) } - result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level) - return setInteractionsThinkingSummaries(result, original, summariesFallback) + return setInteractionsThinkingSummaries(result, original) } func applyInteractionsNone(result, original []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) []byte { if config.Level != "" { - result = applyInteractionsLevel(result, original, string(config.Level), modelInfo, "none") - } else if config.Budget > 0 { - result = applyInteractionsBudget(result, original, config.Budget, modelInfo, "none") + return applyInteractionsLevel(result, original, string(config.Level), modelInfo) } - result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", "none") - return result + if config.Budget > 0 { + return applyInteractionsBudget(result, original, config.Budget, modelInfo) + } + return setInteractionsThinkingSummaries(result, original) } func stripInteractionsThinkingFields(body []byte) []byte { @@ -104,7 +104,7 @@ func stripInteractionsThinkingFields(body []byte) []byte { return result } -func setInteractionsThinkingSummaries(result, original []byte, fallback string) []byte { +func setInteractionsThinkingSummaries(result, original []byte) []byte { if value, okValue := originalInteractionsThinkingSummaries(original); okValue { result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) return result @@ -112,16 +112,9 @@ func setInteractionsThinkingSummaries(result, original []byte, fallback string) if includeThoughts, okValue := originalInteractionsIncludeThoughts(original); okValue { value := "none" if includeThoughts { - value = fallback - if value == "" { - value = "auto" - } + value = "auto" } result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) - return result - } - if fallback != "" { - result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", fallback) } return result } @@ -132,8 +125,12 @@ func originalInteractionsThinkingSummaries(body []byte) (string, bool) { "generation_config.thinkingSummaries", } { value := gjson.GetBytes(body, path) - if value.Exists() && value.Type == gjson.String { - return strings.ToLower(strings.TrimSpace(value.String())), true + if value.Type != gjson.String { + continue + } + switch normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized { + case "auto", "none": + return normalized, true } } return "", false @@ -146,9 +143,11 @@ func originalInteractionsIncludeThoughts(body []byte) (bool, bool) { "generation_config.thinkingConfig.include_thoughts", "generation_config.thinkingConfig.includeThoughts", } { - value := gjson.GetBytes(body, path) - if value.Exists() { - return value.Bool(), true + switch value := gjson.GetBytes(body, path); value.Type { + case gjson.True: + return true, true + case gjson.False: + return false, true } } return false, false diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index f514a7bd..f60b7ff2 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -47,14 +47,14 @@ func StripThinkingConfig(body []byte, provider string) []byte { "generation_config.thinkingConfig", } case "openai": - paths = []string{"reasoning_effort"} + paths = []string{"reasoning_effort", "reasoning"} case "kimi": paths = []string{ "reasoning_effort", "thinking", } case "codex", "xai": - paths = []string{"reasoning.effort"} + paths = []string{"reasoning"} default: return body } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go new file mode 100644 index 00000000..4a19dae1 --- /dev/null +++ b/internal/thinking/summary.go @@ -0,0 +1,456 @@ +package thinking + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// SummaryMode represents whether the client explicitly requested reasoning summaries. +type SummaryMode int + +const ( + SummaryUnspecified SummaryMode = iota + SummaryDisabled + SummaryEnabled +) + +// SummaryConfig is the provider-neutral reasoning-summary visibility intent. +// Detail preserves protocols that distinguish auto, concise, and detailed summaries. +type SummaryConfig struct { + Mode SummaryMode + Detail string +} + +// ExtractSummaryConfig reads protocol-specific summary visibility intent. +// +// OpenAI Chat is the one protocol where effort implies summaries: chat +// completions has no summary field of its own, and clients that send +// reasoning_effort have always received reasoning summaries here, so treating a +// non-none effort as an explicit request preserves that contract. Every other +// protocol carries a dedicated summary field, so effort alone means nothing. +func ExtractSummaryConfig(body []byte, format string) SummaryConfig { + normalized := strings.ToLower(strings.TrimSpace(format)) + // Check the format first so unsupported targets skip whole-body validation. + if !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { + return SummaryConfig{} + } + + switch normalized { + case "openai": + if config, ok := extractOpenAIExplicitSummaryConfig(body); ok { + return config + } + if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Type == gjson.String { + value := strings.ToLower(strings.TrimSpace(effort.String())) + if value == "" { + return SummaryConfig{} + } + if value == "none" { + return SummaryConfig{Mode: SummaryDisabled} + } + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"} + } + case "openai-response", "codex": + if config, ok := responsesSummaryConfig(body, "reasoning.summary"); ok { + return config + } + if config, ok := responsesSummaryConfig(body, "reasoning.generate_summary"); ok { + return config + } + case "claude": + // Anthropic only accepts display alongside active adaptive/manual thinking. + if !claudeThinkingAcceptsDisplay(body) { + return SummaryConfig{} + } + if config, ok := claudeSummaryConfig(body, "thinking.display"); ok { + return config + } + case "gemini": + if config, ok := firstSummaryBoolConfig(body, []string{ + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + }); ok { + return config + } + case "antigravity": + if config, ok := firstSummaryBoolConfig(body, []string{ + "request.generationConfig.thinkingConfig.includeThoughts", + "request.generationConfig.thinkingConfig.include_thoughts", + "request.generationConfig.thinking_config.includeThoughts", + "request.generationConfig.thinking_config.include_thoughts", + }); ok { + return config + } + case "interactions": + for _, path := range []string{ + "generation_config.thinking_summaries", + "generation_config.thinkingSummaries", + } { + if config, ok := interactionsSummaryConfig(body, path); ok { + return config + } + } + } + + return SummaryConfig{} +} + +// ApplySummaryConfig writes canonical summary intent in the target protocol. +func ApplySummaryConfig(body []byte, format string, config SummaryConfig) []byte { + return ApplySummaryConfigForModel(body, format, "", config) +} + +// ApplySummaryConfigForModel writes canonical summary intent in the target +// protocol and uses target model capabilities when a valid target request must +// activate thinking before it can request summaries. +func ApplySummaryConfigForModel(body []byte, format, model string, config SummaryConfig) []byte { + return applySummaryConfigForModel(body, format, model, nil, config) +} + +// applySummaryConfigForModel uses the resolved model definition when execution +// selected a configured API-key model whose capability is not globally visible. +func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { + normalized := strings.ToLower(strings.TrimSpace(format)) + if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + + enabled := config.Mode == SummaryEnabled + switch normalized { + case "openai": + body = applyOpenAIChatSummaryConfig(body, model, enabled) + case "claude": + // Anthropic documents display as invalid with thinking.type=disabled and + // requires it alongside adaptive or enabled thinking. An explicit source + // visibility request is independent of thinking effort, so activate the + // target model's documented thinking mode before writing either + // summarized or omitted. Unspecified intent returns above and leaves the + // target's default untouched. + if !gjson.GetBytes(body, "thinking.type").Exists() { + body = enableClaudeThinkingForSummary(body, model, modelInfo) + } + if !claudeThinkingAcceptsDisplay(body) { + return body + } + value := "omitted" + if enabled { + value = "summarized" + } + body, _ = sjson.SetBytes(body, "thinking.display", value) + case "gemini": + body, _ = sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", enabled) + for _, path := range []string{ + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + } { + body, _ = sjson.DeleteBytes(body, path) + } + case "antigravity": + body, _ = sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", enabled) + for _, path := range []string{ + "request.generationConfig.thinkingConfig.include_thoughts", + "request.generationConfig.thinking_config.include_thoughts", + "request.generationConfig.thinking_config.includeThoughts", + } { + body, _ = sjson.DeleteBytes(body, path) + } + case "interactions": + // Google Interactions only accepts auto or none. OpenAI's concise and + // detailed selectors therefore collapse to the supported enabled value. + value := "none" + if enabled { + value = "auto" + } + body, _ = sjson.SetBytes(body, "generation_config.thinking_summaries", value) + body, _ = sjson.DeleteBytes(body, "generation_config.thinkingSummaries") + case "openai-response", "codex": + if enabled { + body, _ = sjson.SetBytes(body, "reasoning.summary", normalizedSummaryDetail(config.Detail)) + body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary") + break + } + // Omitting the field is the documented way to disable summaries; an + // explicit null is not accepted by every Responses-compatible backend. + body, _ = sjson.DeleteBytes(body, "reasoning.summary") + body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary") + if reasoning := gjson.GetBytes(body, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 { + body, _ = sjson.DeleteBytes(body, "reasoning") + } + } + return body +} + +// summaryFormatSupported reports whether a protocol carries summary visibility +// intent that this package can read or write. +func summaryFormatSupported(format string) bool { + switch format { + case "openai", "openai-response", "codex", "claude", "gemini", "antigravity", "interactions": + return true + default: + return false + } +} + +// claudeThinkingAcceptsDisplay reports whether the body carries an active +// thinking block that can hold a display field. +func claudeThinkingAcceptsDisplay(body []byte) bool { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) { + case "adaptive": + 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 := gjson.GetBytes(body, "thinking.budget_tokens") + return budget.Type != gjson.Number || budget.Int() > 0 + default: + return false + } +} + +// applyOpenAIChatSummaryConfig writes summary visibility intent for the Chat +// Completions protocol. +// +// Four dialects share this protocol and only OpenAI's is authoritative. OpenAI +// documents no reasoning-visibility field at all (Chat Completions never returns +// reasoning text) and rejects unknown body parameters, so reasoning_effort is the +// only field that is always safe to write here. OpenRouter's documented +// "reason but hide" bits (reasoning.exclude and its legacy include_reasoning +// alias) are updated only when the body already carries them, which is exactly +// when the upstream is known to understand them. +func applyOpenAIChatSummaryConfig(body []byte, model string, enabled bool) []byte { + if gjson.GetBytes(body, "reasoning").IsObject() { + body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled) + } + if gjson.GetBytes(body, "include_reasoning").IsBool() { + body, _ = sjson.SetBytes(body, "include_reasoning", enabled) + } + if !enabled { + // Chat has no portable way to keep reasoning while hiding its summary. + // reasoning_effort:"none" would disable reasoning instead of hiding it, + // and Google documents that it is not even honored on Gemini 2.5 Pro or + // 3 models, so leave the effort the client asked for untouched. + return body + } + effort := gjson.GetBytes(body, "reasoning_effort") + if effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" || strings.EqualFold(strings.TrimSpace(effort.String()), "none") { + body, _ = sjson.SetBytes(body, "reasoning_effort", openAIChatSummaryEffort(body, model)) + } + return body +} + +// openAIChatSummaryEffort picks an active reasoning effort that the target model +// documents. Chat exposes reasoning only while an effort is active, so a summary +// request has to select one when the client left it unset. +func openAIChatSummaryEffort(body []byte, model string) string { + baseModel := ParseSuffix(model).ModelName + if baseModel == "" { + baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName + } + modelInfo := registry.LookupModelInfo(baseModel, "openai") + if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 { + return "medium" + } + + levels := make([]string, 0, len(modelInfo.Thinking.Levels)) + for _, level := range modelInfo.Thinking.Levels { + normalized := strings.ToLower(strings.TrimSpace(level)) + if normalized == "" || normalized == "none" { + continue + } + if normalized == "medium" { + return "medium" + } + levels = append(levels, normalized) + } + if len(levels) == 0 { + return "medium" + } + return levels[len(levels)/2] +} + +func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) { + // Google's documented Chat Completions extension is the authoritative + // explicit visibility control when present, ahead of CPA compatibility + // aliases and Chat's reasoning_effort fallback. + for _, path := range []string{ + "extra_body.google.thinking_config.include_thoughts", + "extra_body.google.thinking_config.includeThoughts", + "extra_body.google.thinkingConfig.include_thoughts", + "extra_body.google.thinkingConfig.includeThoughts", + "extra_body.extra_body.google.thinking_config.include_thoughts", + "extra_body.extra_body.google.thinking_config.includeThoughts", + "google.thinking_config.include_thoughts", + "google.thinking_config.includeThoughts", + "thinking.includeThoughts", + "thinking.include_thoughts", + "reasoning.includeThoughts", + "reasoning.include_thoughts", + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + } { + if config, ok := summaryBoolConfig(body, path); ok { + return config, true + } + } + + for _, path := range []string{ + "reasoning.summary", + "reasoning.generate_summary", + } { + if config, ok := responsesSummaryConfig(body, path); ok { + return config, true + } + } + + // reasoning.exclude is OpenRouter's documented "reason but hide" bit, not an + // OpenAI wire field; include_reasoning is its documented legacy alias + // (include_reasoning: false is equivalent to reasoning: {exclude: true}). + // Only accept actual JSON booleans. + if exclude := gjson.GetBytes(body, "reasoning.exclude"); exclude.IsBool() { + if exclude.Bool() { + return SummaryConfig{Mode: SummaryDisabled}, true + } + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + if include := gjson.GetBytes(body, "include_reasoning"); include.IsBool() { + if include.Bool() { + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + return SummaryConfig{Mode: SummaryDisabled}, true + } + // OpenRouter's reasoning.enabled turns reasoning on "with no exclusions", so + // it also decides visibility when no dedicated bit was sent. + if enabled := gjson.GetBytes(body, "reasoning.enabled"); enabled.IsBool() { + if enabled.Bool() { + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + return SummaryConfig{Mode: SummaryDisabled}, true + } + return SummaryConfig{}, false +} + +func firstSummaryBoolConfig(body []byte, paths []string) (SummaryConfig, bool) { + for _, path := range paths { + if config, ok := summaryBoolConfig(body, path); ok { + return config, true + } + } + return SummaryConfig{}, false +} + +func summaryBoolConfig(body []byte, path string) (SummaryConfig, bool) { + switch value := gjson.GetBytes(body, path); value.Type { + case gjson.True: + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case gjson.False: + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func responsesSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Raw == "" { + return SummaryConfig{}, false + } + if value.Type == gjson.Null { + return SummaryConfig{Mode: SummaryDisabled}, true + } + if value.Type != gjson.String { + return SummaryConfig{}, false + } + + raw := strings.ToLower(strings.TrimSpace(value.String())) + switch raw { + case "auto", "concise", "detailed": + return SummaryConfig{Mode: SummaryEnabled, Detail: raw}, true + case "none": + // Compatibility with clients that expose a none enum; the OpenAI wire + // representation disables summaries by omitting the field. + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func claudeSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Type != gjson.String { + return SummaryConfig{}, false + } + switch strings.ToLower(strings.TrimSpace(value.String())) { + case "summarized": + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case "omitted": + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Type != gjson.String { + return SummaryConfig{}, false + } + switch strings.ToLower(strings.TrimSpace(value.String())) { + case "auto": + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case "none": + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte { + modelInfo := resolvedModelInfo + if modelInfo == nil { + baseModel := ParseSuffix(model).ModelName + if baseModel == "" { + baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName + } + modelInfo = registry.LookupModelInfo(baseModel, "claude") + } + if modelInfo == nil || modelInfo.Thinking == nil { + return body + } + + if len(modelInfo.Thinking.Levels) > 0 { + body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") + body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens") + return body + } + + budget := modelInfo.Thinking.Min + if budget <= 0 { + return body + } + if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) { + return body + } + body, _ = sjson.SetBytes(body, "thinking.type", "enabled") + body, _ = sjson.SetBytes(body, "thinking.budget_tokens", budget) + return body +} + +func normalizedSummaryDetail(detail string) string { + switch strings.ToLower(strings.TrimSpace(detail)) { + case "concise": + return "concise" + case "detailed": + return "detailed" + default: + return "auto" + } +} diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go new file mode 100644 index 00000000..6c9011f1 --- /dev/null +++ b/internal/thinking/summary_test.go @@ -0,0 +1,215 @@ +package thinking + +import ( + "bytes" + "testing" + + "github.com/tidwall/gjson" +) + +func TestExtractSummaryConfig(t *testing.T) { + tests := []struct { + name string + format string + body string + wantMode SummaryMode + wantDetail string + }{ + {name: "chat effort enables", format: "openai", body: `{"reasoning_effort":"high"}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat none disables", format: "openai", body: `{"reasoning_effort":"none"}`, wantMode: SummaryDisabled}, + {name: "chat missing unspecified", format: "openai", body: `{}`, wantMode: SummaryUnspecified}, + {name: "chat null effort unspecified", format: "openai", body: `{"reasoning_effort":null}`, wantMode: SummaryUnspecified}, + {name: "chat non-string effort unspecified", format: "openai", body: `{"reasoning_effort":17}`, wantMode: SummaryUnspecified}, + {name: "chat google extension false overrides effort", format: "openai", body: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, wantMode: SummaryDisabled}, + {name: "chat google extension true", format: "openai", body: `{"extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat exclude disables", format: "openai", body: `{"reasoning_effort":"high","reasoning":{"exclude":true}}`, wantMode: SummaryDisabled}, + {name: "chat exclude false enables", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat legacy include_reasoning false disables", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":false}`, wantMode: SummaryDisabled}, + {name: "chat legacy include_reasoning true enables", format: "openai", body: `{"include_reasoning":true}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat reasoning enabled false disables", format: "openai", body: `{"reasoning":{"enabled":false}}`, wantMode: SummaryDisabled}, + {name: "chat reasoning enabled true enables", format: "openai", body: `{"reasoning":{"enabled":true}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat exclude wins over include_reasoning", format: "openai", body: `{"reasoning":{"exclude":true},"include_reasoning":true}`, wantMode: SummaryDisabled}, + {name: "chat non-boolean include_reasoning unspecified", format: "openai", body: `{"include_reasoning":"false"}`, wantMode: SummaryUnspecified}, + {name: "responses effort alone unspecified", format: "openai-response", body: `{"reasoning":{"effort":"high"}}`, wantMode: SummaryUnspecified}, + {name: "responses summary auto", format: "openai-response", body: `{"reasoning":{"effort":"high","summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "responses summary concise", format: "openai-response", body: `{"reasoning":{"summary":"concise"}}`, wantMode: SummaryEnabled, wantDetail: "concise"}, + {name: "responses summary null", format: "openai-response", body: `{"reasoning":{"summary":null}}`, wantMode: SummaryDisabled}, + {name: "responses boolean summary invalid", format: "openai-response", body: `{"reasoning":{"summary":true}}`, wantMode: SummaryUnspecified}, + {name: "responses deprecated generate summary", format: "openai-response", body: `{"reasoning":{"generate_summary":"detailed"}}`, wantMode: SummaryEnabled, wantDetail: "detailed"}, + {name: "claude summarized", format: "claude", body: `{"thinking":{"type":"adaptive","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "claude omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`, wantMode: SummaryDisabled}, + {name: "claude display without type is invalid", format: "claude", body: `{"thinking":{"display":"summarized"}}`, wantMode: SummaryUnspecified}, + {name: "claude display with auto type is invalid", format: "claude", body: `{"thinking":{"type":"auto","display":"summarized"}}`, wantMode: SummaryUnspecified}, + // ApplySummaryConfig runs before ApplyThinking fills budget_tokens, so an + // 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: "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 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}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ExtractSummaryConfig([]byte(test.body), test.format) + if got.Mode != test.wantMode || got.Detail != test.wantDetail { + t.Fatalf("ExtractSummaryConfig() = %+v, want mode=%v detail=%q", got, test.wantMode, test.wantDetail) + } + }) + } +} + +func TestApplySummaryConfig(t *testing.T) { + tests := []struct { + name string + format string + body string + config SummaryConfig + path string + want string + }{ + {name: "chat enabled creates compatibility effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "medium"}, + {name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"}, + // Chat cannot express "reason but hide", so disabling must not fall back to + // reasoning_effort:"none", which would disable reasoning altogether. + {name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"}, + {name: "chat disabled sets openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning.exclude", want: "true"}, + {name: "chat enabled clears openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":true}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning.exclude", want: "false"}, + {name: "chat disabled updates legacy include_reasoning when present", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":true}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "include_reasoning", want: "false"}, + {name: "chat disabled invents no openrouter field", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning", want: ""}, + {name: "claude enabled", format: "claude", body: `{"thinking":{"type":"adaptive"}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "thinking.display", want: "summarized"}, + {name: "claude disabled", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "thinking.display", want: "omitted"}, + {name: "gemini enabled", format: "gemini", config: SummaryConfig{Mode: SummaryEnabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "true"}, + {name: "gemini disabled", format: "gemini", config: SummaryConfig{Mode: SummaryDisabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "false"}, + {name: "antigravity enabled", format: "antigravity", config: SummaryConfig{Mode: SummaryEnabled}, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true"}, + {name: "interactions detail collapses to auto", format: "interactions", config: SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}, path: "generation_config.thinking_summaries", want: "auto"}, + {name: "interactions disabled", format: "interactions", config: SummaryConfig{Mode: SummaryDisabled}, path: "generation_config.thinking_summaries", want: "none"}, + {name: "responses concise", format: "openai-response", config: SummaryConfig{Mode: SummaryEnabled, Detail: "concise"}, path: "reasoning.summary", want: "concise"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := test.body + if body == "" { + body = `{}` + } + out := ApplySummaryConfig([]byte(body), test.format, test.config) + if got := gjson.GetBytes(out, test.path).String(); got != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, got, test.want, out) + } + }) + } +} + +func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) { + tests := []struct { + format string + body string + canonical string + alias string + }{ + {format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`, canonical: "generationConfig.thinkingConfig.includeThoughts", alias: "generationConfig.thinkingConfig.include_thoughts"}, + {format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}}`, canonical: "request.generationConfig.thinkingConfig.includeThoughts", alias: "request.generationConfig.thinkingConfig.include_thoughts"}, + {format: "interactions", body: `{"generation_config":{"thinkingSummaries":"auto"}}`, canonical: "generation_config.thinking_summaries", alias: "generation_config.thinkingSummaries"}, + } + for _, test := range tests { + out := ApplySummaryConfig([]byte(test.body), test.format, SummaryConfig{Mode: SummaryEnabled}) + if !gjson.GetBytes(out, test.canonical).Exists() { + t.Fatalf("%s missing canonical field: %s", test.format, out) + } + if gjson.GetBytes(out, test.alias).Exists() { + t.Fatalf("%s retained alias %s: %s", test.format, test.alias, out) + } + } +} + +// Anthropic requires thinking.type, and rejects display on a disabled block, so +// display must never be written unless thinking is already active. +func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) { + bodies := []string{ + `{}`, + `{"messages":[{"role":"user","content":"hi"}]}`, + `{"thinking":{"type":"disabled"}}`, + } + for _, mode := range []SummaryMode{SummaryEnabled, SummaryDisabled} { + for _, body := range bodies { + out := ApplySummaryConfig([]byte(body), "claude", SummaryConfig{Mode: mode}) + if gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("mode %v wrote display without active thinking: %s", mode, out) + } + if !bytes.Equal(out, []byte(body)) { + t.Fatalf("mode %v changed body: got %s, want %s", mode, out, body) + } + } + } +} + +func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMode(t *testing.T) { + tests := []struct { + name string + model string + body string + mode SummaryMode + wantType string + wantDisplay string + wantBudget int64 + }{ + {name: "adaptive model summarized", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryEnabled, wantType: "adaptive", wantDisplay: "summarized"}, + {name: "adaptive model omitted", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryDisabled, wantType: "adaptive", wantDisplay: "omitted"}, + {name: "manual model summarized", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryEnabled, wantType: "enabled", wantDisplay: "summarized", wantBudget: 1024}, + {name: "manual model omitted", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryDisabled, wantType: "enabled", wantDisplay: "omitted", wantBudget: 1024}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: test.mode}) + 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 != test.wantDisplay { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + } + if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget { + t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out) + } + }) + } +} + +func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}) + if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" { + t.Fatalf("reasoning.summary = %q, want detailed; body=%s", got, out) + } + if gjson.GetBytes(out, "reasoning.generate_summary").Exists() { + t.Fatalf("deprecated reasoning.generate_summary remained: %s", out) + } +} + +func TestApplySummaryConfig_ResponsesDisabledOmitsSummary(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"reasoning":{"effort":"high","summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled}) + if result := gjson.GetBytes(out, "reasoning.summary"); result.Exists() { + t.Fatalf("reasoning.summary = %s, want absent; body=%s", result.Raw, out) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" { + t.Fatalf("reasoning.effort = %q, want high; body=%s", got, out) + } +} + +func TestApplySummaryConfig_ResponsesDisabledDropsEmptyReasoning(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"model":"gpt-5.4","reasoning":{"summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled}) + if gjson.GetBytes(out, "reasoning").Exists() { + t.Fatalf("empty reasoning object left behind: %s", out) + } +} + +func TestApplySummaryConfig_UnspecifiedLeavesBodyUnchanged(t *testing.T) { + body := []byte(`{"thinking":{"type":"adaptive"}}`) + if got := ApplySummaryConfig(body, "claude", SummaryConfig{}); !bytes.Equal(got, body) { + t.Fatalf("unspecified summary changed body: got %s, want %s", got, body) + } +} diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 58a1068e..b575679a 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -885,7 +885,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": // For adaptive thinking: @@ -901,7 +900,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") } - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 52586f29..c41aa752 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -2238,8 +2238,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) { if thinkingConfig.Get("thinkingBudget").Int() != 8000 { t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int()) } - if !thinkingConfig.Get("includeThoughts").Bool() { - t.Error("includeThoughts should be true") + if thinkingConfig.Get("includeThoughts").Exists() { + t.Error("includeThoughts should be absent without explicit Claude display intent") } } else { t.Log("thinkingConfig not present - model may not be registered in test registry") diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 123b42dc..2d00a4f3 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -707,20 +707,17 @@ func antigravityInputAudioMimeType(format string) string { } func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { - switch summary.Type { - case gjson.True: + if summary.Type != gjson.String { + return false, false + } + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "auto": return true, true - case gjson.False: + case "none": return false, true - case gjson.String: - switch strings.ToLower(strings.TrimSpace(summary.String())) { - case "", "none", "off", "false", "disabled": - return false, true - default: - return true, true - } + default: + return false, false } - return false, false } func convertSnakeCaseKeysToCamelCaseForAntigravity(raw []byte) []byte { diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 6c99515f..ee936642 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -5,6 +5,7 @@ package chat_completions import ( "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" @@ -51,14 +52,12 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ thinkingPath := "request.generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } - out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON, modelName) + out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON) // Temperature/top_p/top_k/max_tokens if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number { @@ -513,29 +512,10 @@ func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map return out } -func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte, modelName string) []byte { +func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte) []byte { out = normalizeAntigravityOpenAIThinkingConfig(out) - - for _, path := range []string{ - "thinking.includeThoughts", - "thinking.include_thoughts", - "reasoning.includeThoughts", - "reasoning.include_thoughts", - } { - if value := gjson.GetBytes(rawJSON, path); value.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", value.Bool()) - } - } - - if exclude := gjson.GetBytes(rawJSON, "reasoning.exclude"); exclude.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", !exclude.Bool()) - } - - if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Exists() && antigravityOpenAIDefaultIncludeThoughts(modelName) { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", true) - } - - return normalizeAntigravityOpenAIThinkingConfig(out) + config := thinking.ExtractSummaryConfig(rawJSON, "openai") + return thinking.ApplySummaryConfig(out, "antigravity", config) } func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { @@ -543,11 +523,19 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.thinking_config", "request.generationConfig.thinkingConfig", } { - if includeThoughts := gjson.GetBytes(out, prefix+".includeThoughts"); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + if sourcePath := prefix + ".includeThoughts"; gjson.GetBytes(out, sourcePath).Exists() { + includeThoughts := gjson.GetBytes(out, sourcePath) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False { + out, _ = sjson.DeleteBytes(out, sourcePath) + } } - if includeThoughts := gjson.GetBytes(out, prefix+".include_thoughts"); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + if sourcePath := prefix + ".include_thoughts"; gjson.GetBytes(out, sourcePath).Exists() { + includeThoughts := gjson.GetBytes(out, sourcePath) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False { + out, _ = sjson.DeleteBytes(out, sourcePath) + } } if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() { out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) @@ -568,7 +556,7 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.include_thoughts", } { if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) } } @@ -588,6 +576,17 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { return out } +func setAntigravityOpenAIBoolResultIfValid(out []byte, path string, value gjson.Result) []byte { + switch value.Type { + case gjson.True: + return setAntigravityOpenAIBoolIfDifferent(out, path, true) + case gjson.False: + return setAntigravityOpenAIBoolIfDifferent(out, path, false) + default: + return out + } +} + func setAntigravityOpenAIBoolIfDifferent(out []byte, path string, value bool) []byte { current := gjson.GetBytes(out, path) if value && current.Type == gjson.True || !value && current.Type == gjson.False { @@ -611,8 +610,3 @@ func setAntigravityOpenAIRawIfDifferent(out []byte, path string, value gjson.Res } return updated } - -func antigravityOpenAIDefaultIncludeThoughts(modelName string) bool { - modelName = strings.ToLower(modelName) - return strings.Contains(modelName, "gemini-3") -} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 81907cbb..33bd0e4c 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -191,17 +191,27 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyAssistantMessages(t *testing func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { tests := []struct { - name string - body string - want bool + name string + body string + wantExists bool + want bool }{ { - name: "Default Gemini include thoughts", + name: "Missing summary intent leaves include thoughts absent", body: `{ "model":"gemini-3.1-pro-low", "messages":[{"role":"user","content":"hi"}] }`, - want: true, + }, + { + name: "Reasoning effort enables thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning_effort":"high" + }`, + wantExists: true, + want: true, }, { name: "GenerationConfig snake include thoughts", @@ -210,7 +220,16 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "generationConfig":{"thinkingConfig":{"include_thoughts":true}} }`, - want: true, + wantExists: true, + want: true, + }, + { + name: "String include thoughts is ignored", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{"thinkingConfig":{"includeThoughts":"true"}} + }`, }, { name: "Top-level thinking include thoughts", @@ -219,7 +238,8 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "thinking":{"include_thoughts":true} }`, - want: true, + wantExists: true, + want: true, }, { name: "Reasoning exclude false includes thoughts", @@ -228,7 +248,8 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "reasoning":{"exclude":false} }`, - want: true, + wantExists: true, + want: true, }, { name: "Reasoning exclude true hides thoughts", @@ -237,7 +258,19 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "reasoning":{"exclude":true} }`, - want: false, + wantExists: true, + want: false, + }, + { + name: "Google extension disables thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning_effort":"high", + "extra_body":{"google":{"thinking_config":{"include_thoughts":false}}} + }`, + wantExists: true, + want: false, }, } @@ -245,11 +278,13 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false) includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") - if !includeThoughts.Exists() { - t.Fatalf("includeThoughts missing. Output: %s", result) + if includeThoughts.Exists() != tt.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), tt.wantExists, result) } - if got := includeThoughts.Bool(); got != tt.want { - t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result) + if tt.wantExists { + if got := includeThoughts.Bool(); got != tt.want { + t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result) + } } if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() { t.Fatalf("include_thoughts should be normalized away. Output: %s", result) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 3a27e850..20241bf2 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -341,7 +341,9 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } } template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort) - template, _ = sjson.SetBytes(template, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. serviceTier := normalizeCodexServiceTier(rootResult.Get("service_tier")) if speed := rootResult.Get("speed"); speed.Type == gjson.String && speed.String() == "fast" { serviceTier = "priority" diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index e61dc975..f5a03bdf 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -322,7 +322,9 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // No thinking config, set default effort out, _ = sjson.SetBytes(out, "reasoning.effort", "medium") } - out, _ = sjson.SetBytes(out, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. out, _ = sjson.SetBytes(out, "stream", true) out, _ = sjson.SetBytes(out, "store", false) out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"}) diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go index 89b083f2..25287e89 100644 --- a/internal/translator/codex/interactions/interactions_codex_request.go +++ b/internal/translator/codex/interactions/interactions_codex_request.go @@ -155,17 +155,11 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string { "thinkingSummaries", "reasoning.summary", } { - if value := cfg.Get(path); value.Exists() { - switch value.Type { - case gjson.True: - return "auto" - case gjson.False: - return "none" - case gjson.String: - summary := strings.ToLower(strings.TrimSpace(value.String())) - if summary != "" { - return summary - } + if value := cfg.Get(path); value.Type == gjson.String { + summary := strings.ToLower(strings.TrimSpace(value.String())) + switch summary { + case "auto", "none": + return summary } } } @@ -177,10 +171,10 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string { "thinkingConfig.include_thoughts", "thinkingConfig.includeThoughts", } { - if value := cfg.Get(path); value.Exists() { - if value.Bool() { - return "auto" - } + switch value := cfg.Get(path); value.Type { + case gjson.True: + return "auto" + case gjson.False: return "none" } } diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 051d26ef..307df55d 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -64,7 +64,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b out, _ = sjson.SetBytes(out, "reasoning.effort", "medium") } out, _ = sjson.SetBytes(out, "parallel_tool_calls", true) - out, _ = sjson.SetBytes(out, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"}) // Model diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 2df2009e..8a2259b7 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -266,7 +266,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": // For adaptive thinking: @@ -290,7 +289,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high") } } - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go index e27e815e..59db6641 100644 --- a/internal/translator/gemini/interactions/interactions_gemini_common.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -449,20 +449,17 @@ func normalizeInteractionsGenerationConfig(out []byte) []byte { } func interactionsThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { - switch summary.Type { - case gjson.True: + if summary.Type != gjson.String { + return false, false + } + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "auto": return true, true - case gjson.False: + case "none": return false, true - case gjson.String: - switch strings.ToLower(strings.TrimSpace(summary.String())) { - case "", "none", "off", "false", "disabled": - return false, true - default: - return true, true - } + default: + return false, false } - return false, false } func copyInteractionsResponseModalities(out []byte, root gjson.Result) []byte { diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 0eea0925..64731dc4 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -48,10 +48,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) thinkingPath := "generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 8ee3186a..6ebb4336 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -379,10 +379,8 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte thinkingPath := "generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index ad4d351d..e9ef609f 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -4,6 +4,7 @@ import ( "context" "sync" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -56,6 +57,8 @@ 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 { @@ -85,7 +88,7 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt } } } - return body + return thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) } // HasRequestTransformer indicates whether a request translator exists. diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go new file mode 100644 index 00000000..79312dd8 --- /dev/null +++ b/sdk/translator/registry_summary_test.go @@ -0,0 +1,127 @@ +package translator + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { + tests := []struct { + name string + from Format + to Format + input string + translated string + path string + want string + wantExists bool + }{ + { + name: "chat effort enables Claude summary", + from: FormatOpenAI, + to: FormatClaude, + input: `{"reasoning_effort":"high"}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + want: "summarized", + wantExists: true, + }, + { + name: "responses effort alone leaves Claude display absent", + from: FormatOpenAIResponse, + to: FormatClaude, + input: `{"reasoning":{"effort":"high"}}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + }, + { + name: "responses summary enables Claude summary", + from: FormatOpenAIResponse, + to: FormatClaude, + input: `{"reasoning":{"effort":"high","summary":"auto"}}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + want: "summarized", + wantExists: true, + }, + { + name: "responses null summary disables Gemini summaries", + from: FormatOpenAIResponse, + to: FormatGemini, + input: `{"reasoning":{"effort":"high","summary":null}}`, + translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`, + path: "generationConfig.thinkingConfig.includeThoughts", + want: "false", + wantExists: true, + }, + { + name: "Google Chat extension overrides effort", + from: FormatOpenAI, + to: FormatGemini, + input: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, + translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}}`, + path: "generationConfig.thinkingConfig.includeThoughts", + want: "false", + wantExists: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + registry.Register(test.from, test.to, func(_ string, _ []byte, _ bool) []byte { + return []byte(test.translated) + }, ResponseTransform{}) + out := registry.TranslateRequest(test.from, test.to, "model", []byte(test.input), false) + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + }) + } +} + +func TestRegistryTranslateRequestMakesExplicitClaudeVisibilityValid(t *testing.T) { + tests := []struct { + name string + input string + wantDisplay string + }{ + {name: "summary auto is visible", input: `{"reasoning":{"summary":"auto"},"input":"hi"}`, wantDisplay: "summarized"}, + {name: "summary null is hidden", input: `{"reasoning":{"summary":null},"input":"hi"}`, wantDisplay: "omitted"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(test.input), + false, + ) + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + } + }) + } +} + +func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing.T) { + registry := NewRegistry() + body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"}}`) + 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) + } +} diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go new file mode 100644 index 00000000..e2cfdd99 --- /dev/null +++ b/test/summary_intent_translation_test.go @@ -0,0 +1,226 @@ +package test + +import ( + "fmt" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/antigravity" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestSummaryIntentTranslation(t *testing.T) { + tests := []struct { + name string + from sdktranslator.Format + to sdktranslator.Format + body string + path string + want string + wantExists bool + }{ + {name: "Chat effort enables Claude summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + // Anthropic rejects display next to a disabled thinking block, so a "none" + // effort must leave the field off rather than write "omitted". + {name: "Chat none leaves disabled Claude thinking without display", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display"}, + // Anthropic requires thinking.type. For an unregistered target CPA cannot + // safely guess adaptive versus manual thinking, so it must not emit an + // invalid display-only object. Registered targets are covered below. + {name: "Unknown Claude target does not get display only thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"unregistered-claude-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking"}, + {name: "Unknown Claude target from Interactions stays valid", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, body: `{"model":"unregistered-claude-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking"}, + {name: "Chat none omits Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary"}, + // The Responses API makes reasoning.summary an explicit opt-in, so an + // absent source intent must remain absent when translated to Codex. + {name: "Claude absent display leaves Codex summary absent", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary"}, + {name: "Gemini absent includeThoughts leaves Codex summary absent", from: sdktranslator.FormatGemini, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "reasoning.summary"}, + {name: "Claude summarized enables Codex summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive","display":"summarized"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, + {name: "Interactions none omits Codex summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "reasoning.summary"}, + {name: "Chat effort enables Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, + {name: "Responses summary only enables Chat compatibility effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort", want: "medium", wantExists: true}, + // Chat has no field for "reason but hide": OpenAI documents none and rejects + // unknown parameters, so a disabled summary must leave the requested effort + // alone instead of turning reasoning off upstream. + {name: "Responses disabled summary keeps Chat effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"effort":"high","summary":null},"input":"hi"}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Gemini disabled summary keeps Chat effort", from: sdktranslator.FormatGemini, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":false}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Claude omitted display keeps Chat effort", from: sdktranslator.FormatClaude, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Chat without effort leaves Claude display absent", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display"}, + {name: "Responses effort alone leaves Claude display absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high"},"input":"hi"}`, path: "thinking.display"}, + {name: "Responses summary enables Claude summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Responses null summary disables Claude summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high","summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Chat effort enables Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Chat none disables Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Chat effort enables Antigravity summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Google Chat extension overrides Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Responses effort alone leaves Gemini summary absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses detailed summary enables Gemini summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"detailed"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Responses effort alone leaves Antigravity summary absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses summary enables Antigravity summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {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: "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}, + {name: "Native Interactions none omits Claude summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := sdktranslator.TranslateRequest(test.from, test.to, "", []byte(test.body), true) + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + }) + } +} + +func TestInvalidInteractionsSummaryDoesNotWriteTargetControl(t *testing.T) { + body := []byte(`{"model":"model","generation_config":{"thinking_summaries":"banana"},"input":"hi"}`) + for _, test := range []struct { + name string + to sdktranslator.Format + path string + }{ + {name: "Gemini", to: sdktranslator.FormatGemini, path: "generationConfig.thinkingConfig.includeThoughts"}, + {name: "Antigravity", to: sdktranslator.FormatAntigravity, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Codex", to: sdktranslator.FormatCodex, path: "reasoning.summary"}, + } { + t.Run(test.name, func(t *testing.T) { + out := sdktranslator.TranslateRequest(sdktranslator.FormatInteractions, test.to, "model", body, false) + if result := gjson.GetBytes(out, test.path); result.Exists() { + t.Fatalf("invalid Interactions summary wrote %s=%s; body=%s", test.path, result.Raw, out) + } + }) + } +} + +func TestSummaryIntentFinalPipeline(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("summary-final-pipeline-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + tests := []struct { + name string + from sdktranslator.Format + to sdktranslator.Format + model string + body string + path string + want string + wantExists bool + }{ + {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Responses null summary only activates hidden Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, + {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Claude suffix none removes otherwise enabled display", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(none)", body: `{"model":"claude-sonnet-4-6-model(none)","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display"}, + {name: "Claude suffix preserves explicit disabled summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(high)", body: `{"model":"claude-sonnet-4-6-model(high)","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Responses effort alone stays omitted on Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses summary reaches Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Google Chat extension false survives Gemini applier", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + // Captured from isolated Claude Code 2.1.220 with + // alwaysThinkingEnabled:true. Sonnet uses adaptive thinking, while Haiku + // uses manual enabled thinking with a budget; both explicitly omit text. + {name: "Claude Code Sonnet omitted thinking reaches Gemini", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"claude-sonnet-4-6","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Sonnet omitted thinking reaches Antigravity", from: sdktranslator.FormatClaude, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"claude-sonnet-4-6","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Haiku omitted thinking reaches Gemini", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"claude-haiku-4-5-20251001","thinking":{"type":"enabled","budget_tokens":31999,"display":"omitted"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Haiku omitted thinking reaches Antigravity", from: sdktranslator.FormatClaude, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"claude-haiku-4-5-20251001","thinking":{"type":"enabled","budget_tokens":31999,"display":"omitted"},"messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, + {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, + {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Deprecated Responses detail reaches Codex", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatCodex, model: "level-model", body: `{"model":"level-model","reasoning":{"effort":"high","generate_summary":"detailed"},"input":"hi"}`, path: "reasoning.summary", want: "detailed", wantExists: true}, + {name: "Gemini missing includeThoughts stays omitted on Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, + {name: "Gemini true includeThoughts reaches Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Native Antigravity budget keeps visibility omitted", from: sdktranslator.FormatAntigravity, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseModel := thinking.ParseSuffix(test.model).ModelName + out := sdktranslator.TranslateRequest(test.from, test.to, baseModel, []byte(test.body), true) + var err error + out, err = thinking.ApplyThinkingWithSummary(out, test.model, test.from.String(), test.to.String(), test.to.String(), thinking.ExtractSummaryConfig([]byte(test.body), test.from.String())) + if err != nil { + t.Fatalf("ApplyThinking() error = %v; body=%s", err, out) + } + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + 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 TestNativeClaudeMissingDisplayPreservesSignatureOnlyHistory(t *testing.T) { + body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"opus-signature"}]},{"role":"user","content":"continue"}]}`) + out := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FormatClaude, "claude-opus-5", body, true) + if gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("native Claude request without display gained one: %s", out) + } + if got := gjson.GetBytes(out, "messages").Raw; got != gjson.GetBytes(body, "messages").Raw { + t.Fatalf("signature-only history changed: got %s, want %s", got, gjson.GetBytes(body, "messages").Raw) + } +} + +// Antigravity wraps Gemini generateContent, where includeThoughts is an +// independent opt-in. Thinking level/budget changes must preserve explicit +// booleans and leave an omitted visibility control omitted. +func TestAntigravityIncludeThoughtsPreservesExplicitness(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("antigravity-summary-default-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + const contents = `"contents":[{"role":"user","parts":[{"text":"hi"}]}]` + tests := []struct { + name string + model string + body string + want string + wantExists bool + }{ + {name: "suffix thinking without intent stays omitted", model: "antigravity-budget-model(medium)", body: `{"request":{` + contents + `}}`}, + {name: "native budget without intent stays omitted", model: "antigravity-budget-model", body: `{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}},` + contents + `}}`}, + {name: "explicit true is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}},` + contents + `}}`, want: "true", wantExists: true}, + {name: "explicit false is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":false}},` + contents + `}}`, want: "false", wantExists: true}, + {name: "explicit snake case false is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":false}},` + contents + `}}`, want: "false", wantExists: true}, + {name: "disabled thinking without summary intent stays omitted", model: "antigravity-budget-model(none)", body: `{"request":{` + contents + `}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out, err := thinking.ApplyThinking([]byte(test.body), test.model, "antigravity", "antigravity", "antigravity") + if err != nil { + t.Fatalf("ApplyThinking() error = %v; body=%s", err, out) + } + result := gjson.GetBytes(out, "request.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 { + if got := fmt.Sprintf("%v", result.Bool()); got != test.want { + t.Fatalf("includeThoughts = %s, want %s; body=%s", got, test.want, out) + } + } + if gjson.GetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts").Exists() { + t.Fatalf("snake_case includeThoughts left in payload: %s", out) + } + }) + } +} diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 2a95d107..07dfb039 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1456,25 +1456,30 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "false", expectErr: false, }, - // Case 31A: reasoning_effort=none with zero allowed → delete thinkingConfig + // Case 31A: reasoning_effort=none with zero allowed removes the amount but + // preserves Chat's explicit disabled summary intent. { - name: "31A", - from: "openai", - to: "gemini", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "", - expectErr: false, + name: "31A", + from: "openai", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "generationConfig.thinkingConfig.includeThoughts", + expectValue: "false", + includeThoughts: "false", + expectErr: false, }, - // Case 31B: reasoning_effort=none with zero allowed to Antigravity → delete thinkingConfig + // Case 31B: the same explicit disabled intent survives Antigravity. { - name: "31B", - from: "openai", - to: "antigravity", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "", - expectErr: false, + name: "31B", + from: "openai", + to: "antigravity", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "request.generationConfig.thinkingConfig.includeThoughts", + expectValue: "false", + includeThoughts: "false", + expectErr: false, }, // Case 31C: reasoning.effort=none with zero allowed → delete thinkingConfig { @@ -2448,7 +2453,7 @@ func TestThinkingE2EProviderTargets(t *testing.T) { expectValue: "high", }, - // Interactions target: native API uses generation_config.thinking_level and thinking_summaries. + // Interactions target: native API uses generation_config.thinking_level and optional thinking_summaries. { name: "I1", from: "interactions", @@ -2461,16 +2466,310 @@ func TestThinkingE2EProviderTargets(t *testing.T) { expectValue2: "auto", }, { - name: "I2", + name: "I2", + from: "interactions", + to: "interactions", + model: "level-model(8192)", + inputJSON: `{"model":"level-model(8192)","input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "medium", + }, + // Responses client against a chat-shaped provider. Because thinking is read + // back off the translated body, this pair only works if the request translator + // rewrites reasoning.effort as reasoning_effort; nothing else covered it. + { + name: "R1", + from: "openai-response", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"high"}}`, + expectField: "reasoning_effort", + expectValue: "high", + }, + { + name: "R2", + from: "openai-response", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"none"}}`, + expectField: "reasoning_effort", + expectValue: "minimal", + }, + { + name: "R3", + from: "openai-response", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","input":"hi","reasoning":{"effort":"high"}}`, + expectField: "thinking.type", + expectValue: "enabled", + expectField2: "thinking.effort", + expectValue2: "high", + expectAbsent: []string{"reasoning_effort"}, + }, + { + name: "R4", + from: "openai-response", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","input":"hi","reasoning":{"effort":"medium"}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + }, + } + + runThinkingTests(t, cases) +} + +// TestThinkingE2EInteractionsMatrix covers the Interactions protocol in both +// directions, which the suffix and body matrices above barely touch. +// +// Interactions expresses thinking through generation_config.thinking_level and the +// independent auto/none generation_config.thinking_summaries control. Compatibility +// thinking_budget and none/auto level inputs map onto a documented target level. The +// IN cases drive Interactions +// as the provider from every client protocol; the OUT cases drive an Interactions +// client against every provider, so an explicit on/off request has to survive the +// round trip in both roles. +func TestThinkingE2EInteractionsMatrix(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("thinking-e2e-interactions-%d", time.Now().UnixNano()) + + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + cases := []thinkingTestCase{ + // Interactions as provider: explicit on from every client protocol. + { + name: "IN1", + from: "claude", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":10000}}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + { + name: "IN2", + from: "openai", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN3", + from: "openai-response", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"low"}}`, + expectField: "generation_config.thinking_level", + expectValue: "low", + }, + { + name: "IN4", + from: "gemini", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":20000}}}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + // A level the model does not publish falls back to its highest level. + { + name: "IN5", + from: "openai", + to: "interactions", + model: "level-subset-model", + inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + // Interactions cannot fully disable this model, so thinking clamps to the + // lowest documented level. Summary visibility remains omitted unless the + // source independently requested it. + { + name: "IN6", + from: "claude", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN7", + from: "openai", + to: "interactions", + model: "level-model(none)", + inputJSON: `{"model":"level-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN8", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + // Interactions supports auto as its only enabled summary selector. + { + name: "IN9", from: "interactions", to: "interactions", - model: "level-model(8192)", - inputJSON: `{"model":"level-model(8192)","input":"hi"}`, + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"low","thinking_summaries":"auto"},"input":"hi"}`, expectField: "generation_config.thinking_level", - expectValue: "medium", + expectValue: "low", expectField2: "generation_config.thinking_summaries", expectValue2: "auto", }, + // A legacy thinking_budget maps onto the level enum. + { + name: "IN10", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_budget":400},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + // Auto on a model without dynamic thinking resolves to the mid-range level, + // the same normalization every other target gets. + { + name: "IN11", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_budget":-1},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "medium", + }, + + // Interactions as client: explicit on has to reach every provider's own knob. + { + name: "OUT1", + from: "interactions", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + }, + { + name: "OUT2", + from: "interactions", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "reasoning_effort", + expectValue: "high", + }, + { + name: "OUT3", + from: "interactions", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"low"},"input":"hi"}`, + expectField: "reasoning.effort", + expectValue: "low", + }, + { + name: "OUT4", + from: "interactions", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + }, + { + name: "OUT5", + from: "interactions", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + }, + { + name: "OUT6", + from: "interactions", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "enabled", + expectField2: "thinking.effort", + expectValue2: "high", + }, + { + name: "OUT7", + from: "interactions", + to: "xai", + model: "xai-level-model", + inputJSON: `{"model":"xai-level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "reasoning.effort", + expectValue: "high", + }, + // Interactions as client: explicit off has to reach every provider's own way + // of saying no thinking. + { + name: "OUT8", + from: "interactions", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "disabled", + }, + { + name: "OUT9", + from: "interactions", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + }, + { + name: "OUT10", + from: "interactions", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "disabled", + expectAbsent: []string{"thinking.effort", "reasoning_effort"}, + }, + // A level+budget model that allows zero expresses off by dropping + // thinkingConfig entirely, so an Interactions client reaches the same shape a + // chat or Responses client does. + { + name: "OUT11", + from: "interactions", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectAbsent: []string{"generationConfig.thinkingConfig"}, + }, + // Auto reaches a dynamic-capable provider as dynamic thinking. + { + name: "OUT12", + from: "interactions", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","generation_config":{"thinking_level":"auto"},"input":"hi"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + }, } runThinkingTests(t, cases) @@ -3204,18 +3503,36 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { assertField(tc.expectField3, tc.expectValue3) } - if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "antigravity") { + if tc.to == "gemini" || tc.to == "antigravity" { path := "generationConfig.thinkingConfig.includeThoughts" if tc.to == "antigravity" { path = "request.generationConfig.thinkingConfig.includeThoughts" } - itVal := gjson.GetBytes(body, path) - if !itVal.Exists() { - t.Fatalf("expected includeThoughts field not found, body=%s", string(body)) + wantIncludeThoughts := "" + summaryConfig := thinking.ExtractSummaryConfig([]byte(tc.inputJSON), tc.from) + switch summaryConfig.Mode { + case thinking.SummaryEnabled: + wantIncludeThoughts = "true" + case thinking.SummaryDisabled: + wantIncludeThoughts = "false" + default: + // Thinking amount does not imply summary visibility. Keep the + // provider field absent when the source omitted its summary control. } - actual := fmt.Sprintf("%v", itVal.Bool()) - if actual != tc.includeThoughts { - t.Fatalf("includeThoughts: expected %s, got %s, body=%s", tc.includeThoughts, actual, string(body)) + + itVal := gjson.GetBytes(body, path) + if wantIncludeThoughts == "" { + if itVal.Exists() { + t.Fatalf("includeThoughts should be absent without summary intent, body=%s", string(body)) + } + } else { + if !itVal.Exists() { + t.Fatalf("expected includeThoughts field not found, body=%s", string(body)) + } + actual := fmt.Sprintf("%v", itVal.Bool()) + if actual != wantIncludeThoughts { + t.Fatalf("includeThoughts: expected %s, got %s, body=%s", wantIncludeThoughts, actual, string(body)) + } } } }) -- 2.51.2 From b63a38d566ab04bf2237084771d7bc303475031c Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 22:38:07 +0800 Subject: [PATCH 2/9] fix(thinking): avoid enabling Claude for hidden summaries --- .../thinking/apply_configured_api_key_test.go | 58 ++++++++++-------- internal/thinking/summary.go | 12 ++-- internal/thinking/summary_test.go | 38 +++++++----- sdk/translator/registry_summary_test.go | 61 ++++++++++--------- test/summary_intent_translation_test.go | 4 +- 5 files changed, 98 insertions(+), 75 deletions(-) diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index 5aa3ce9d..a443491e 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -92,36 +92,44 @@ func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) } } -func TestApplyThinkingWithModelInfoAppliesSummaryOnlyClaudeVisibility(t *testing.T) { +func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "private-claude", Type: "claude", Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, } - for _, test := range []struct { - name string - source string - display string - }{ - {name: "enabled", source: `{"reasoning":{"summary":"auto"}}`, display: "summarized"}, - {name: "disabled", source: `{"reasoning":{"summary":null}}`, display: "omitted"}, - } { - t.Run(test.name, func(t *testing.T) { - out, err := thinking.ApplyThinkingWithModelInfo( - []byte(`{"model":"private-claude","max_tokens":32000}`), - []byte(test.source), - "private-claude", "openai-response", "claude", "claude", modelInfo, - ) - if err != nil { - t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) - } - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) - } - if got := gjson.GetBytes(out, "thinking.display").String(); got != test.display { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.display, out) - } - }) + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(`{"reasoning":{"summary":"auto"}}`), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-claude", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(`{"reasoning":{"summary":null}}`), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("disabled summary activated Claude thinking: %s", out) } } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 4a19dae1..430a35d8 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -126,12 +126,12 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re body = applyOpenAIChatSummaryConfig(body, model, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and - // requires it alongside adaptive or enabled thinking. An explicit source - // visibility request is independent of thinking effort, so activate the - // target model's documented thinking mode before writing either - // summarized or omitted. Unspecified intent returns above and leaves the - // target's default untouched. - if !gjson.GetBytes(body, "thinking.type").Exists() { + // requires it alongside adaptive or enabled thinking. An enabled source + // summary needs an active target thinking mode. A disabled summary only + // hides an already-active target thinking mode; it must not enable thinking + // merely to hide a summary that would not otherwise exist. Unspecified + // intent returns above and leaves the target's default untouched. + if enabled && !gjson.GetBytes(body, "thinking.type").Exists() { body = enableClaudeThinkingForSummary(body, model, modelInfo) } if !claudeThinkingAcceptsDisplay(body) { diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 6c9011f1..59bd2eb4 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -149,29 +149,25 @@ func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) { } } -func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMode(t *testing.T) { +func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t *testing.T) { tests := []struct { - name string - model string - body string - mode SummaryMode - wantType string - wantDisplay string - wantBudget int64 + name string + model string + body string + wantType string + wantBudget int64 }{ - {name: "adaptive model summarized", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryEnabled, wantType: "adaptive", wantDisplay: "summarized"}, - {name: "adaptive model omitted", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryDisabled, wantType: "adaptive", wantDisplay: "omitted"}, - {name: "manual model summarized", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryEnabled, wantType: "enabled", wantDisplay: "summarized", wantBudget: 1024}, - {name: "manual model omitted", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryDisabled, wantType: "enabled", wantDisplay: "omitted", wantBudget: 1024}, + {name: "adaptive model", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, wantType: "adaptive"}, + {name: "manual model", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, wantType: "enabled", wantBudget: 1024}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: test.mode}) + out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: SummaryEnabled}) 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 != test.wantDisplay { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) } if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget { t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out) @@ -180,6 +176,18 @@ func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMod } } +// Disabling summaries must not activate Claude thinking. Doing so would add +// reasoning tokens, latency, and cost to a request that asked only to hide output. +func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) { + for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} { + body := []byte(`{"model":"` + model + `","max_tokens":32000}`) + out := ApplySummaryConfigForModel(body, "claude", model, SummaryConfig{Mode: SummaryDisabled}) + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("model %s gained thinking for a disabled summary: %s", model, out) + } + } +} + func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) { out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}) if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" { diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go index 79312dd8..312ee57b 100644 --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -85,35 +85,40 @@ func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { } } -func TestRegistryTranslateRequestMakesExplicitClaudeVisibilityValid(t *testing.T) { - tests := []struct { - name string - input string - wantDisplay string - }{ - {name: "summary auto is visible", input: `{"reasoning":{"summary":"auto"},"input":"hi"}`, wantDisplay: "summarized"}, - {name: "summary null is hidden", input: `{"reasoning":{"summary":null},"input":"hi"}`, wantDisplay: "omitted"}, +func TestRegistryTranslateRequestActivatesClaudeForEnabledSummary(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`), + false, + ) + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - registry := NewRegistry() - registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { - return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) - }, ResponseTransform{}) - out := registry.TranslateRequest( - FormatOpenAIResponse, - FormatClaude, - "claude-opus-5", - []byte(test.input), - false, - ) - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) - } - if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) - } - }) + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } +} + +func TestRegistryTranslateRequestDoesNotActivateClaudeForDisabledSummary(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(`{"reasoning":{"summary":null},"input":"hi"}`), + false, + ) + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("disabled summary activated Claude thinking: %s", out) } } diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index e2cfdd99..06edfdad 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -119,7 +119,9 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { wantExists bool }{ {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, - {name: "Responses null summary only activates hidden Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + // Disabling summaries alone must not activate Claude thinking: doing so adds + // reasoning tokens, latency, and cost to a request with no thinking effort. + {name: "Responses null summary alone keeps Claude thinking disabled", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 76008b4720823747560705a8b0f2baa2a10bd1df Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 22:44:01 +0800 Subject: [PATCH 3/9] fix(thinking): decouple Interactions effort summaries --- .../interactions_antigravity_request.go | 5 ++-- .../interactions_antigravity_test.go | 29 +++++++++++++++++++ test/summary_intent_translation_test.go | 3 ++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 2d00a4f3..53d9df0e 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -195,12 +195,13 @@ func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byt effort = strings.ToLower(strings.TrimSpace(reasoning.Get("thinking_level").String())) } if effort != "" { + // Thinking amount and summary visibility are independent. This OpenAI-style + // compatibility alias controls only the amount; includeThoughts is written + // below only for an explicit Interactions summary selector. if effort == "auto" { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } else { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", effort != "none") } } if summary := reasoning.Get("summary"); summary.Exists() { diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_test.go index d6baa800..d0052a7b 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_test.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_test.go @@ -68,6 +68,35 @@ func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *tes } } +func TestConvertInteractionsReasoningToAntigravityKeepsSummaryIndependent(t *testing.T) { + tests := []struct { + name string + reasoning string + want bool + wantExists bool + }{ + {name: "effort only leaves summaries unspecified", reasoning: `{"effort":"high"}`}, + {name: "explicit auto enables summaries", reasoning: `{"effort":"high","summary":"auto"}`, want: true, wantExists: true}, + {name: "explicit none disables summaries", reasoning: `{"effort":"high","summary":"none"}`, wantExists: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"antigravity-test","input":"hi","reasoning":` + test.reasoning + `}`) + out := ConvertInteractionsRequestToAntigravity("antigravity-test", body, false) + if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" { + t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, out) + } + includeThoughts := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts") + if includeThoughts.Exists() != test.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), test.wantExists, out) + } + if test.wantExists && includeThoughts.Bool() != test.want { + t.Fatalf("includeThoughts = %v, want %v. Output: %s", includeThoughts.Bool(), test.want, out) + } + }) + } +} + func TestConvertAntigravityResponseToInteractionsNonStream(t *testing.T) { raw := []byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"},{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}`) out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", nil, nil, raw, nil) diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 06edfdad..5cb01362 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -140,6 +140,9 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Interactions reasoning effort leaves Antigravity summaries unspecified", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Interactions reasoning summary auto reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Interactions reasoning summary none reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"none"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, {name: "Deprecated Responses detail reaches Codex", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatCodex, model: "level-model", body: `{"model":"level-model","reasoning":{"effort":"high","generate_summary":"detailed"},"input":"hi"}`, path: "reasoning.summary", want: "detailed", wantExists: true}, {name: "Gemini missing includeThoughts stays omitted on Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, {name: "Gemini true includeThoughts reaches Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 92b6bc4a868a3e5ecf2804f24239c213c9d6f575 Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 23:05:14 +0800 Subject: [PATCH 4/9] docs(thinking): clarify Claude model defaults --- internal/thinking/summary.go | 18 +++++++++++++----- internal/thinking/summary_test.go | 5 +++-- test/summary_intent_translation_test.go | 7 ++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 430a35d8..5cca0a9b 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -126,11 +126,19 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re body = applyOpenAIChatSummaryConfig(body, model, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and - // requires it alongside adaptive or enabled thinking. An enabled source - // summary needs an active target thinking mode. A disabled summary only - // hides an already-active target thinking mode; it must not enable thinking - // merely to hide a summary that would not otherwise exist. Unspecified - // intent returns above and leaves the target's default untouched. + // requires it alongside adaptive or enabled thinking. Model defaults differ: + // Opus 5 and Sonnet 5 default to adaptive thinking; Fable/Mythos 5 are always + // on. Opus 4.8/4.7/4.6, Sonnet 4.6, and the 4.5 models default to thinking + // off. The newest models also default display to omitted. Keeping a missing + // thinking block absent therefore preserves both kinds of model default; + // absence does not mean every Claude model runs without thinking. Only an + // enabled summary may activate a valid target thinking mode so that summarized + // text can be returned. A disabled summary only adds omitted to an + // already-active target mode. + // + // Anthropic docs: + // https://platform.claude.com/docs/en/build-with-claude/thinking + // https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models if enabled && !gjson.GetBytes(body, "thinking.type").Exists() { body = enableClaudeThinkingForSummary(body, model, modelInfo) } diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 59bd2eb4..1fed4147 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -176,8 +176,9 @@ func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t } } -// Disabling summaries must not activate Claude thinking. Doing so would add -// reasoning tokens, latency, and cost to a request that asked only to hide output. +// Disabling summaries must not make CPA add a Claude thinking block. Absence +// preserves the per-model default: newer models may still think by default, +// while older models remain off. func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) { for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} { body := []byte(`{"model":"` + model + `","max_tokens":32000}`) diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 5cb01362..b53c7ca3 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -119,9 +119,10 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { wantExists bool }{ {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, - // Disabling summaries alone must not activate Claude thinking: doing so adds - // reasoning tokens, latency, and cost to a request with no thinking effort. - {name: "Responses null summary alone keeps Claude thinking disabled", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, + // Summary visibility must not override Claude's per-model thinking default. + // Sonnet 4.6 defaults off; newer default-on models remain default-on without + // CPA injecting an explicit thinking block. + {name: "Responses null summary alone preserves Claude thinking default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 5d307c195dd23c533f9c5ca9f59216e192101eab Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 23:26:20 +0800 Subject: [PATCH 5/9] fix(thinking): close summary translation gaps --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/aistudio_executor_test.go | 20 +++++ internal/thinking/summary.go | 18 ++++- internal/thinking/summary_test.go | 8 ++ .../claude/gemini/claude_gemini_request.go | 4 - sdk/translator/registry.go | 38 ++++++---- sdk/translator/registry_summary_test.go | 73 +++++++++++++++++++ test/summary_intent_translation_test.go | 38 ++++++++++ 8 files changed, 179 insertions(+), 22 deletions(-) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index d2e78eca..3cabe5da 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe 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 index 52ce6147..ea5bd8df 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -17,8 +17,28 @@ import ( 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" delay := 40 * time.Millisecond diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 5cca0a9b..5f7d0ead 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -95,6 +95,14 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { 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 @@ func claudeThinkingAcceptsDisplay(body []byte) bool { 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 index 1fed4147..da7d7bb2 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -44,11 +44,19 @@ func TestExtractSummaryConfig(t *testing.T) { // 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/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index ccbaa9d0..b7c7bfa8 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -217,10 +217,6 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream 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") } } } diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index e9ef609f..830d0355 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -70,25 +70,35 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt 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 index 312ee57b..1b77b951 100644 --- 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) { @@ -130,3 +132,74 @@ func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing. 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/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index b53c7ca3..59ad4b50 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -62,6 +62,7 @@ func TestSummaryIntentTranslation(t *testing.T) { {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}, @@ -173,6 +174,43 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { } } +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) + } + }) + } +} + func TestNativeClaudeMissingDisplayPreservesSignatureOnlyHistory(t *testing.T) { body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"opus-signature"}]},{"role":"user","content":"continue"}]}`) out := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FormatClaude, "claude-opus-5", body, true) -- 2.51.2 From 87ceaf83bb702ab4053623ffad78205df7f37d08 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 00:05:59 +0800 Subject: [PATCH 6/9] fix(thinking): honor provider visibility semantics --- internal/thinking/apply.go | 25 ++++-- .../thinking/apply_configured_api_key_test.go | 55 ++++++++++++ .../thinking/provider/antigravity/apply.go | 5 +- internal/thinking/provider/gemini/apply.go | 5 +- internal/thinking/summary.go | 88 +++++++++---------- internal/thinking/summary_test.go | 46 +++++++++- test/summary_intent_translation_test.go | 4 +- test/thinking_conversion_test.go | 38 ++++---- 8 files changed, 187 insertions(+), 79 deletions(-) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index c19369c7..a349f269 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -225,7 +225,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF // Unknown models are treated as user-defined so thinking config can still be applied. // The upstream service is responsible for validating the configuration. if IsUserDefinedModel(modelInfo) { - return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult, summaryConfig) + return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, summaryConfig) } if modelInfo.Thinking == nil { config := extractThinkingConfig(body, providerFormat) @@ -277,7 +277,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") - return applySummaryConfigForModel(body, providerFormat, baseModel, modelInfo, summaryConfig), nil + return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil } if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { config.Level = mapConfiguredHighIntent(config.Level, modelInfo) @@ -320,7 +320,17 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if err != nil { return applied, err } - return applySummaryConfigForModel(applied, providerFormat, baseModel, modelInfo, summaryConfig), nil + // A fully disabled amount takes precedence over visibility. Re-applying a + // summary-only field can recreate an otherwise removed provider config and + // make a default-on model think again. + if thinkingIsFullyDisabled(*validated) { + return applied, nil + } + return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil +} + +func thinkingIsFullyDisabled(config ThinkingConfig) bool { + return config.Mode == ModeNone && config.Budget == 0 && config.Level == "" } func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { @@ -409,7 +419,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig { // applyUserDefinedModel applies thinking configuration for user-defined models // without ThinkingSupport validation. -func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { +func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat, providerKey string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { // Get model ID for logging modelID := "" if modelInfo != nil { @@ -450,7 +460,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "model": modelID, "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (no config) |") - return applySummaryConfigForModel(body, toFormat, modelID, modelInfo, summaryConfig), nil + return applySummaryConfigForProvider(body, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil } applier := GetProviderApplier(toFormat) @@ -474,7 +484,10 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma if err != nil { return applied, err } - return applySummaryConfigForModel(applied, toFormat, modelID, modelInfo, summaryConfig), nil + if thinkingIsFullyDisabled(config) { + return applied, nil + } + return applySummaryConfigForProvider(applied, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil } func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index a443491e..b056139e 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -133,6 +133,61 @@ func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *te } } +func TestApplyThinkingWithModelInfoSummaryOnlyDoesNotInventOpenAIEffort(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-openai", + Type: "openai", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`), + []byte(`{"model":"private-openai","reasoning":{"summary":"auto"},"input":"hi"}`), + "private-openai", "openai-response", "openai", "openai", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("summary-only request invented reasoning_effort: %s", out) + } +} + +func TestApplyThinkingWithSummaryKeepsOpenAIChatSuffixNone(t *testing.T) { + out, err := thinking.ApplyThinkingWithSummary( + []byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`), + "private-openai(none)", "openai-response", "openai", "openai", + thinking.SummaryConfig{Mode: thinking.SummaryEnabled, Detail: "auto"}, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSummary() error = %v; body=%s", err, out) + } + if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "none" { + t.Fatalf("reasoning_effort = %q, want none; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoUsesOpenRouterVisibility(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "openrouter-model", + Type: "openai-compatibility", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}]}`), + []byte(`{"model":"openrouter-model","reasoning":{"summary":"auto"},"input":"hi"}`), + "openrouter-model", "openai-response", "openai", "openrouter", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out) + } + if exclude := gjson.GetBytes(out, "reasoning.exclude"); !exclude.Exists() || exclude.Bool() { + t.Fatalf("OpenRouter summary visibility not enabled: %s", out) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("OpenRouter summary visibility invented reasoning_effort: %s", out) + } +} + func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "claude-upstream", diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index 968ee09d..6d2edbfa 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -104,8 +104,11 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { + // With the amount fully disabled, visibility is irrelevant. Restoring + // includeThoughts alone would recreate thinkingConfig and let a + // default-on model think again. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig") - return applyAntigravityIncludeThoughts(result, body), nil + return result, nil } if config.Level != "" { result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index c332e9ef..cc4f071e 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -128,8 +128,11 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { + // With the amount fully disabled, visibility is irrelevant. Restoring + // includeThoughts alone would recreate thinkingConfig and let a + // default-on model think again. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig") - return applyGeminiIncludeThoughts(result, body), nil + return result, nil } if config.Level != "" { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 5f7d0ead..17951990 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -95,6 +95,12 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { return config } } + // Existing Interactions translators accept the OpenAI-style top-level + // compatibility object. Keep the official generation_config selector + // authoritative when both are present. + if config, ok := interactionsSummaryConfig(body, "reasoning.summary"); ok { + return config + } if config, ok := firstSummaryBoolConfig(body, []string{ "generation_config.thinking_config.include_thoughts", "generation_config.thinking_config.includeThoughts", @@ -123,6 +129,12 @@ func ApplySummaryConfigForModel(body []byte, format, model string, config Summar // applySummaryConfigForModel uses the resolved model definition when execution // selected a configured API-key model whose capability is not globally visible. func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { + return applySummaryConfigForProvider(body, format, model, "", modelInfo, config) +} + +// applySummaryConfigForProvider uses the execution provider identity for Chat +// dialects whose visibility controls are not part of the OpenAI wire format. +func applySummaryConfigForProvider(body []byte, format, model, provider string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { normalized := strings.ToLower(strings.TrimSpace(format)) if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { return body @@ -131,7 +143,7 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re enabled := config.Mode == SummaryEnabled switch normalized { case "openai": - body = applyOpenAIChatSummaryConfig(body, model, enabled) + body = applyOpenAIChatSummaryConfig(body, provider, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and // requires it alongside adaptive or enabled thinking. Model defaults differ: @@ -234,65 +246,45 @@ func claudeThinkingAcceptsDisplay(body []byte) bool { } } -// applyOpenAIChatSummaryConfig writes summary visibility intent for the Chat -// Completions protocol. +// applyOpenAIChatSummaryConfig writes only documented Chat visibility controls. // -// Four dialects share this protocol and only OpenAI's is authoritative. OpenAI -// documents no reasoning-visibility field at all (Chat Completions never returns -// reasoning text) and rejects unknown body parameters, so reasoning_effort is the -// only field that is always safe to write here. OpenRouter's documented -// "reason but hide" bits (reasoning.exclude and its legacy include_reasoning -// alias) are updated only when the body already carries them, which is exactly -// when the upstream is known to understand them. -func applyOpenAIChatSummaryConfig(body []byte, model string, enabled bool) []byte { - if gjson.GetBytes(body, "reasoning").IsObject() { +// OpenAI Chat Completions exposes reasoning_effort but no reasoning summary or +// visibility parameter. DeepSeek and Kimi Chat return reasoning_content while +// thinking is active, but likewise document no independent hide/show switch. +// Summary intent must therefore never invent or overwrite thinking effort for +// those dialects. OpenRouter is the exception: reasoning.exclude is its +// documented "reason but hide" control, and include_reasoning is its deprecated +// inverse alias. Unknown OpenAI-compatible providers are handled conservatively +// by updating those fields only when the payload already carries them. +// +// Docs: +// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create +// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens +// https://api-docs.deepseek.com/guides/thinking_mode +// https://platform.kimi.ai/docs/api/chat +func applyOpenAIChatSummaryConfig(body []byte, provider string, enabled bool) []byte { + if isOpenRouterProvider(provider) || gjson.GetBytes(body, "reasoning.exclude").IsBool() { body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled) } if gjson.GetBytes(body, "include_reasoning").IsBool() { body, _ = sjson.SetBytes(body, "include_reasoning", enabled) } - if !enabled { - // Chat has no portable way to keep reasoning while hiding its summary. - // reasoning_effort:"none" would disable reasoning instead of hiding it, - // and Google documents that it is not even honored on Gemini 2.5 Pro or - // 3 models, so leave the effort the client asked for untouched. - return body - } - effort := gjson.GetBytes(body, "reasoning_effort") - if effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" || strings.EqualFold(strings.TrimSpace(effort.String()), "none") { - body, _ = sjson.SetBytes(body, "reasoning_effort", openAIChatSummaryEffort(body, model)) - } return body } -// openAIChatSummaryEffort picks an active reasoning effort that the target model -// documents. Chat exposes reasoning only while an effort is active, so a summary -// request has to select one when the client left it unset. -func openAIChatSummaryEffort(body []byte, model string) string { - baseModel := ParseSuffix(model).ModelName - if baseModel == "" { - baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName - } - modelInfo := registry.LookupModelInfo(baseModel, "openai") - if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 { - return "medium" +func isOpenRouterProvider(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "openrouter" { + return true } - - levels := make([]string, 0, len(modelInfo.Thinking.Levels)) - for _, level := range modelInfo.Thinking.Levels { - normalized := strings.ToLower(strings.TrimSpace(level)) - if normalized == "" || normalized == "none" { - continue - } - if normalized == "medium" { - return "medium" + for _, part := range strings.FieldsFunc(provider, func(r rune) bool { + return r == '-' || r == '_' || r == '/' || r == '.' || r == ':' + }) { + if part == "openrouter" { + return true } - levels = append(levels, normalized) - } - if len(levels) == 0 { - return "medium" } - return levels[len(levels)/2] + return false } func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) { diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index da7d7bb2..84c110c9 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -55,6 +55,9 @@ func TestExtractSummaryConfig(t *testing.T) { {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 compatibility reasoning", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"},"reasoning":{"summary":"auto"}}`, wantMode: SummaryDisabled}, + {name: "interactions compatibility reasoning auto", format: "interactions", body: `{"reasoning":{"summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions compatibility reasoning none", format: "interactions", body: `{"reasoning":{"summary":"none"}}`, 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}, @@ -81,8 +84,9 @@ func TestApplySummaryConfig(t *testing.T) { path string want string }{ - {name: "chat enabled creates compatibility effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "medium"}, + {name: "chat enabled invents no effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: ""}, {name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"}, + {name: "chat enabled preserves disabled effort", format: "openai", body: `{"reasoning_effort":"none"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "none"}, // Chat cannot express "reason but hide", so disabling must not fall back to // reasoning_effort:"none", which would disable reasoning altogether. {name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"}, @@ -114,6 +118,46 @@ func TestApplySummaryConfig(t *testing.T) { } } +func TestApplySummaryConfig_OpenAIChatProviderDialects(t *testing.T) { + tests := []struct { + name string + provider string + body string + mode SummaryMode + wantExclude string + wantExisting bool + wantEffort string + }{ + {name: "OpenAI does not invent visibility", provider: "openai", body: `{}`, mode: SummaryEnabled}, + {name: "OpenRouter enables visibility", provider: "openrouter", body: `{}`, mode: SummaryEnabled, wantExclude: "false", wantExisting: true}, + {name: "OpenRouter disables visibility", provider: "prod-openrouter", body: `{}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true}, + {name: "DeepSeek preserves documented effort", provider: "deepseek", body: `{"reasoning_effort":"high"}`, mode: SummaryDisabled, wantEffort: "high"}, + {name: "Kimi preserves documented K3 effort", provider: "kimi", body: `{"reasoning_effort":"max"}`, mode: SummaryEnabled, wantEffort: "max"}, + {name: "Moonshot does not invent visibility", provider: "moonshot", body: `{"thinking":{"type":"enabled"}}`, mode: SummaryEnabled}, + {name: "generic provider updates existing OpenRouter field", provider: "openai-compatibility", body: `{"reasoning":{"exclude":false}}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := applySummaryConfigForProvider([]byte(test.body), "openai", "model", test.provider, nil, SummaryConfig{Mode: test.mode}) + exclude := gjson.GetBytes(out, "reasoning.exclude") + if exclude.Exists() != test.wantExisting { + t.Fatalf("reasoning.exclude exists = %v, want %v; body=%s", exclude.Exists(), test.wantExisting, out) + } + if test.wantExisting && exclude.String() != test.wantExclude { + t.Fatalf("reasoning.exclude = %q, want %q; body=%s", exclude.String(), test.wantExclude, out) + } + effort := gjson.GetBytes(out, "reasoning_effort") + if test.wantEffort == "" { + if effort.Exists() { + t.Fatalf("summary visibility invented reasoning_effort: %s", out) + } + } else if effort.String() != test.wantEffort { + t.Fatalf("reasoning_effort = %q, want %q; body=%s", effort.String(), test.wantEffort, out) + } + }) + } +} + func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) { tests := []struct { format string diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 59ad4b50..cc1724f5 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -40,7 +40,7 @@ func TestSummaryIntentTranslation(t *testing.T) { {name: "Claude summarized enables Codex summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive","display":"summarized"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, {name: "Interactions none omits Codex summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "reasoning.summary"}, {name: "Chat effort enables Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, - {name: "Responses summary only enables Chat compatibility effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort", want: "medium", wantExists: true}, + {name: "Responses summary only invents no Chat effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort"}, // Chat has no field for "reason but hide": OpenAI documents none and rejects // unknown parameters, so a disabled summary must leave the requested effort // alone instead of turning reasoning off upstream. @@ -127,10 +127,12 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Interactions compatibility summary activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Claude suffix none removes otherwise enabled display", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(none)", body: `{"model":"claude-sonnet-4-6-model(none)","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display"}, {name: "Claude suffix preserves explicit disabled summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(high)", body: `{"model":"claude-sonnet-4-6-model(high)","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, {name: "Responses effort alone stays omitted on Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, {name: "Responses summary reaches Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Responses null summary alone hides default Gemini thoughts", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning":{"summary":null},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, {name: "Google Chat extension false survives Gemini applier", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, // Captured from isolated Claude Code 2.1.220 with // alwaysThinkingEnabled:true. Sonnet uses adaptive thinking, while Haiku diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 07dfb039..d71d6e35 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1456,30 +1456,26 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "false", expectErr: false, }, - // Case 31A: reasoning_effort=none with zero allowed removes the amount but - // preserves Chat's explicit disabled summary intent. + // Case 31A: reasoning_effort=none with zero allowed removes the entire + // thinking config. includeThoughts alone would restore the model default. { - name: "31A", - from: "openai", - to: "gemini", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "generationConfig.thinkingConfig.includeThoughts", - expectValue: "false", - includeThoughts: "false", - expectErr: false, + name: "31A", + from: "openai", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "", + expectErr: false, }, - // Case 31B: the same explicit disabled intent survives Antigravity. + // Case 31B: Antigravity keeps the same fully disabled representation. { - name: "31B", - from: "openai", - to: "antigravity", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "request.generationConfig.thinkingConfig.includeThoughts", - expectValue: "false", - includeThoughts: "false", - expectErr: false, + name: "31B", + from: "openai", + to: "antigravity", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "", + expectErr: false, }, // Case 31C: reasoning.effort=none with zero allowed → delete thinkingConfig { -- 2.51.2 From 0c2ec7da235d4f5b1bcd9ab1c03ec77bccfb93f3 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 12:45:13 +0800 Subject: [PATCH 7/9] fix(thinking): honor normalized summary payloads --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/antigravity_executor_execute.go | 4 +- .../executor/antigravity_executor_stream.go | 2 +- .../executor/antigravity_executor_tokens.go | 2 +- .../runtime/executor/codex_openai_images.go | 2 +- .../executor/helps/model_capabilities.go | 10 +- internal/runtime/executor/helps/thinking.go | 62 ++++++- .../runtime/executor/helps/thinking_test.go | 100 ++++++++++++ internal/runtime/executor/kimi_executor.go | 4 +- internal/thinking/apply.go | 7 + internal/thinking/summary.go | 16 ++ internal/thinking/summary_test.go | 12 ++ test/thinking_conversion_test.go | 153 +++++++++--------- 13 files changed, 277 insertions(+), 99 deletions(-) create mode 100644 internal/runtime/executor/helps/thinking_test.go diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 3cabe5da..3cc37a09 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe 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, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 77bce648..6721bb01 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -68,7 +68,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -290,7 +290,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index d0aa0725..98c7177c 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -63,7 +63,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 45867ee8..523d7d2c 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -50,7 +50,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 3251489e..18ef4418 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -674,7 +674,7 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe mainModel = codexOpenAIImagesMainModel } var errThinking error - out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) + out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) if errThinking != nil { return nil, errThinking } diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index 8bf6723d..fea97c5d 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -9,13 +9,13 @@ import ( // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound an exact configured API-key model definition to this attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { - sourceBody := opts.OriginalRequest - if len(sourceBody) == 0 { - sourceBody = req.Payload + originalSource := opts.OriginalRequest + if len(originalSource) == 0 { + originalSource = req.Payload } + summaryConfig := translatedRequestSummaryConfig(body, req.Payload, originalSource, req.Model, fromFormat, toFormat) if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { - return thinking.ApplyThinkingWithModelInfo(body, sourceBody, req.Model, fromFormat, toFormat, provider, modelInfo) + return thinking.ApplyThinkingWithModelInfoAndSummary(body, originalSource, req.Model, fromFormat, toFormat, provider, modelInfo, summaryConfig) } - summaryConfig := thinking.ExtractSummaryConfig(sourceBody, fromFormat) return thinking.ApplyThinkingWithSummary(body, req.Model, fromFormat, toFormat, provider, summaryConfig) } diff --git a/internal/runtime/executor/helps/thinking.go b/internal/runtime/executor/helps/thinking.go index 49f3155c..9ad7a2e6 100644 --- a/internal/runtime/executor/helps/thinking.go +++ b/internal/runtime/executor/helps/thinking.go @@ -1,12 +1,64 @@ package helps -import "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) // ApplyThinkingWithSourcePayload preserves summary visibility from the original // client payload while applying thinking configuration to its translated target -// payload. A target representation alone can lose an explicit disabled summary -// before a model suffix changes Claude thinking from disabled to adaptive. -func ApplyThinkingWithSourcePayload(body, sourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { - summary := thinking.ExtractSummaryConfig(sourcePayload, fromFormat) +// payload. currentSourcePayload is the payload that was translated, while +// originalSourcePayload retains intent removed by an earlier interceptor. +func ApplyThinkingWithSourcePayload(body, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { + summary := translatedRequestSummaryConfig(body, currentSourcePayload, originalSourcePayload, model, fromFormat, toFormat) return thinking.ApplyThinkingWithSummary(body, model, fromFormat, toFormat, providerKey, summary) } + +// translatedRequestSummaryConfig gives the translated target payload precedence +// so a plugin request normalizer can remove or rewrite a canonical summary field. +// The original source is consulted only when the payload that was translated no +// longer carries the inbound intent, or when the target could not represent that +// intent until model-aware thinking is applied later (notably Claude). +func translatedRequestSummaryConfig(body, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat string) thinking.SummaryConfig { + fromFormat = strings.ToLower(strings.TrimSpace(fromFormat)) + toFormat = strings.ToLower(strings.TrimSpace(toFormat)) + + var targetSummary thinking.SummaryConfig + if fromFormat == toFormat { + targetSummary = thinking.ExtractSummaryConfig(body, toFormat) + } else { + targetSummary = thinking.ExtractExplicitSummaryConfig(body, toFormat) + } + if targetSummary.Mode != thinking.SummaryUnspecified { + return targetSummary + } + + currentSummary := thinking.ExtractSummaryConfig(currentSourcePayload, fromFormat) + originalSummary := thinking.ExtractSummaryConfig(originalSourcePayload, fromFormat) + if currentSummary.Mode == thinking.SummaryUnspecified { + return originalSummary + } + + from := sdktranslator.FromString(fromFormat) + to := sdktranslator.FromString(toFormat) + if !sdktranslator.HasRequestTransformer(from, to) { + // A missing translation must remain source-shaped. Same-format requests + // were handled by targetSummary above, including explicit native aliases. + return thinking.SummaryConfig{} + } + + candidate := thinking.ApplySummaryConfigForModel(body, toFormat, model, currentSummary) + if thinking.ExtractExplicitSummaryConfig(candidate, toFormat).Mode != thinking.SummaryUnspecified { + // Registry translation applied this field before plugin normalization. If + // it is absent now but can be represented on the normalized body, the + // normalizer deliberately removed it and must remain authoritative. + return thinking.SummaryConfig{} + } + + // Some intents cannot be represented until the final model-aware pass. For + // example, Claude display is invalid on disabled thinking, but a suffix can + // subsequently activate adaptive thinking. Preserve the source in that case. + return currentSummary +} diff --git a/internal/runtime/executor/helps/thinking_test.go b/internal/runtime/executor/helps/thinking_test.go new file mode 100644 index 00000000..69b18fda --- /dev/null +++ b/internal/runtime/executor/helps/thinking_test.go @@ -0,0 +1,100 @@ +package helps_test + +import ( + "context" + "testing" + + helps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type summaryRemovingPluginHooks struct { + t *testing.T +} + +func (h *summaryRemovingPluginHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte { + h.t.Helper() + const path = "generationConfig.thinkingConfig.includeThoughts" + if !gjson.GetBytes(body, path).Bool() { + h.t.Fatalf("request normalizer did not receive enabled summary: %s", body) + } + out, _ := sjson.DeleteBytes(body, path) + return out +} + +func (*summaryRemovingPluginHooks) TranslateRequest(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*summaryRemovingPluginHooks) NormalizeResponseBefore(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + +func (*summaryRemovingPluginHooks) TranslateResponse(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*summaryRemovingPluginHooks) NormalizeResponseAfter(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + +func TestApplyThinkingWithSourcePayloadPreservesNormalizerSummaryRemoval(t *testing.T) { + hooks := &summaryRemovingPluginHooks{t: t} + sdktranslator.SetPluginHooks(hooks) + t.Cleanup(func() { sdktranslator.SetPluginHooks(nil) }) + + source := []byte(`{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`) + translated := sdktranslator.TranslateRequest( + sdktranslator.FormatOpenAIResponse, + sdktranslator.FormatGemini, + "gemini-3.6-flash", + source, + false, + ) + const summaryPath = "generationConfig.thinkingConfig.includeThoughts" + if gjson.GetBytes(translated, summaryPath).Exists() { + t.Fatalf("request normalizer did not remove summary: %s", translated) + } + + out, err := helps.ApplyThinkingWithSourcePayload( + translated, + source, + source, + "gemini-3.6-flash", + sdktranslator.FormatOpenAIResponse.String(), + sdktranslator.FormatGemini.String(), + "gemini", + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err) + } + if gjson.GetBytes(out, summaryPath).Exists() { + t.Fatalf("executor restored summary removed by request normalizer: %s", out) + } +} + +func TestApplyThinkingWithSourcePayloadPreservesOriginalOnlySummary(t *testing.T) { + currentSource := []byte(`{"model":"gemini-3.6-flash","input":"hi"}`) + originalSource := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":null},"input":"hi"}`) + body := []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`) + + out, err := helps.ApplyThinkingWithSourcePayload( + body, + currentSource, + originalSource, + "gemini-3.6-flash", + sdktranslator.FormatOpenAIResponse.String(), + sdktranslator.FormatGemini.String(), + "gemini", + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err) + } + if include := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || include.Bool() { + t.Fatalf("original disabled summary was not preserved: %s", out) + } +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index d3c88145..b9a89425 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -113,7 +113,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -222,7 +222,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index a349f269..e9e3d34d 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -183,6 +183,13 @@ func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromForma if len(sourceBody) == 0 { summaryConfig = ExtractSummaryConfig(body, toFormat) } + return ApplyThinkingWithModelInfoAndSummary(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, summaryConfig) +} + +// ApplyThinkingWithModelInfoAndSummary applies the exact configured model +// definition with a summary intent already resolved across source translation +// and plugin normalization. +func ApplyThinkingWithModelInfoAndSummary(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo, summaryConfig SummaryConfig) ([]byte, error) { return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig) } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 17951990..72977ab4 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -114,6 +114,22 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { return SummaryConfig{} } +// ExtractExplicitSummaryConfig reads only explicit visibility controls from a +// provider payload. Unlike ExtractSummaryConfig, OpenAI Chat reasoning_effort +// is not treated as a summary proxy. This lets executor post-processing tell +// whether a request normalizer retained or removed the translated target field. +func ExtractExplicitSummaryConfig(body []byte, format string) SummaryConfig { + normalized := strings.ToLower(strings.TrimSpace(format)) + if normalized != "openai" { + return ExtractSummaryConfig(body, normalized) + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return SummaryConfig{} + } + config, _ := extractOpenAIExplicitSummaryConfig(body) + return config +} + // ApplySummaryConfig writes canonical summary intent in the target protocol. func ApplySummaryConfig(body []byte, format string, config SummaryConfig) []byte { return ApplySummaryConfigForModel(body, format, "", config) diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 84c110c9..e038fc14 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -75,6 +75,18 @@ func TestExtractSummaryConfig(t *testing.T) { } } +func TestExtractExplicitSummaryConfigDoesNotUseChatEffort(t *testing.T) { + body := []byte(`{"reasoning_effort":"high"}`) + if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryUnspecified { + t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want unspecified", got) + } + + body = []byte(`{"reasoning_effort":"high","reasoning":{"exclude":true}}`) + if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryDisabled { + t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want disabled", got) + } +} + func TestApplySummaryConfig(t *testing.T) { tests := []struct { name string diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index d71d6e35..45d709e3 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -241,7 +241,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"level-subset-model(1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 17A: auto → medium → clamped to low when low/high are equally close @@ -277,7 +277,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 20: Effort xhigh → clamped to 20000 (max) @@ -289,10 +289,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 21: Effort none → clamped to 128 (min) → includeThoughts=false + // Case 21: Effort none → clamped to 128 (min) { name: "21", from: "openai", @@ -301,7 +301,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 22: Effort auto → DynamicAllowed=true → -1 @@ -313,7 +313,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 23: Claude source no suffix → passthrough @@ -335,7 +335,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 25: Budget 64000 → clamped to 20000 (max) @@ -347,10 +347,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 26: Budget 0 → clamped to 128 (min) → includeThoughts=false + // Case 26: Budget 0 → clamped to 128 (min) { name: "26", from: "claude", @@ -359,7 +359,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 27: Budget -1 → DynamicAllowed=true → -1 @@ -371,7 +371,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -396,7 +396,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(high)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 30: Effort xhigh → clamped to high @@ -408,10 +408,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 31: Effort none → clamped to low (min supported) → includeThoughts=false + // Case 31: Effort none → clamped to low (min supported) { name: "31", from: "openai", @@ -420,7 +420,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(none)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 32: Effort auto → DynamicAllowed=true → -1 (budget) @@ -432,7 +432,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(auto)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 33: Claude source no suffix → passthrough @@ -454,7 +454,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 35: Budget 64000 → clamped to 32768 (max) @@ -466,10 +466,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "32768", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 36: Budget 0 → minimal → clamped to low (min level) → includeThoughts=false + // Case 36: Budget 0 → minimal → clamped to low (min level) { name: "36", from: "claude", @@ -478,7 +478,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 37: Budget -1 → DynamicAllowed=true → -1 (budget) @@ -490,7 +490,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -626,7 +626,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(medium)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 50: Effort xhigh → clamped to 20000 (max) @@ -638,10 +638,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(xhigh)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 51: Effort none → ZeroAllowed=true → 0 → includeThoughts=false + // Case 51: Effort none → ZeroAllowed=true → 0 { name: "51", from: "gemini", @@ -650,7 +650,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(none)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 52: Effort auto → DynamicAllowed=true → -1 @@ -662,7 +662,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(auto)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 53: Claude to Antigravity no suffix → passthrough @@ -684,7 +684,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 55: Budget 64000 → clamped to 20000 (max) @@ -696,10 +696,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 56: Budget 0 → ZeroAllowed=true → 0 → includeThoughts=false + // Case 56: Budget 0 → ZeroAllowed=true → 0 { name: "56", from: "claude", @@ -708,7 +708,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 57: Budget -1 → DynamicAllowed=true → -1 @@ -720,7 +720,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -927,7 +927,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 77: OpenAI to Claude budget 8192 → passthrough → 8192 @@ -950,7 +950,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 79: OpenAI-Response to Claude budget 8192 → passthrough → 8192 @@ -1018,7 +1018,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 85: Gemini to Gemini, budget 64000 → clamped to Max @@ -1030,7 +1030,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 86: Claude to Claude, budget 8192 → passthrough thinking.budget_tokens @@ -1067,7 +1067,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 89: Gemini to Antigravity, budget 8192 → passthrough (normal value) @@ -1079,7 +1079,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, } @@ -1285,7 +1285,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":1}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1368,7 +1368,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 25: thinking.budget_tokens=64000 → clamped to 20000 @@ -1380,10 +1380,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 26: thinking.budget_tokens=0 → clamped to 128 → includeThoughts=false + // Case 26: thinking.budget_tokens=0 → clamped to 128 { name: "26", from: "claude", @@ -1392,7 +1392,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 27: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1404,7 +1404,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1528,7 +1528,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 35: thinking.budget_tokens=64000 → clamped to 32768 (keeps budget) @@ -1540,10 +1540,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "32768", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 36: thinking.budget_tokens=0 → clamped to low → includeThoughts=false + // Case 36: thinking.budget_tokens=0 → clamped to low { name: "36", from: "claude", @@ -1552,7 +1552,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 37: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1564,7 +1564,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1700,7 +1700,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 50: thinkingLevel=xhigh → clamped to 20000 @@ -1712,7 +1712,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 51: thinkingLevel=none → 0 (ZeroAllowed=true) @@ -1724,7 +1724,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"none"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 52: thinkingBudget=-1 → -1 (DynamicAllowed=true) @@ -1736,7 +1736,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 53: Claude no param → passthrough @@ -1758,7 +1758,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 55: thinking.budget_tokens=64000 → clamped to 20000 @@ -1770,7 +1770,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 56: thinking.budget_tokens=0 → 0 (ZeroAllowed=true) @@ -1782,7 +1782,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 57: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1794,7 +1794,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -2024,7 +2024,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 79: OpenAI-Response reasoning.effort=medium to Claude → 8192 @@ -2092,7 +2092,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 85: Gemini to Gemini, thinkingBudget=64000 → exceeds Max error @@ -2148,7 +2148,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, } @@ -2511,7 +2511,7 @@ func TestThinkingE2EProviderTargets(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","input":"hi","reasoning":{"effort":"medium"}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", }, } @@ -2692,7 +2692,7 @@ func TestThinkingE2EInteractionsMatrix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", }, { name: "OUT6", @@ -2733,7 +2733,7 @@ func TestThinkingE2EInteractionsMatrix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", }, { name: "OUT10", @@ -3084,7 +3084,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3095,7 +3095,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "1024", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3106,7 +3106,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3117,7 +3117,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3128,7 +3128,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3139,7 +3139,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -3201,7 +3201,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -3504,18 +3504,9 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { if tc.to == "antigravity" { path = "request.generationConfig.thinkingConfig.includeThoughts" } - wantIncludeThoughts := "" - summaryConfig := thinking.ExtractSummaryConfig([]byte(tc.inputJSON), tc.from) - switch summaryConfig.Mode { - case thinking.SummaryEnabled: - wantIncludeThoughts = "true" - case thinking.SummaryDisabled: - wantIncludeThoughts = "false" - default: - // Thinking amount does not imply summary visibility. Keep the - // provider field absent when the source omitted its summary control. - } - + // Each case declares its expected visibility independently from the + // extractor under test. Empty means the provider field must be absent. + wantIncludeThoughts := tc.includeThoughts itVal := gjson.GetBytes(body, path) if wantIncludeThoughts == "" { if itVal.Exists() { -- 2.51.2 From c4dcd8703ad964ab7ca1f0c98b74948d7de7d1ce Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 13:28:14 +0800 Subject: [PATCH 8/9] fix(thinking): respect final summary authority --- .../executor/helps/model_capabilities_test.go | 88 ++++++++++++++++++- internal/thinking/apply.go | 11 +++ .../thinking/apply_configured_api_key_test.go | 20 +++++ internal/thinking/summary.go | 28 ++++++ sdk/translator/registry.go | 7 +- sdk/translator/registry_summary_test.go | 53 +++++++++++ 6 files changed, 202 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go index c1e0b371..826c82e9 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 e9e3d34d..92e6161c 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 b056139e..9c48c36d 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 72977ab4..34ae9010 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 830d0355..6e9f0eed 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 1b77b951..16b03216 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 { -- 2.51.2 From 24323ee4b7c33ec7268abf15728fe712888f90c4 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 13:40:41 +0800 Subject: [PATCH 9/9] fix(thinking): drop disabled Interactions summaries --- internal/thinking/provider/interactions/apply.go | 5 ++++- test/summary_intent_translation_test.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go index c644f5ad..b23f0d74 100644 --- a/internal/thinking/provider/interactions/apply.go +++ b/internal/thinking/provider/interactions/apply.go @@ -77,7 +77,10 @@ func applyInteractionsNone(result, original []byte, config thinking.ThinkingConf if config.Budget > 0 { return applyInteractionsBudget(result, original, config.Budget, modelInfo) } - return setInteractionsThinkingSummaries(result, original) + // With the amount fully disabled, visibility is irrelevant. Restoring + // thinking_summaries alone could make a default-on model reason and return a + // summary despite the explicit none override. + return result } func stripInteractionsThinkingFields(body []byte) []byte { diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index cc1724f5..b19f0129 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -144,6 +144,7 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Interactions suffix none removes summary visibility", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "gemini-toggle-mixed-model(none)", body: `{"model":"gemini-toggle-mixed-model(none)","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions reasoning effort leaves Antigravity summaries unspecified", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, {name: "Interactions reasoning summary auto reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, {name: "Interactions reasoning summary none reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"none"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, -- 2.51.2