From 87ceaf83bb702ab4053623ffad78205df7f37d08 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 00:05:59 +0800 Subject: [PATCH] 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 c19369c7c..a349f2690 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 a443491ee..b056139e9 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 968ee09d5..6d2edbfa8 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 c332e9ef2..cc4f071e9 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 5f7d0ead3..179519906 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 +func isOpenRouterProvider(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "openrouter" { + return true } - 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 + for _, part := range strings.FieldsFunc(provider, func(r rune) bool { + return r == '-' || r == '_' || r == '/' || r == '.' || r == ':' + }) { + if part == "openrouter" { + return true } - if normalized == "medium" { - return "medium" - } - 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 da7d7bb21..84c110c9b 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 59ad4b508..cc1724f5b 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 07dfb0396..d71d6e359 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 {