diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index d5913557d04..10733ca5905 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" + "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" @@ -23,6 +24,7 @@ import ( "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/recorder" "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) type BlockingInterception struct { @@ -247,7 +249,7 @@ func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Req } w.Header().Set("Content-Type", "application/json") - out, err := json.Marshal(completion) + out, err := i.marshalCompletion(completion) if err != nil { out, _ = json.Marshal(i.newErrorResponse(xerrors.Errorf("failed to marshal response: %w", err))) w.WriteHeader(http.StatusInternalServerError) @@ -260,6 +262,29 @@ func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Req return nil } +// marshalCompletion renders the final blocking response. Google responses are +// serialized from the raw upstream body, mirroring marshalChunk on the +// streaming path, because the typed round trip drops provider-specific fields +// such as Gemini's extra_content thought metadata, which clients need to +// separate thought output from the answer. The ID and usage overrides applied +// to the typed completion are re-applied on top of the raw body. +func (i *BlockingInterception) marshalCompletion(completion *openai.ChatCompletion) ([]byte, error) { + if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) || completion.RawJSON() == "" { + return json.Marshal(completion) + } + sj, err := sjson.Set(completion.RawJSON(), "id", completion.ID) + if err != nil { + return nil, xerrors.Errorf("marshal completion id failed: %w", err) + } + if completion.Usage.CompletionTokens > 0 { + sj, err = sjson.Set(sj, "usage", completion.Usage) + if err != nil { + return nil, xerrors.Errorf("marshal completion usage failed: %w", err) + } + } + return []byte(sj), nil +} + // newChatCompletion routes by credential type, returning the upstream // completion, the number of key attempts made for this call, and any error. A // centralized key pool fails over across keys, while BYOK authenticates with a diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go new file mode 100644 index 00000000000..06614df15cd --- /dev/null +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -0,0 +1,52 @@ +package chatcompletions + +import ( + "encoding/json" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/aibridge/intercept" +) + +// The typed openai.ChatCompletion round trip drops provider-specific fields, +// so Google blocking responses must be serialized from the raw upstream body +// or Gemini's thought metadata never reaches the client. +func TestBlockingMarshalCompletionPreservesGoogleExtraContent(t *testing.T) { + t.Parallel() + + raw := `{"id":"upstream-id","object":"chat.completion","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hiddenanswer","extra_content":{"google":{"thought":true,"thought_signature":"sig"}}}}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}` + var completion openai.ChatCompletion + require.NoError(t, json.Unmarshal([]byte(raw), &completion)) + completion.ID = "bridge-id" + completion.Usage.CompletionTokens = 7 + + t.Run("GoogleUpstreamKeepsRawFields", func(t *testing.T) { + t.Parallel() + + out, err := (&BlockingInterception{interceptionBase: interceptionBase{ + cfg: intercept.Config{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }}).marshalCompletion(&completion) + require.NoError(t, err) + + require.True(t, gjson.GetBytes(out, "choices.0.message.extra_content.google.thought").Bool()) + require.Equal(t, "sig", gjson.GetBytes(out, "choices.0.message.extra_content.google.thought_signature").String()) + require.Equal(t, "bridge-id", gjson.GetBytes(out, "id").String()) + require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int()) + }) + + t.Run("OtherUpstreamsUseTypedMarshal", func(t *testing.T) { + t.Parallel() + + out, err := (&BlockingInterception{interceptionBase: interceptionBase{ + cfg: intercept.Config{BaseURL: "https://api.openai.com/v1"}, + }}).marshalCompletion(&completion) + require.NoError(t, err) + + require.False(t, gjson.GetBytes(out, "choices.0.message.extra_content").Exists()) + require.Equal(t, "bridge-id", gjson.GetBytes(out, "id").String()) + require.Equal(t, int64(7), gjson.GetBytes(out, "usage.completion_tokens").Int()) + }) +} diff --git a/aibridge/intercept/chatcompletions/google_openai_compat.go b/aibridge/intercept/chatcompletions/google_openai_compat.go index 251cbc71a01..41cce98200e 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat.go @@ -6,7 +6,7 @@ import ( "github.com/openai/openai-go/v3/option" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) { @@ -17,7 +17,17 @@ func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) { if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) { return body, nil } - patched, _, err := googleopenai.PatchThoughtSignatures(body) + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + // Reattach the extra_body passthrough dropped by the typed params so + // Gemini settings such as thinking_config reach Google. + if len(i.req.ExtraBody) > 0 { + payload["extra_body"] = i.req.ExtraBody + } + googleopenai.AddThoughtSignaturesToLatestTurn(payload) + patched, err := json.Marshal(payload) if err != nil { return nil, err } diff --git a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go index a8acd5397ac..fdf659e7a25 100644 --- a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go +++ b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/aibridge/intercept" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func TestGoogleOpenAICompatThoughtSignaturePatchSurvivesParamRoundTrip(t *testing.T) { @@ -98,3 +98,57 @@ func googleThoughtSignatureFromBody(t *testing.T, body []byte, messageIndex int, signature, _ := google["thought_signature"].(string) return signature } + +func TestGoogleOpenAICompatExtraBodySurvivesParamRoundTrip(t *testing.T) { + t.Parallel() + + raw := []byte(`{ + "model":"gemini-3-flash-preview", + "stream":true, + "extra_body":{"google":{"thinking_config":{"include_thoughts":true,"thinking_level":"high"}}}, + "messages":[{"role":"user","content":"current turn"}] + }`) + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal(raw, &req)) + + roundTripped, err := json.Marshal(req.ChatCompletionNewParams) + require.NoError(t, err) + require.NotContains(t, string(roundTripped), "extra_body", + "openai-go drops extra_body during the typed param round-trip") + + body, err := (&interceptionBase{ + req: &req, + cfg: intercept.Config{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }).chatCompletionRequestBody() + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + require.Equal(t, map[string]any{ + "google": map[string]any{ + "thinking_config": map[string]any{ + "include_thoughts": true, + "thinking_level": "high", + }, + }, + }, payload["extra_body"]) +} + +func TestGoogleOpenAICompatExtraBodyNotForwardedToOtherUpstreams(t *testing.T) { + t.Parallel() + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gpt-4o", + "extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}, + "messages":[{"role":"user","content":"current turn"}] + }`), &req)) + + body, err := (&interceptionBase{ + req: &req, + cfg: intercept.Config{BaseURL: "https://api.openai.com/v1"}, + }).chatCompletionRequestBody() + require.NoError(t, err) + require.NotContains(t, string(body), "extra_body") +} diff --git a/aibridge/intercept/chatcompletions/paramswrap.go b/aibridge/intercept/chatcompletions/paramswrap.go index 8b9efbbf4fd..da1d0a3cfa1 100644 --- a/aibridge/intercept/chatcompletions/paramswrap.go +++ b/aibridge/intercept/chatcompletions/paramswrap.go @@ -1,6 +1,8 @@ package chatcompletions import ( + "encoding/json" + "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/packages/param" "github.com/tidwall/gjson" @@ -13,6 +15,11 @@ import ( type ChatCompletionNewParamsWrapper struct { openai.ChatCompletionNewParams `json:""` Stream bool `json:"stream,omitempty"` + // ExtraBody preserves the OpenAI SDK's extra_body passthrough object, + // which the typed params drop on unmarshal. It is forwarded only to + // Google upstreams, which read provider-specific settings such as + // Gemini's thinking_config from it. + ExtraBody json.RawMessage `json:"-"` } func (c ChatCompletionNewParamsWrapper) MarshalJSON() ([]byte, error) { @@ -28,6 +35,10 @@ func (c *ChatCompletionNewParamsWrapper) UnmarshalJSON(raw []byte) error { return err } + if extraBody := gjson.GetBytes(raw, "extra_body"); extraBody.IsObject() { + c.ExtraBody = json.RawMessage(extraBody.Raw) + } + c.Stream = gjson.GetBytes(raw, "stream").Bool() if c.Stream { c.ChatCompletionNewParams.StreamOptions = openai.ChatCompletionStreamOptionsParam{ diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8b3f69ee5c0..e401b50d34c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7505,15 +7505,28 @@ func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallC } func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error { - if options == nil || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil { + if options == nil { return nil } - if strings.TrimSpace(*options.Anthropic.ThinkingDisplay) == "" || - chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) != nil { - return nil + if options.Anthropic != nil && options.Anthropic.ThinkingDisplay != nil && + strings.TrimSpace(*options.Anthropic.ThinkingDisplay) != "" && + chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) == nil { + return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted") + } + + if options.Google != nil && options.Google.ThinkingConfig != nil && + options.Google.ThinkingConfig.ThinkingLevel != nil && + strings.TrimSpace(*options.Google.ThinkingConfig.ThinkingLevel) != "" { + if chatprovider.GoogleThinkingLevelFromChat(options.Google.ThinkingConfig.ThinkingLevel) == nil { + return xerrors.Errorf("provider_options.google.thinking_config.thinking_level must be one of minimal, low, medium, high") + } + if options.Google.ThinkingConfig.ThinkingBudget != nil { + return xerrors.Errorf("provider_options.google.thinking_config.thinking_level cannot be combined with thinking_budget") + } } - return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted") + + return nil } func unmarshalChatModelCallConfig( diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index da8ba38286c..9b134a21b2e 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -283,6 +283,53 @@ func TestValidateChatModelProviderOptions_AnthropicThinkingDisplay(t *testing.T) } } +func TestValidateChatModelProviderOptions_GoogleThinkingLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level *string + budget *int64 + wantErr string + }{ + {name: "Minimal", level: ptr.Ref("minimal")}, + {name: "High", level: ptr.Ref(" HIGH ")}, + {name: "Empty", level: ptr.Ref(" ")}, + {name: "NilLevelWithBudget", budget: ptr.Ref(int64(2048))}, + {name: "EmptyLevelWithBudget", level: ptr.Ref(""), budget: ptr.Ref(int64(2048))}, + { + name: "Invalid", + level: ptr.Ref("ultra"), + wantErr: "provider_options.google.thinking_config.thinking_level must be one of minimal, low, medium, high", + }, + { + name: "LevelWithBudget", + level: ptr.Ref("high"), + budget: ptr.Ref(int64(2048)), + wantErr: "provider_options.google.thinking_config.thinking_level cannot be combined with thinking_budget", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateChatModelProviderOptions(&codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: tt.level, + ThinkingBudget: tt.budget, + }, + }, + }) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + func TestValidateChatModelConfigProviderModel(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index b96180eb54a..3cf24c15dfb 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -810,6 +810,27 @@ func AnthropicThinkingDisplayFromChat(value *string) *fantasyanthropic.ThinkingD return &valueCopy } +// GoogleThinkingLevelFromChat normalizes chat-config thinking level values +// for Google and returns the canonical provider level value. +func GoogleThinkingLevelFromChat(value *string) *fantasygoogle.ThinkingLevel { + if value == nil { + return nil + } + + normalized := strings.ToLower(strings.TrimSpace(*value)) + if normalized == "" { + return nil + } + + return chatutil.NormalizedEnumValue( + normalized, + fantasygoogle.ThinkingLevelMinimal, + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, + ) +} + // Header constants sent on upstream LLM API requests so that // intermediaries (e.g. aibridged) can correlate traffic back to // Coder entities. @@ -1124,7 +1145,12 @@ func providerOptionsFromChatModelConfig( ) } if options.Google != nil { + var modelID string + if model.Valid() { + modelID = model.ModelID() + } result[fantasygoogle.Name] = googleProviderOptionsFromChatConfig( + modelID, options.Google, ) } @@ -1144,6 +1170,18 @@ func providerOptionsFromChatModelConfig( ) } + // Google models backed by an AI Provider route through the + // OpenAI-compatible client, which ignores the fantasygoogle options key, + // so a pinned thinking configuration must also travel as the compat + // request's extra_body for the transport patch to honor it. + if options.Google != nil && options.Google.ThinkingConfig != nil && + model.Valid() && NormalizeProvider(model.Provider()) == fantasyopenaicompat.Name { + if extraBody := googleCompatExtraBodyFromThinkingConfig(model.ModelID(), options.Google.ThinkingConfig); extraBody != nil { + compatOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](result, fantasyopenaicompat.Name) + compatOptions.ExtraBody = extraBody + } + } + if len(result) == 0 { return nil } @@ -1167,6 +1205,7 @@ func anthropicProviderOptionsFromChatConfig( } func googleProviderOptionsFromChatConfig( + modelID string, options *codersdk.ChatModelGoogleProviderOptions, ) *fantasygoogle.ProviderOptions { result := &fantasygoogle.ProviderOptions{ @@ -1178,6 +1217,18 @@ func googleProviderOptionsFromChatConfig( ThinkingBudget: options.ThinkingConfig.ThinkingBudget, IncludeThoughts: options.ThinkingConfig.IncludeThoughts, } + // Each Gemini model accepts a different thinking_level subset and + // pre-Gemini-3 models reject the field entirely, so clamp a pinned + // level into the model's supported set and drop it for models + // without support. Gating here rather than at config save time + // also covers updates that switch a config's model without + // resubmitting options. + if pinned := GoogleThinkingLevelFromChat(options.ThinkingConfig.ThinkingLevel); pinned != nil { + if supported := googleSupportedThinkingLevels(modelID); len(supported) > 0 { + level := clampGoogleThinkingLevel(*pinned, supported) + result.ThinkingConfig.ThinkingLevel = &level + } + } } if options.SafetySettings != nil { result.SafetySettings = make( diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index eccaf06d1cb..92905513c7e 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -405,6 +405,207 @@ func TestProviderOptionsForCall_AnthropicThinkingDisplay(t *testing.T) { require.Equal(t, fantasyanthropic.ThinkingDisplaySummarized, *anthropicOptions.ThinkingDisplay) } +func TestGoogleThinkingLevelFromChat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *string + want *fantasygoogle.ThinkingLevel + }{ + { + name: "Minimal", + input: ptr.Ref(" MINIMAL "), + want: ptr.Ref(fantasygoogle.ThinkingLevelMinimal), + }, + { + name: "Low", + input: ptr.Ref("low"), + want: ptr.Ref(fantasygoogle.ThinkingLevelLow), + }, + { + name: "Medium", + input: ptr.Ref("Medium"), + want: ptr.Ref(fantasygoogle.ThinkingLevelMedium), + }, + { + name: "High", + input: ptr.Ref("high"), + want: ptr.Ref(fantasygoogle.ThinkingLevelHigh), + }, + { + name: "InvalidReturnsNil", + input: ptr.Ref("ultra"), + }, + { + name: "EmptyReturnsNil", + input: ptr.Ref(" "), + }, + { + name: "NilInputReturnsNil", + input: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := chatprovider.GoogleThinkingLevelFromChat(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestProviderOptionsForCall_GoogleThinkingConfig(t *testing.T) { + t.Parallel() + + googleModel := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3.7-flash", + }, nil) + + t.Run("PinnedLevelWithoutEffortConfig", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref(" MEDIUM "), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelMedium, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("PinnedLevelDroppedForGemini25", func(t *testing.T) { + t.Parallel() + + gemini25 := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-2.5-flash", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini25, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("medium"), + IncludeThoughts: ptr.Ref(true), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.True(t, *googleOptions.ThinkingConfig.IncludeThoughts) + }) + + t.Run("PinnedLevelClampedForGemini3Pro", func(t *testing.T) { + t.Parallel() + + gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3-pro-preview", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("minimal"), + }, + }, + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelLow, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("EffortClampedForGemini3Pro", func(t *testing.T) { + t.Parallel() + + gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{ + ProviderName: fantasygoogle.Name, + ModelName: "gemini-3-pro-preview", + }, nil) + + providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, ptr.Ref(codersdk.ChatModelReasoningEffortMedium)) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("medium"), + }, + }, + }, + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, ptr.Ref(codersdk.ChatModelReasoningEffortHigh)) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel) + }) + + t.Run("ConfiguredBudgetSuppressesEffortLevel", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingBudget: ptr.Ref(int64(2048)), + }, + }, + }, + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, nil) + + googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok) + require.NotNil(t, googleOptions.ThinkingConfig) + require.Equal(t, int64(2048), *googleOptions.ThinkingConfig.ThinkingBudget) + require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel) + }) +} + func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking.go b/coderd/x/chatd/chatprovider/google_compat_thinking.go new file mode 100644 index 00000000000..795936f4a3f --- /dev/null +++ b/coderd/x/chatd/chatprovider/google_compat_thinking.go @@ -0,0 +1,178 @@ +package chatprovider + +import ( + "slices" + "strconv" + "strings" + + fantasygoogle "charm.land/fantasy/providers/google" + + "github.com/coder/coder/v2/codersdk" +) + +// rewriteGoogleCompatThinkingConfig swaps reasoning_effort for an explicit +// Google thinking_config on requests for thinking-capable Gemini models. +// Google's OpenAI-compatible endpoint rejects requests carrying both fields, +// and it never emits thought text unless include_thoughts is requested +// through thinking_config, so this is the only way to surface Gemini +// reasoning in the chat UI on this path. Models without known thinking +// support (pre-2.5 or unrecognized Gemini variants) keep their previous +// request shape untouched. +func rewriteGoogleCompatThinkingConfig(payload map[string]any) bool { + modelID, _ := payload["model"].(string) + normalized, supported, capable := googleCompatThinkingSupport(modelID) + if !capable { + return false + } + + thinkingConfig := map[string]any{"include_thoughts": true} + if effort, ok := payload["reasoning_effort"].(string); ok { + if len(supported) > 0 { + level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported) + thinkingConfig["thinking_level"] = strings.ToLower(level) + } else if budget, ok := googleCompatThinkingBudget(normalized, effort); ok { + thinkingConfig["thinking_budget"] = budget + } else { + // Unknown effort value: keep the request untouched rather than + // guessing a thinking budget. + return false + } + } + + extraBody, _ := payload["extra_body"].(map[string]any) + if extraBody == nil { + extraBody = map[string]any{} + payload["extra_body"] = extraBody + } + google, _ := extraBody["google"].(map[string]any) + if google == nil { + google = map[string]any{} + extraBody["google"] = google + } + // Merge with a config-pinned thinking_config using the same precedence + // as the native Google path: a pinned thinking_budget wins over the + // per-turn effort, while the effort overrides a pinned thinking_level. + // reasoning_effort must go in every case because Google rejects it in + // combination with thinking_config. + if pinned, ok := google["thinking_config"].(map[string]any); ok { + if _, hasBudget := pinned["thinking_budget"]; !hasBudget { + if level, ok := thinkingConfig["thinking_level"]; ok { + pinned["thinking_level"] = level + } else if budget, ok := thinkingConfig["thinking_budget"]; ok { + pinned["thinking_budget"] = budget + } + } + } else { + google["thinking_config"] = thinkingConfig + } + delete(payload, "reasoning_effort") + return true +} + +// googleCompatThinkingSupport reports whether a model ID on the +// OpenAI-compatible path is a thinking-capable Gemini model, returning the +// normalized model ID and its supported thinking levels (empty for the +// budget-based 2.5 families). +func googleCompatThinkingSupport(modelID string) (string, []fantasygoogle.ThinkingLevel, bool) { + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") + normalized = strings.TrimPrefix(normalized, "google/") + if !strings.HasPrefix(normalized, "gemini-") { + return "", nil, false + } + supported := googleSupportedThinkingLevels(normalized) + if len(supported) == 0 && !googleSupportsThinkingBudget(normalized) { + return "", nil, false + } + return normalized, supported, true +} + +// googleCompatExtraBodyFromThinkingConfig translates a config-pinned Google +// thinking configuration into the extra_body payload for Gemini models routed +// through the OpenAI-compatible client, which ignores the native Google +// provider options. include_thoughts defaults to enabled so pinned +// configurations still surface thinking in the chat UI. Returns nil when the +// model has no thinking support. +func googleCompatExtraBodyFromThinkingConfig( + modelID string, + config *codersdk.ChatModelGoogleThinkingConfig, +) map[string]any { + if config == nil { + return nil + } + _, supported, capable := googleCompatThinkingSupport(modelID) + if !capable { + return nil + } + + includeThoughts := true + if config.IncludeThoughts != nil { + includeThoughts = *config.IncludeThoughts + } + thinkingConfig := map[string]any{"include_thoughts": includeThoughts} + if config.ThinkingBudget != nil { + thinkingConfig["thinking_budget"] = *config.ThinkingBudget + } else if pinned := GoogleThinkingLevelFromChat(config.ThinkingLevel); pinned != nil && len(supported) > 0 { + level := clampGoogleThinkingLevel(*pinned, supported) + thinkingConfig["thinking_level"] = strings.ToLower(level) + } + return map[string]any{ + "extra_body": map[string]any{ + "google": map[string]any{"thinking_config": thinkingConfig}, + }, + } +} + +// googleSupportsThinkingBudget reports whether a Gemini model predating +// thinking_level thinks via thinking_budget. Only the Gemini 2.5 Pro, Flash, +// and Flash-Lite chat families qualify; specialized 2.5 variants such as +// image and TTS models reject thinking_config outright ("Thinking is not +// enabled for this model", verified live). Unrecognized name tokens fail +// closed so new specialized variants keep their previous request shape. +func googleSupportsThinkingBudget(normalized string) bool { + rest, ok := strings.CutPrefix(normalized, "gemini-") + if !ok { + return false + } + segments := strings.Split(rest, "-") + major, minor, hasVersion := parseGoogleModelVersion(segments[0]) + if !hasVersion || major != 2 || minor != 5 { + return false + } + family := false + for _, segment := range segments[1:] { + switch segment { + case "pro", "flash": + family = true + case "lite", "preview", "exp", "latest": + default: + if _, err := strconv.Atoi(segment); err != nil { + return false + } + } + } + return family +} + +// googleCompatThinkingBudget maps the global reasoning effort scale onto the +// thinking budgets Google's OpenAI-compatible endpoint uses when translating +// reasoning_effort for pre-Gemini-3 models, keeping effort semantics intact +// when reasoning_effort is replaced by an explicit thinking_config. Pro +// models cannot disable thinking (budget 0 is rejected: "This model only +// works in thinking mode"), so "none" clamps up to the low budget for them. +func googleCompatThinkingBudget(modelID string, effort string) (int, bool) { + switch effort { + case codersdk.ChatModelReasoningEffortNone: + if slices.Contains(strings.Split(strings.TrimPrefix(modelID, "gemini-"), "-"), "pro") { + return 1024, true + } + return 0, true + case codersdk.ChatModelReasoningEffortMinimal, codersdk.ChatModelReasoningEffortLow: + return 1024, true + case codersdk.ChatModelReasoningEffortMedium: + return 8192, true + case codersdk.ChatModelReasoningEffortHigh, codersdk.ChatModelReasoningEffortXHigh, codersdk.ChatModelReasoningEffortMax: + return 24576, true + default: + return 0, false + } +} diff --git a/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go new file mode 100644 index 00000000000..9c6c034e2cf --- /dev/null +++ b/coderd/x/chatd/chatprovider/google_compat_thinking_internal_test.go @@ -0,0 +1,241 @@ +package chatprovider + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestRewriteGoogleCompatThinkingConfig(t *testing.T) { + t.Parallel() + + thinkingConfig := func(payload map[string]any) map[string]any { + extraBody, _ := payload["extra_body"].(map[string]any) + google, _ := extraBody["google"].(map[string]any) + config, _ := google["thinking_config"].(map[string]any) + return config + } + + t.Run("Gemini3EffortBecomesThinkingLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-3-flash-preview", "reasoning_effort": "medium"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "medium"}, thinkingConfig(payload)) + }) + + t.Run("Gemini3ProClampsUnsupportedLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-3.0-pro", "reasoning_effort": "minimal"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "low"}, thinkingConfig(payload)) + }) + + t.Run("PreGemini3EffortBecomesThinkingBudget", func(t *testing.T) { + t.Parallel() + for _, modelID := range []string{ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-preview-09-2025", + "gemini-2.5-pro-preview-06-05", + } { + payload := map[string]any{"model": modelID, "reasoning_effort": "medium"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload), modelID) + require.NotContains(t, payload, "reasoning_effort", modelID) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, thinkingConfig(payload), modelID) + } + }) + + t.Run("EffortNoneBudget", func(t *testing.T) { + t.Parallel() + // Flash can disable thinking; Pro rejects budget 0 ("This model only + // works in thinking mode", verified live) so none clamps to low. + payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "none"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 0}, thinkingConfig(payload)) + + payload = map[string]any{"model": "gemini-2.5-pro", "reasoning_effort": "none"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 1024}, thinkingConfig(payload)) + }) + + t.Run("NoEffortStillIncludesThoughts", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-2.5-flash"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true}, thinkingConfig(payload)) + }) + + t.Run("ProviderPrefixedModelID", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "google/gemini-3.1-pro-preview", "reasoning_effort": "high"} + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "high"}, thinkingConfig(payload)) + }) + + t.Run("ExplicitThinkingConfigWins", func(t *testing.T) { + t.Parallel() + pinned := map[string]any{"thinking_budget": float64(128)} + payload := map[string]any{ + "model": "gemini-2.5-pro", + "reasoning_effort": "high", + "extra_body": map[string]any{"google": map[string]any{"thinking_config": pinned}}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + require.Equal(t, pinned, thinkingConfig(payload)) + }) + + t.Run("NonGeminiUntouched", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gpt-4o", "reasoning_effort": "high"} + require.False(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Equal(t, map[string]any{"model": "gpt-4o", "reasoning_effort": "high"}, payload) + }) + + t.Run("UnknownEffortUntouched", func(t *testing.T) { + t.Parallel() + payload := map[string]any{"model": "gemini-2.5-flash", "reasoning_effort": "turbo"} + require.False(t, rewriteGoogleCompatThinkingConfig(payload)) + require.Contains(t, payload, "reasoning_effort") + }) +} + +func TestRewriteGoogleCompatThinkingConfig_NonThinkingModelsUntouched(t *testing.T) { + t.Parallel() + + // Models without known thinking support must keep their previous request + // shape, whether or not an effort was configured. + for _, modelID := range []string{ + "gemini-1.5-flash", + "gemini-2.0-flash", + "gemini-exp-1206", + // Specialized 2.5 variants reject thinking_config ("Thinking is not + // enabled for this model") even though their version matches the + // thinking-capable 2.5 chat families. + "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview", + "gemini-2.5-flash-preview-tts", + "gemini-2.5-flash-native-audio-preview-09-2025", + "gemini-2.5-computer-use-preview-10-2025", + // Specialized Gemini 3 variants likewise reject thinking_level or + // do not serve generateContent at all. + "gemini-3.1-flash-live-preview", + "gemini-3.1-flash-tts-preview", + "gemini-omni-flash-preview", + } { + t.Run(modelID, func(t *testing.T) { + t.Parallel() + + plain := map[string]any{"model": modelID} + require.False(t, rewriteGoogleCompatThinkingConfig(plain)) + require.NotContains(t, plain, "extra_body") + + withEffort := map[string]any{"model": modelID, "reasoning_effort": "low"} + require.False(t, rewriteGoogleCompatThinkingConfig(withEffort)) + require.Equal(t, "low", withEffort["reasoning_effort"]) + require.NotContains(t, withEffort, "extra_body") + }) + } +} + +func TestGoogleCompatExtraBodyFromThinkingConfig(t *testing.T) { + t.Parallel() + + int64Ptr := func(v int64) *int64 { return &v } + strPtr := func(v string) *string { return &v } + boolPtr := func(v bool) *bool { return &v } + thinkingConfig := func(extraBody map[string]any) map[string]any { + body, _ := extraBody["extra_body"].(map[string]any) + google, _ := body["google"].(map[string]any) + config, _ := google["thinking_config"].(map[string]any) + return config + } + + t.Run("PinnedLevelClampedWithThoughtsDefaultOn", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-3.0-pro", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("minimal"), + }) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_level": "low"}, thinkingConfig(extraBody)) + }) + + t.Run("PinnedBudgetWithExplicitThoughtsOff", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingBudget: int64Ptr(2048), + IncludeThoughts: boolPtr(false), + }) + require.Equal(t, map[string]any{"include_thoughts": false, "thinking_budget": int64(2048)}, thinkingConfig(extraBody)) + }) + + t.Run("PinnedLevelDroppedForBudgetModels", func(t *testing.T) { + t.Parallel() + extraBody := googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("high"), + }) + require.Equal(t, map[string]any{"include_thoughts": true}, thinkingConfig(extraBody)) + }) + + t.Run("NonThinkingModelNil", func(t *testing.T) { + t.Parallel() + require.Nil(t, googleCompatExtraBodyFromThinkingConfig("gemini-2.5-flash-image", &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: strPtr("high"), + })) + }) + + t.Run("NilConfigNil", func(t *testing.T) { + t.Parallel() + require.Nil(t, googleCompatExtraBodyFromThinkingConfig("gemini-3-flash-preview", nil)) + }) +} + +func TestRewriteGoogleCompatThinkingConfig_PinnedConfigPrecedence(t *testing.T) { + t.Parallel() + + t.Run("EffortOverridesPinnedLevel", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-3-flash-preview", + "reasoning_effort": "low", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": false, "thinking_level": "high"}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": false, "thinking_level": "low"}, google["thinking_config"]) + }) + + t.Run("PinnedBudgetWinsOverEffort", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-2.5-flash", + "reasoning_effort": "high", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": true, "thinking_budget": int64(2048)}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + require.NotContains(t, payload, "reasoning_effort") + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": int64(2048)}, google["thinking_config"]) + }) + + t.Run("EffortAddsBudgetToPinnedThoughtsOnly", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "model": "gemini-2.5-flash", + "reasoning_effort": "medium", + "extra_body": map[string]any{"google": map[string]any{ + "thinking_config": map[string]any{"include_thoughts": true}, + }}, + } + require.True(t, rewriteGoogleCompatThinkingConfig(payload)) + google := payload["extra_body"].(map[string]any)["google"].(map[string]any) + require.Equal(t, map[string]any{"include_thoughts": true, "thinking_budget": 8192}, google["thinking_config"]) + }) +} diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go index 8c60f2a16b6..e9bd5fe143e 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -7,12 +7,13 @@ import ( "net/http" "strings" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) // OpenAI-compatible providers share an API shape but differ in the exact JSON -// they accept. These patches adjust Fantasy's serialized request body at the -// transport boundary so higher-level generation code can stay provider agnostic. +// they accept and emit. These patches adjust Fantasy's serialized request body +// and, for Gemini endpoints, the response body at the transport boundary so +// higher-level generation code can stay provider agnostic. func withOpenAICompatRequestPatches( client *http.Client, @@ -64,7 +65,11 @@ func (t *openAICompatRequestPatchTransport) RoundTrip(req *http.Request) (*http. return io.NopCloser(bytes.NewReader(patched)), nil } - return base.RoundTrip(patchedReq) + resp, err := base.RoundTrip(patchedReq) + if err == nil && googleopenai.ShouldPatchOpenAICompatRequest(t.BaseURL, t.ModelID) { + googleopenai.RewriteThoughtResponse(resp) + } + return resp, err } func (t *openAICompatRequestPatchTransport) base() http.RoundTripper { @@ -90,6 +95,7 @@ func patchOpenAICompatChatCompletionsBody(body []byte, baseURL string, modelID s changed := rewriteOpenAICompatSingleToolChoice(payload) if googleopenai.ShouldPatchOpenAICompatRequest(baseURL, modelID) { changed = googleopenai.AddThoughtSignaturesToLatestTurn(payload) || changed + changed = rewriteGoogleCompatThinkingConfig(payload) || changed } if !changed { return body diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go index e8d5194ef89..07cfffcb3cd 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -8,11 +8,14 @@ import ( "testing" "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" + "github.com/coder/coder/v2/codersdk" ) func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) { @@ -49,7 +52,7 @@ func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) { }) } -func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) map[string]any { +func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string, providerOptions ...fantasy.ProviderOptions) map[string]any { t.Helper() transport := &captureChatCompletionTransport{} @@ -71,9 +74,13 @@ func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) m ) require.NoError(t, err) - _, err = model.LanguageModel().Generate(t.Context(), fantasy.Call{ + call := fantasy.Call{ Prompt: geminiOpenAICompatToolPrompt(), - }) + } + if len(providerOptions) > 0 { + call.ProviderOptions = providerOptions[0] + } + _, err = model.LanguageModel().Generate(t.Context(), call) require.NoError(t, err) require.NotNil(t, transport.body) return transport.body @@ -184,3 +191,185 @@ func thoughtSignature(t *testing.T, rawMessage any, toolCallIndex int) string { signature, _ := google["thought_signature"].(string) return signature } + +func TestModelFromConfig_GeminiOpenAICompatThinkingConfig(t *testing.T) { + t.Parallel() + + effort := fantasyopenai.ReasoningEffort("medium") + effortOptions := fantasy.ProviderOptions{ + fantasyopenaicompat.Name: &fantasyopenaicompat.ProviderOptions{ReasoningEffort: &effort}, + } + + t.Run("Gemini endpoint swaps reasoning_effort for thinking_config", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://generativelanguage.googleapis.com/v1beta/openai/", "gemini-3-flash-preview", effortOptions) + + require.NotContains(t, body, "reasoning_effort") + extraBody := body["extra_body"].(map[string]any) + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": true, + "thinking_level": "medium", + }, google["thinking_config"]) + }) + + t.Run("Coder AI Bridge Gemini route swaps reasoning_effort for thinking_config", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "http://coder-aibridge/v1", "gemini-2.5-flash", effortOptions) + + require.NotContains(t, body, "reasoning_effort") + extraBody := body["extra_body"].(map[string]any) + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": true, + "thinking_budget": float64(8192), + }, google["thinking_config"]) + }) + + t.Run("Vercel OpenAI-compatible Gemini route is unchanged", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://gateway.vercel.ai/v1", "google/gemini-3-flash-preview", effortOptions) + + require.Equal(t, "medium", body["reasoning_effort"]) + require.NotContains(t, body, "extra_body") + }) +} + +func TestModelFromConfig_GeminiOpenAICompatThoughtStreaming(t *testing.T) { + t.Parallel() + + stream := strings.Join([]string{ + `data: {"choices":[{"delta":{"role":"assistant","content":"**Calculating**\n\nStep one.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"More thinking.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"The answer is "},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"391."},"index":0,"finish_reason":"stop"}],"object":"chat.completion.chunk"}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + transport := &sseChatCompletionTransport{stream: stream} + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + "gemini-3-flash-preview", + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: "https://generativelanguage.googleapis.com/v1beta/openai/", + }, + }, + chatprovider.UserAgent(), + nil, + &http.Client{Transport: transport}, + nil, + ) + require.NoError(t, err) + + parts, err := model.LanguageModel().Stream(t.Context(), fantasy.Call{ + Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "What is 17*23?"}}, + }}, + }) + require.NoError(t, err) + + var reasoning, text strings.Builder + sawReasoningEnd := false + for part := range parts { + switch part.Type { + case fantasy.StreamPartTypeReasoningDelta: + _, _ = reasoning.WriteString(part.Delta) + case fantasy.StreamPartTypeReasoningEnd: + sawReasoningEnd = true + case fantasy.StreamPartTypeTextDelta: + _, _ = text.WriteString(part.Delta) + } + } + + require.Equal(t, "**Calculating**\n\nStep one.More thinking.", reasoning.String()) + require.True(t, sawReasoningEnd) + require.Equal(t, "The answer is 391.", text.String()) +} + +type sseChatCompletionTransport struct { + stream string +} + +func (st *sseChatCompletionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + _, _ = io.ReadAll(req.Body) + _ = req.Body.Close() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + }, + Body: io.NopCloser(strings.NewReader(st.stream)), + }, nil +} + +func TestModelFromConfig_GeminiOpenAICompatPinnedThinkingConfig(t *testing.T) { + t.Parallel() + + transport := &captureChatCompletionTransport{} + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + "gemini-3-flash-preview", + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: "http://coder-aibridge/v1", + }, + }, + chatprovider.UserAgent(), + nil, + &http.Client{Transport: transport}, + nil, + ) + require.NoError(t, err) + + // A Google-configured pinned thinking level must reach the compat + // request as extra_body, and the per-turn effort must override it. + requestedEffort := "low" + providerOptions := chatprovider.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: &requestedEffort, + Max: ptr.Ref("max"), + }, + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Google: &codersdk.ChatModelGoogleProviderOptions{ + ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{ + ThinkingLevel: ptr.Ref("high"), + IncludeThoughts: ptr.Ref(false), + }, + }, + }, + }, &requestedEffort) + + _, err = model.LanguageModel().Generate(t.Context(), fantasy.Call{ + Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "current turn"}}, + }}, + ProviderOptions: providerOptions, + }) + require.NoError(t, err) + require.NotNil(t, transport.body) + + require.NotContains(t, transport.body, "reasoning_effort") + extraBody, ok := transport.body["extra_body"].(map[string]any) + require.True(t, ok, "pinned Google thinking config must reach the request as extra_body") + google := extraBody["google"].(map[string]any) + require.Equal(t, map[string]any{ + "include_thoughts": false, + "thinking_level": "low", + }, google["thinking_config"]) +} diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 52e8a016067..89f2e4e0fb9 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -2,11 +2,14 @@ package chatprovider import ( "slices" + "strconv" + "strings" "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" fantasyazure "charm.land/fantasy/providers/azure" fantasybedrock "charm.land/fantasy/providers/bedrock" + fantasygoogle "charm.land/fantasy/providers/google" fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" fantasyopenrouter "charm.land/fantasy/providers/openrouter" @@ -122,8 +125,37 @@ func applyReasoningEffort( providerEffort := fantasyanthropic.Effort(*effort) providerOptions := ensureProviderOptions[fantasyanthropic.ProviderOptions](options, fantasyanthropic.Name) providerOptions.Effort = &providerEffort + case fantasygoogle.Name: + // Only Gemini 3+ accepts thinking_level; older generations reject + // requests carrying it, so keep dropping the effort for them. + supported := googleSupportedThinkingLevels(model.ModelID()) + if len(supported) == 0 { + return options + } + providerOptions := ensureProviderOptions[fantasygoogle.ProviderOptions](options, fantasygoogle.Name) + if providerOptions.ThinkingConfig == nil { + providerOptions.ThinkingConfig = &fantasygoogle.ThinkingConfig{} + } + // A configured thinking budget wins: fantasy rejects requests that + // set both thinking_budget and thinking_level. The resolved effort + // overrides a config-pinned thinking_level so the user's effort + // selection stays meaningful. + if providerOptions.ThinkingConfig.ThinkingBudget == nil { + level := clampGoogleThinkingLevel(googleThinkingLevel(*effort), supported) + providerOptions.ThinkingConfig.ThinkingLevel = &level + } + // Google returns thought summaries only when they are requested, so + // default them on for reasoning-effort generations; an explicitly + // configured include_thoughts (either value) is preserved. + if providerOptions.ThinkingConfig.IncludeThoughts == nil { + includeThoughts := true + providerOptions.ThinkingConfig.IncludeThoughts = &includeThoughts + } case fantasyopenaicompat.Name: providerEffort := fantasyopenai.ReasoningEffort(*effort) + if compatEffort, ok := googleCompatReasoningEffort(model.ModelID(), *effort); ok { + providerEffort = fantasyopenai.ReasoningEffort(compatEffort) + } providerOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](options, fantasyopenaicompat.Name) providerOptions.ReasoningEffort = &providerEffort case fantasyopenrouter.Name: @@ -144,6 +176,189 @@ func applyReasoningEffort( return options } +// googleCompatReasoningEffort maps the global reasoning effort scale onto the +// reasoning_effort values a Gemini model accepts behind an OpenAI-compatible +// endpoint, reporting ok=false for non-Gemini model IDs. Google's compat +// layer translates reasoning_effort into the model's thinking configuration +// but validates instead of clamping, so out-of-range values (including the +// Coder-only xhigh and max) fail the whole request with HTTP 400. +func googleCompatReasoningEffort(modelID, effort string) (string, bool) { + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") + normalized = strings.TrimPrefix(normalized, "google/") + if !strings.HasPrefix(normalized, "gemini-") { + return "", false + } + if supported := googleSupportedThinkingLevels(normalized); len(supported) > 0 { + level := clampGoogleThinkingLevel(googleThinkingLevel(effort), supported) + return strings.ToLower(level), true + } + // Pre-Gemini-3 models translate reasoning_effort into a thinking budget + // and accept only none/low/medium/high. Pro models cannot disable + // thinking, so "none" is rejected for them and clamps up to low. + isPro := slices.Contains(strings.Split(strings.TrimPrefix(normalized, "gemini-"), "-"), "pro") + switch effort { + case codersdk.ChatModelReasoningEffortNone: + if isPro { + return codersdk.ChatModelReasoningEffortLow, true + } + return codersdk.ChatModelReasoningEffortNone, true + case codersdk.ChatModelReasoningEffortMinimal, codersdk.ChatModelReasoningEffortLow: + return codersdk.ChatModelReasoningEffortLow, true + case codersdk.ChatModelReasoningEffortMedium: + return codersdk.ChatModelReasoningEffortMedium, true + default: + return codersdk.ChatModelReasoningEffortHigh, true + } +} + +// googleThinkingLevelsAscending orders Google thinking levels from least to +// most thinking, for clamping into a model's supported subset. +var googleThinkingLevelsAscending = []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelMinimal, + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, +} + +// googleSupportedThinkingLevels returns the thinking_level values the Google +// model accepts in ascending order, or nil when the model does not accept +// thinking_level at all. Gemini introduced thinking_level in version 3, and +// each model supports a different subset: Gemini 3 Pro launched with LOW and +// HIGH, 3.1 Pro added MEDIUM, the Flash family accepted all four through 3.6 +// but 3.7 dropped MINIMAL, and image models accept only HIGH (plus MINIMAL +// for flash). Versionless "-latest" aliases track the newest release of their +// family, which has been Gemini 3+ since the aliases were introduced. +// Only the Pro and Flash chat families qualify; specialized variants such +// as live, TTS, and audio models reject thinking_level or generateContent +// entirely (verified live), so unrecognized name tokens fail closed and +// keep the previous request shape, matching googleSupportsThinkingBudget. +func googleSupportedThinkingLevels(modelID string) []fantasygoogle.ThinkingLevel { + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(modelID)), "models/") + rest, ok := strings.CutPrefix(normalized, "gemini-") + if !ok { + return nil + } + segments := strings.Split(rest, "-") + + major, minor, hasVersion := parseGoogleModelVersion(segments[0]) + isLatestAlias := !hasVersion && segments[len(segments)-1] == "latest" + if hasVersion && major < 3 { + return nil + } + if !hasVersion && !isLatestAlias { + return nil + } + + family := segments + if hasVersion { + family = segments[1:] + } + isPro, isFlash, isImage := false, false, false + for _, segment := range family { + switch segment { + case "pro": + isPro = true + case "flash": + isFlash = true + case "image": + isImage = true + case "lite", "preview", "exp", "latest": + default: + if _, err := strconv.Atoi(segment); err != nil { + return nil + } + } + } + if !isPro && !isFlash { + return nil + } + + switch { + case isImage && isFlash: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelMinimal, + fantasygoogle.ThinkingLevelHigh, + } + case isImage: + return []fantasygoogle.ThinkingLevel{fantasygoogle.ThinkingLevelHigh} + case isFlash: + // Gemini 3.7 Flash rejects MINIMAL with HTTP 400 while 3 through + // 3.6 flash and flash-lite models accept it (live-verified + // 2026-08-20). Latest aliases and unknown future versions stay + // off MINIMAL too: gemini-flash-latest already tracks 3.7 and + // rejects it, and clamping up to LOW is accepted everywhere + // while an unsupported level fails the whole request. + if isLatestAlias || major > 3 || minor >= 7 { + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, + } + } + return slices.Clone(googleThinkingLevelsAscending) + case hasVersion && major == 3 && minor == 0: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelHigh, + } + default: + return []fantasygoogle.ThinkingLevel{ + fantasygoogle.ThinkingLevelLow, + fantasygoogle.ThinkingLevelMedium, + fantasygoogle.ThinkingLevelHigh, + } + } +} + +// parseGoogleModelVersion parses a Gemini version segment such as "3" or +// "3.7" into major and minor components. +func parseGoogleModelVersion(segment string) (major, minor int, ok bool) { + majorText, minorText, hasMinor := strings.Cut(segment, ".") + major, err := strconv.Atoi(majorText) + if err != nil { + return 0, 0, false + } + if hasMinor { + minor, err = strconv.Atoi(minorText) + if err != nil { + return 0, 0, false + } + } + return major, minor, true +} + +// clampGoogleThinkingLevel snaps the desired level into the model's supported +// subset: the lowest supported level at or above the desired one, so at least +// the requested reasoning depth is preserved, else the highest supported. +func clampGoogleThinkingLevel( + desired fantasygoogle.ThinkingLevel, + supported []fantasygoogle.ThinkingLevel, +) fantasygoogle.ThinkingLevel { + desiredRank := slices.Index(googleThinkingLevelsAscending, desired) + for _, candidate := range supported { + if slices.Index(googleThinkingLevelsAscending, candidate) >= desiredRank { + return candidate + } + } + return supported[len(supported)-1] +} + +// googleThinkingLevel maps the global reasoning effort scale to Google +// thinking levels. Google offers no way to disable thinking on Gemini 3+ +// models and no levels above HIGH, so the scale clamps at both ends. +func googleThinkingLevel(effort string) fantasygoogle.ThinkingLevel { + switch effort { + case codersdk.ChatModelReasoningEffortNone, codersdk.ChatModelReasoningEffortMinimal: + return fantasygoogle.ThinkingLevelMinimal + case codersdk.ChatModelReasoningEffortLow: + return fantasygoogle.ThinkingLevelLow + case codersdk.ChatModelReasoningEffortMedium: + return fantasygoogle.ThinkingLevelMedium + default: + return fantasygoogle.ThinkingLevelHigh + } +} + func ensureProviderOptions[T any, PT interface { *T fantasy.ProviderOptionsData diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go index b7b4110a58d..004baca7a15 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -6,6 +6,7 @@ import ( "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasygoogle "charm.land/fantasy/providers/google" fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" fantasyopenrouter "charm.land/fantasy/providers/openrouter" @@ -67,10 +68,11 @@ func TestApplyReasoningEffort(t *testing.T) { }) tests := []struct { - name string - provider string - options fantasy.ProviderOptions - assert func(*testing.T, fantasy.ProviderOptions) + name string + provider string + modelName string + options fantasy.ProviderOptions + assert func(*testing.T, fantasy.ProviderOptions) }{ { name: "CreatesAnthropicEntry", @@ -92,6 +94,65 @@ func TestApplyReasoningEffort(t *testing.T) { require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) }, }, + { + name: "CreatesGoogleEntry", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, ok, "%T", got[fantasygoogle.Name]) + require.NotNil(t, providerOptions.ThinkingConfig) + require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + // Google returns thought summaries only when requested, so + // effort generations default include_thoughts on. + require.NotNil(t, providerOptions.ThinkingConfig.IncludeThoughts) + require.True(t, *providerOptions.ThinkingConfig.IncludeThoughts) + }, + }, + { + name: "PreservesGoogleEntry", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(true)}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.True(t, *providerOptions.ThinkingConfig.IncludeThoughts) + require.NotNil(t, providerOptions.ThinkingConfig.ThinkingLevel) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, + { + name: "GoogleExplicitBudgetWins", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{ThinkingBudget: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.Equal(t, int64(1024), *providerOptions.ThinkingConfig.ThinkingBudget) + require.Nil(t, providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, + { + name: "GoogleExplicitThoughtsOffPreserved", + provider: fantasygoogle.Name, + modelName: "gemini-3.7-flash", + options: fantasy.ProviderOptions{fantasygoogle.Name: &fantasygoogle.ProviderOptions{ThinkingConfig: &fantasygoogle.ThinkingConfig{IncludeThoughts: ptr.Ref(false)}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasygoogle.Name].(*fantasygoogle.ProviderOptions) + require.NotNil(t, providerOptions.ThinkingConfig.IncludeThoughts) + require.False(t, *providerOptions.ThinkingConfig.IncludeThoughts) + require.Equal(t, fantasygoogle.ThinkingLevelHigh, *providerOptions.ThinkingConfig.ThinkingLevel) + }, + }, + { + name: "GoogleGemini25GetsNoLevel", + provider: fantasygoogle.Name, + modelName: "gemini-2.5-flash", + assert: func(t *testing.T, got fantasy.ProviderOptions) { + require.Nil(t, got[fantasygoogle.Name]) + }, + }, { name: "CreatesOpenAICompatEntry", provider: fantasyopenaicompat.Name, @@ -163,8 +224,177 @@ func TestApplyReasoningEffort(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: tt.provider}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: tt.provider, ModelName: tt.modelName}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) tt.assert(t, got) }) } + + t.Run("OpenAICompatClampsGeminiEffort", func(t *testing.T) { + t.Parallel() + + got := applyReasoningEffort( + NewModel(&chattest.FakeModel{ProviderName: fantasyopenaicompat.Name, ModelName: "gemini-3-pro-preview"}, nil), + nil, + new(codersdk.ChatModelReasoningEffortMedium), + ) + providerOptions, ok := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenaicompat.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) +} + +func TestGoogleCompatReasoningEffort(t *testing.T) { + t.Parallel() + + tests := []struct { + modelID string + effort string + want string + wantOK bool + }{ + // Gemini 3.1 Pro supports LOW/MEDIUM/HIGH; Google rejects + // out-of-range values instead of clamping. + {modelID: "gemini-3.1-pro-preview", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "low", want: "low", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "medium", want: "medium", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "high", want: "high", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "gemini-3.1-pro-preview", effort: "max", want: "high", wantOK: true}, + // Gemini 3.0 Pro supports only LOW/HIGH. + {modelID: "gemini-3-pro-preview", effort: "medium", want: "high", wantOK: true}, + {modelID: "gemini-3-pro-preview", effort: "minimal", want: "low", wantOK: true}, + // The Gemini 3 Flash family supports all four levels through 3.6. + {modelID: "gemini-3-flash-preview", effort: "minimal", want: "minimal", wantOK: true}, + {modelID: "gemini-3-flash-preview", effort: "medium", want: "medium", wantOK: true}, + {modelID: "gemini-3-flash-preview", effort: "max", want: "high", wantOK: true}, + {modelID: "gemini-3.6-flash", effort: "none", want: "minimal", wantOK: true}, + // Gemini 3.7 Flash dropped MINIMAL, so the lowest efforts clamp + // up to low instead of failing the request. + {modelID: "gemini-3.7-flash", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-3.7-flash", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-3.7-flash", effort: "medium", want: "medium", wantOK: true}, + // Pre-Gemini-3 models accept none/low/medium/high; none stays + // usable on Flash but clamps to low on Pro, which cannot + // disable thinking. + {modelID: "gemini-2.5-flash", effort: "none", want: "none", wantOK: true}, + {modelID: "gemini-2.5-flash", effort: "minimal", want: "low", wantOK: true}, + {modelID: "gemini-2.5-flash", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "gemini-2.5-pro", effort: "none", want: "low", wantOK: true}, + {modelID: "gemini-2.5-pro", effort: "high", want: "high", wantOK: true}, + // Model ID prefixes used by gateways and the Google API. + {modelID: "models/gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + {modelID: "google/gemini-3.1-pro-preview", effort: "xhigh", want: "high", wantOK: true}, + // Non-Gemini models keep the caller's effort untouched. + {modelID: "gpt-5", effort: "xhigh", wantOK: false}, + {modelID: "deepseek/deepseek-v4", effort: "high", wantOK: false}, + {modelID: "", effort: "high", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.modelID+"/"+tt.effort, func(t *testing.T) { + t.Parallel() + got, ok := googleCompatReasoningEffort(tt.modelID, tt.effort) + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.want, got) + }) + } +} + +func TestGoogleSupportedThinkingLevels(t *testing.T) { + t.Parallel() + + minimal := fantasygoogle.ThinkingLevelMinimal + low := fantasygoogle.ThinkingLevelLow + medium := fantasygoogle.ThinkingLevelMedium + high := fantasygoogle.ThinkingLevelHigh + + tests := []struct { + modelID string + want []fantasygoogle.ThinkingLevel + }{ + {modelID: "gemini-3-flash-preview", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.5-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.5-flash-lite", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.6-flash", want: []fantasygoogle.ThinkingLevel{minimal, low, medium, high}}, + {modelID: "gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "models/gemini-3.7-flash", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: " Gemini-4-Flash ", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-flash-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-flash-lite-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-3-pro-preview", want: []fantasygoogle.ThinkingLevel{low, high}}, + {modelID: "gemini-3.1-pro-preview", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-10.5-pro", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-pro-latest", want: []fantasygoogle.ThinkingLevel{low, medium, high}}, + {modelID: "gemini-3-pro-image-preview", want: []fantasygoogle.ThinkingLevel{high}}, + {modelID: "gemini-3.1-flash-image", want: []fantasygoogle.ThinkingLevel{minimal, high}}, + // Specialized variants reject thinking_level or generateContent + // outright, and unknown families fail closed with them. + {modelID: "gemini-3.1-flash-live-preview", want: nil}, + {modelID: "gemini-3.1-flash-tts-preview", want: nil}, + {modelID: "gemini-omni-flash-preview", want: nil}, + {modelID: "gemini-3-ultra", want: nil}, + {modelID: "gemini-2.5-flash", want: nil}, + {modelID: "gemini-2.0-flash", want: nil}, + {modelID: "gemini-1.5-flash-latest", want: nil}, + {modelID: "gemini-exp-1206", want: nil}, + {modelID: "gemma-3-27b-it", want: nil}, + {modelID: "learnlm-2.0-flash", want: nil}, + {modelID: "", want: nil}, + } + + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, googleSupportedThinkingLevels(tt.modelID)) + }) + } +} + +func TestClampGoogleThinkingLevel(t *testing.T) { + t.Parallel() + + gemini3Pro := googleSupportedThinkingLevels("gemini-3-pro-preview") + proImage := googleSupportedThinkingLevels("gemini-3-pro-image-preview") + flash := googleSupportedThinkingLevels("gemini-3.6-flash") + + tests := []struct { + name string + desired fantasygoogle.ThinkingLevel + supported []fantasygoogle.ThinkingLevel + want fantasygoogle.ThinkingLevel + }{ + {name: "MinimalRoundsUpToLowOnPro", desired: fantasygoogle.ThinkingLevelMinimal, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelLow}, + {name: "MediumRoundsUpToHighOnPro", desired: fantasygoogle.ThinkingLevelMedium, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelHigh}, + {name: "HighExactOnPro", desired: fantasygoogle.ThinkingLevelHigh, supported: gemini3Pro, want: fantasygoogle.ThinkingLevelHigh}, + {name: "LowRoundsUpToHighOnProImage", desired: fantasygoogle.ThinkingLevelLow, supported: proImage, want: fantasygoogle.ThinkingLevelHigh}, + {name: "MediumExactOnFlash", desired: fantasygoogle.ThinkingLevelMedium, supported: flash, want: fantasygoogle.ThinkingLevelMedium}, + {name: "MinimalExactOnFlash", desired: fantasygoogle.ThinkingLevelMinimal, supported: flash, want: fantasygoogle.ThinkingLevelMinimal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, clampGoogleThinkingLevel(tt.desired, tt.supported)) + }) + } +} + +func TestGoogleThinkingLevel(t *testing.T) { + t.Parallel() + + want := map[string]fantasygoogle.ThinkingLevel{ + codersdk.ChatModelReasoningEffortNone: fantasygoogle.ThinkingLevelMinimal, + codersdk.ChatModelReasoningEffortMinimal: fantasygoogle.ThinkingLevelMinimal, + codersdk.ChatModelReasoningEffortLow: fantasygoogle.ThinkingLevelLow, + codersdk.ChatModelReasoningEffortMedium: fantasygoogle.ThinkingLevelMedium, + codersdk.ChatModelReasoningEffortHigh: fantasygoogle.ThinkingLevelHigh, + codersdk.ChatModelReasoningEffortXHigh: fantasygoogle.ThinkingLevelHigh, + codersdk.ChatModelReasoningEffortMax: fantasygoogle.ThinkingLevelHigh, + } + for _, effort := range codersdk.ChatModelReasoningEffortValues() { + require.Contains(t, want, effort, "effort %q missing an expected Google thinking level", effort) + require.Equal(t, want[effort], googleThinkingLevel(effort), "effort %q", effort) + } } diff --git a/internal/googleopenai/thought_signature.go b/coderd/x/googleopenai/thought_signature.go similarity index 89% rename from internal/googleopenai/thought_signature.go rename to coderd/x/googleopenai/thought_signature.go index 84467bec183..5fd46460e36 100644 --- a/internal/googleopenai/thought_signature.go +++ b/coderd/x/googleopenai/thought_signature.go @@ -3,7 +3,6 @@ package googleopenai import ( - "encoding/json" "net/url" "strings" ) @@ -72,23 +71,6 @@ func isGeminiModelID(modelID string) bool { return strings.HasPrefix(modelID, "gemini-") || strings.Contains(modelID, "/gemini-") } -// PatchThoughtSignatures adds fallback thought signatures to Gemini tool-call -// history in body. It returns changed=false when no patch is needed. -func PatchThoughtSignatures(body []byte) ([]byte, bool, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, false, err - } - if !AddThoughtSignaturesToLatestTurn(payload) { - return body, false, nil - } - patched, err := json.Marshal(payload) - if err != nil { - return nil, false, err - } - return patched, true, nil -} - // AddThoughtSignaturesToLatestTurn patches only the current turn because // completed tool-call/result pairs from earlier turns are not validated by // Google as active function calls. diff --git a/internal/googleopenai/thought_signature_test.go b/coderd/x/googleopenai/thought_signature_test.go similarity index 98% rename from internal/googleopenai/thought_signature_test.go rename to coderd/x/googleopenai/thought_signature_test.go index c73bf25ee7f..ce9d7e89e93 100644 --- a/internal/googleopenai/thought_signature_test.go +++ b/coderd/x/googleopenai/thought_signature_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/internal/googleopenai" + "github.com/coder/coder/v2/coderd/x/googleopenai" ) func TestShouldPatchOpenAICompatRequest(t *testing.T) { diff --git a/coderd/x/googleopenai/thoughts.go b/coderd/x/googleopenai/thoughts.go new file mode 100644 index 00000000000..613e71a785a --- /dev/null +++ b/coderd/x/googleopenai/thoughts.go @@ -0,0 +1,194 @@ +package googleopenai + +import ( + "bufio" + "bytes" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Gemini's OpenAI-compatible endpoint emits thought text inline in the +// regular content field, marked with extra_content.google.thought and +// wrapped in .... OpenAI-compatible clients expect +// reasoning in the reasoning_content field instead, so these helpers +// rewrite responses at the transport boundary. +const ( + thoughtOpenMarker = "" + thoughtCloseMarker = "" +) + +// RewriteThoughtResponse rewrites a Gemini OpenAI-compatible chat +// completions response so thought output surfaces as reasoning_content. +// Streaming bodies are rewritten incrementally; JSON bodies are rewritten +// in place. Responses without thought output pass through unchanged. +func RewriteThoughtResponse(resp *http.Response) { + if resp == nil || resp.Body == nil || resp.StatusCode != http.StatusOK { + return + } + contentType := resp.Header.Get("Content-Type") + switch { + case strings.HasPrefix(contentType, "text/event-stream"): + resp.Body = &thoughtStreamBody{ + reader: bufio.NewReader(resp.Body), + closer: resp.Body, + inThought: map[int]bool{}, + } + case strings.HasPrefix(contentType, "application/json"): + body, err := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err != nil || closeErr != nil { + // The client re-reads the replaced body, so surface read + // failures there instead of swallowing them here. + resp.Body = io.NopCloser(&errorReader{err: errors.Join(err, closeErr)}) + return + } + rewritten := RewriteThoughtCompletion(body) + resp.Body = io.NopCloser(bytes.NewReader(rewritten)) + resp.ContentLength = int64(len(rewritten)) + resp.Header.Set("Content-Length", strconv.Itoa(len(rewritten))) + } +} + +type errorReader struct{ err error } + +func (r *errorReader) Read([]byte) (int, error) { return 0, r.err } + +// RewriteThoughtCompletion rewrites a non-streaming chat completion, +// splitting -marked message content into reasoning_content and +// the visible answer. +func RewriteThoughtCompletion(body []byte) []byte { + choices := gjson.GetBytes(body, "choices") + if !choices.IsArray() { + return body + } + out := body + for index, choice := range choices.Array() { + content := choice.Get("message.content") + // The thought metadata gates the rewrite so an answer that + // legitimately begins with the marker text is left alone; Gemini sets + // it on every message that carries thought output. + if content.Type != gjson.String || !strings.HasPrefix(content.Str, thoughtOpenMarker) || + !choice.Get("message.extra_content.google.thought").Bool() { + continue + } + reasoning := content.Str[len(thoughtOpenMarker):] + answer := "" + if markerIndex := strings.Index(reasoning, thoughtCloseMarker); markerIndex >= 0 { + answer = reasoning[markerIndex+len(thoughtCloseMarker):] + reasoning = reasoning[:markerIndex] + } + prefix := "choices." + strconv.Itoa(index) + ".message." + updated, err := sjson.SetBytes(out, prefix+"reasoning_content", reasoning) + if err != nil { + return body + } + updated, err = sjson.SetBytes(updated, prefix+"content", answer) + if err != nil { + return body + } + out = updated + } + return out +} + +// thoughtStreamBody rewrites SSE chat completion chunks line by line as the +// client reads them, preserving streaming latency. +type thoughtStreamBody struct { + reader *bufio.Reader + closer io.Closer + pending []byte + err error + // inThought tracks, per choice index, whether the previous delta was + // thought output, so the and markers can be + // stripped at the transitions. + inThought map[int]bool +} + +func (b *thoughtStreamBody) Read(p []byte) (int, error) { + for len(b.pending) == 0 { + if b.err != nil { + return 0, b.err + } + line, err := b.reader.ReadBytes('\n') + if len(line) > 0 { + b.pending = b.rewriteLine(line) + } + b.err = err + } + n := copy(p, b.pending) + b.pending = b.pending[n:] + return n, nil +} + +func (b *thoughtStreamBody) Close() error { + return b.closer.Close() +} + +var streamDataPrefix = []byte("data: ") + +func (b *thoughtStreamBody) rewriteLine(line []byte) []byte { + payload := bytes.TrimPrefix(line, streamDataPrefix) + if len(payload) == len(line) || !bytes.HasPrefix(payload, []byte("{")) { + return line + } + suffixLength := len(payload) - len(bytes.TrimRight(payload, "\r\n")) + suffix := payload[len(payload)-suffixLength:] + payload = payload[:len(payload)-suffixLength] + + choices := gjson.GetBytes(payload, "choices") + if !choices.IsArray() { + return line + } + out := payload + for index, choice := range choices.Array() { + delta := choice.Get("delta") + if !delta.Exists() { + continue + } + deltaPath := "choices." + strconv.Itoa(index) + ".delta" + content := delta.Get("content") + if delta.Get("extra_content.google.thought").Bool() { + text := content.Str + if !b.inThought[index] { + text = strings.TrimPrefix(text, thoughtOpenMarker) + b.inThought[index] = true + } + updated, err := sjson.SetBytes(out, deltaPath+".reasoning_content", text) + if err != nil { + return line + } + updated, err = sjson.DeleteBytes(updated, deltaPath+".content") + if err != nil { + return line + } + out = updated + continue + } + if !b.inThought[index] { + continue + } + if content.Type == gjson.String && content.Str != "" { + b.inThought[index] = false + if strings.HasPrefix(content.Str, thoughtCloseMarker) { + updated, err := sjson.SetBytes(out, deltaPath+".content", strings.TrimPrefix(content.Str, thoughtCloseMarker)) + if err != nil { + return line + } + out = updated + } + } else if delta.Get("tool_calls").Exists() { + b.inThought[index] = false + } + } + rewritten := make([]byte, 0, len(streamDataPrefix)+len(out)+len(suffix)) + rewritten = append(rewritten, streamDataPrefix...) + rewritten = append(rewritten, out...) + rewritten = append(rewritten, suffix...) + return rewritten +} diff --git a/coderd/x/googleopenai/thoughts_test.go b/coderd/x/googleopenai/thoughts_test.go new file mode 100644 index 00000000000..62e4e7b8957 --- /dev/null +++ b/coderd/x/googleopenai/thoughts_test.go @@ -0,0 +1,160 @@ +package googleopenai_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/coderd/x/googleopenai" +) + +// The fixtures mirror live captures from Google's OpenAI-compatible endpoint +// with include_thoughts enabled: thought deltas carry +// extra_content.google.thought and the text is wrapped in markers, +// with the closing marker prefixed onto the first answer delta. +func TestRewriteThoughtResponse_Stream(t *testing.T) { + t.Parallel() + + lines := []string{ + `data: {"choices":[{"delta":{"role":"assistant","content":"**Calculating**\n\nStep one.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"More thinking.","extra_content":{"google":{"thought":true}}},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"The answer is ","extra_content":null},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":"391."},"index":0}],"object":"chat.completion.chunk"}`, + ``, + `data: {"choices":[{"delta":{"content":null,"extra_content":{"google":{"thought_signature":"sig123"}}},"finish_reason":"stop","index":0}],"usage":{"completion_tokens":9,"prompt_tokens":16,"total_tokens":627}}`, + ``, + `data: [DONE]`, + ``, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n"))), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + out := strings.Split(string(rewritten), "\n") + require.Len(t, out, len(lines)) + + first := gjson.Get(strings.TrimPrefix(out[0], "data: "), "choices.0.delta") + require.Equal(t, "**Calculating**\n\nStep one.", first.Get("reasoning_content").String()) + require.False(t, first.Get("content").Exists()) + require.Equal(t, "assistant", first.Get("role").String()) + + second := gjson.Get(strings.TrimPrefix(out[2], "data: "), "choices.0.delta") + require.Equal(t, "More thinking.", second.Get("reasoning_content").String()) + require.False(t, second.Get("content").Exists()) + + third := gjson.Get(strings.TrimPrefix(out[4], "data: "), "choices.0.delta") + require.Equal(t, "The answer is ", third.Get("content").String()) + require.False(t, third.Get("reasoning_content").Exists()) + + // Untouched lines pass through byte for byte. + require.Equal(t, lines[6], out[6]) + require.Equal(t, lines[8], out[8]) + require.Equal(t, "data: [DONE]", out[10]) +} + +func TestRewriteThoughtResponse_StreamWithoutThoughts(t *testing.T) { + t.Parallel() + + body := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"index\":0}]}\n\ndata: [DONE]\n\n" + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, body, string(rewritten)) +} + +func TestRewriteThoughtResponse_StreamToolCallsEndThought(t *testing.T) { + t.Parallel() + + lines := []string{ + `data: {"choices":[{"delta":{"content":"Pick a tool.","extra_content":{"google":{"thought":true}}},"index":0}]}`, + ``, + `data: {"choices":[{"delta":{"content":null,"tool_calls":[{"index":0,"id":"call_1","function":{"name":"f","arguments":"{}"}}]},"index":0}]}`, + ``, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n"))), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + out := strings.Split(string(rewritten), "\n") + + require.Equal(t, "Pick a tool.", gjson.Get(strings.TrimPrefix(out[0], "data: "), "choices.0.delta.reasoning_content").String()) + require.Equal(t, lines[2], out[2]) +} + +func TestRewriteThoughtResponse_JSONCompletion(t *testing.T) { + t.Parallel() + + body := `{"choices":[{"message":{"role":"assistant","content":"**Quick**\n\nThinking here.\n17 * 23 = **391**","extra_content":{"google":{"thought":true,"thought_signature":"sig"}}},"index":0}],"object":"chat.completion"}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + googleopenai.RewriteThoughtResponse(resp) + rewritten, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(rewritten))) + + message := gjson.GetBytes(rewritten, "choices.0.message") + require.Equal(t, "**Quick**\n\nThinking here.\n", message.Get("reasoning_content").String()) + require.Equal(t, "17 * 23 = **391**", message.Get("content").String()) + require.Equal(t, "sig", message.Get("extra_content.google.thought_signature").String()) +} + +func TestRewriteThoughtCompletion(t *testing.T) { + t.Parallel() + + t.Run("ThoughtOnlyContent", func(t *testing.T) { + t.Parallel() + out := googleopenai.RewriteThoughtCompletion([]byte(`{"choices":[{"message":{"content":"All thought, no close marker.","extra_content":{"google":{"thought":true}}}}]}`)) + message := gjson.GetBytes(out, "choices.0.message") + require.Equal(t, "All thought, no close marker.", message.Get("reasoning_content").String()) + require.Equal(t, "", message.Get("content").String()) + }) + + t.Run("MarkerTextWithoutThoughtMetadataUnchanged", func(t *testing.T) { + t.Parallel() + // An answer that legitimately begins with the marker text must not + // be reclassified as reasoning; real thought output always carries + // extra_content.google.thought. + body := `{"choices":[{"message":{"content":"example XML is the requested format."}}]}` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) + + t.Run("NoThoughtsUnchanged", func(t *testing.T) { + t.Parallel() + body := `{"choices":[{"message":{"content":"Just an answer."}}]}` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) + + t.Run("InvalidJSONUnchanged", func(t *testing.T) { + t.Parallel() + body := `not json` + require.Equal(t, body, string(googleopenai.RewriteThoughtCompletion([]byte(body)))) + }) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index ea03c48a0bf..21f14b9235b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1391,8 +1391,9 @@ type ChatModelAnthropicProviderOptions struct { // ChatModelGoogleThinkingConfig configures Google thinking behavior. type ChatModelGoogleThinkingConfig struct { - ThinkingBudget *int64 `json:"thinking_budget,omitempty" description:"Maximum number of tokens the model may use for thinking"` - IncludeThoughts *bool `json:"include_thoughts,omitempty" description:"Whether to include thinking content in the response"` + ThinkingBudget *int64 `json:"thinking_budget,omitempty" description:"Maximum number of tokens the model may use for thinking (cannot be used with thinking_level)" conflicts_with:"thinking_config.thinking_level"` + ThinkingLevel *string `json:"thinking_level,omitempty" description:"Thinking level for Gemini 3+ models, used when the user has not selected a reasoning effort (cannot be used with thinking_budget)" enum:"minimal,low,medium,high" conflicts_with:"thinking_config.thinking_budget"` + IncludeThoughts *bool `json:"include_thoughts,omitempty" description:"Whether to include thinking content in the response"` } // ChatModelGoogleSafetySetting configures Google safety filtering. diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index dd3e3555cf2..5dc86f2f4bb 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -165,9 +165,20 @@ "json_name": "thinking_config.thinking_budget", "go_name": "ThinkingConfig.ThinkingBudget", "type": "integer", - "description": "Maximum number of tokens the model may use for thinking", + "description": "Maximum number of tokens the model may use for thinking (cannot be used with thinking_level)", "required": false, - "input_type": "input" + "input_type": "input", + "conflicts_with": ["thinking_config.thinking_level"] + }, + { + "json_name": "thinking_config.thinking_level", + "go_name": "ThinkingConfig.ThinkingLevel", + "type": "string", + "description": "Thinking level for Gemini 3+ models, used when the user has not selected a reasoning effort (cannot be used with thinking_budget)", + "required": false, + "enum": ["minimal", "low", "medium", "high"], + "input_type": "select", + "conflicts_with": ["thinking_config.thinking_budget"] }, { "json_name": "thinking_config.include_thoughts", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 56b0f4825bd..556fa048ab3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2898,6 +2898,7 @@ export interface ChatModelGoogleSafetySetting { */ export interface ChatModelGoogleThinkingConfig { readonly thinking_budget?: number; + readonly thinking_level?: string; readonly include_thoughts?: boolean; } diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 7c1c3bf54ca..55b4a429204 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -9,6 +9,7 @@ import { MockAnthropicProviderState, MockAzureProviderState, MockDisabledProviderState, + MockGoogleProviderState, MockOpenAIProviderState, mockClaude, mockGPT5, @@ -423,6 +424,43 @@ export const ReasoningEffortValidationError: Story = { }, }; +// thinking_level and thinking_budget are mutually exclusive on Google +// models: setting either one disables the other until it is cleared. +export const GoogleThinkingLevelBudgetMutualExclusion: Story = { + args: { + providerStates: [MockGoogleProviderState], + selectedProviderState: MockGoogleProviderState, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /provider configuration/i }), + ); + const budget = canvas.getByLabelText(/thinking config thinking budget/i); + const level = canvas.getByRole("combobox", { + name: /thinking config thinking level/i, + }); + await expect(budget).toBeEnabled(); + await expect(level).toBeEnabled(); + + await userEvent.click(level); + await userEvent.click(await screen.findByRole("option", { name: "Low" })); + await expect(budget).toBeDisabled(); + + await userEvent.click(level); + await userEvent.click( + await screen.findByRole("option", { name: "Default" }), + ); + await expect(budget).toBeEnabled(); + + await userEvent.type(budget, "2048"); + await expect(level).toBeDisabled(); + + await userEvent.clear(budget); + await expect(level).toBeEnabled(); + }, +}; + // The catalog fallback covers an entitled deployment with no matching price // book row. Values come from the baked-in catalog and must not be submitted. export const CostEstimateFieldsAreImmutable: Story = { diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts index 48097953489..7c7e49af191 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -82,6 +82,22 @@ export const MockAnthropicProviderState: ProviderState = { modelConfigs: [mockClaude], }; +const MockGoogleProviderConfig: ChatProviderConfig = { + ...MockOpenAIProviderConfig, + id: "prov-google", + provider: "google", + display_name: "Google", +}; + +export const MockGoogleProviderState: ProviderState = { + ...MockOpenAIProviderState, + key: "prov-google", + provider: "google", + label: "Google", + providerConfig: MockGoogleProviderConfig, + modelConfigs: [], +}; + const MockBedrockProviderConfig: ChatProviderConfig = { ...MockOpenAIProviderConfig, id: "prov-bedrock",