mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
## Problem
Selecting a reasoning effort on a Google (Gemini) chat model either got
dropped or broke the request outright, and even when the request
succeeded, Gemini chats never showed thinking blocks:
- The native `fantasygoogle` path had no case in `applyReasoningEffort`
at all, so effort was silently ignored. With only `include_thoughts`
configured, Gemini 3+ models default to no thinking, so chats produced
zero reasoning tokens.
- The path chat generation actually uses today routes Google providers
through aibridge as openai-compat clients and forwarded the raw effort
string as `reasoning_effort`. Google's OpenAI-compatible endpoint
validates that value against the model's supported thinking levels
instead of clamping, so out-of-range efforts fail the whole request with
HTTP 400. Verified against the live Gemini API: `gemini-3.1-pro-preview`
accepts low/medium/high and rejects none/minimal/xhigh/max. Because the
config's default effort is sent whenever the user selects nothing, a
config whose default falls outside the model's supported set failed
every no-selection message.
- Google's OpenAI-compatible endpoint never emits thought text for
`reasoning_effort` requests. Thoughts require
`extra_body.google.thinking_config` with `include_thoughts`, which is
mutually exclusive with `reasoning_effort`, and even then the thought
deltas come back inline in `content` (marked
`extra_content.google.thought`, wrapped in `<thought>` markers) rather
than in the `reasoning_content` field OpenAI-compatible clients parse.
So no reasoning parts were ever produced, persisted, or rendered for
Gemini chats (verified live in UAT).
## Fix
Reasoning effort:
- Map the global reasoning effort scale onto Google's `thinking_level`
in `applyReasoningEffort`, gated to Gemini 3+ models and clamped to each
model's supported subset (3.0 Pro launched with LOW/HIGH only, 3.1 Pro
added MEDIUM, Flash accepts all four through 3.6 while 3.7 and the
latest flash aliases drop MINIMAL, image models accept HIGH plus MINIMAL
for flash). Pre-Gemini-3 models reject `thinking_level`, so effort
degrades to a no-op for them and a config-pinned level is dropped at
call time instead of failing every generation.
- Add `thinking_level` to `ChatModelGoogleThinkingConfig` so admins can
pin a level for configs without user-selectable effort. A resolved
per-turn effort overrides the pinned level; an explicit
`thinking_budget` wins over both since the Google API rejects requests
that set `thinking_budget` and `thinking_level` together, and config
validation now rejects that combination up front. The admin panel picks
up the new field automatically through the generated model-options
schema.
Thinking blocks on the openai-compat path (the path chat generation
uses), translated in both directions at the existing Gemini transport
seam:
- chatd's OpenAI-compat request patch swaps `reasoning_effort` for an
equivalent `extra_body.google.thinking_config` with `include_thoughts`
enabled: `thinking_level` on Gemini 3+ (clamped to the model's supported
subset), `thinking_budget` on earlier models (effort `none` maps to
budget 0, which disables thinking). Non-Gemini models and non-Google
routes are untouched.
- The same transport rewrites Google's responses so thought output
surfaces as `reasoning_content`, which fantasy already converts into
reasoning parts: streaming bodies are rewritten per SSE line (preserving
streaming latency), JSON bodies in place, with the
`<thought>`/`</thought>` markers stripped at the transitions.
- aibridge's chat-completions interception preserves the `extra_body`
passthrough that openai-go's typed params silently drop, forwarding it
to Google upstreams only, so the chatd -> aibridge -> Google route
carries the thinking config end to end.
## Validation
- `go test ./coderd/x/chatd/... ./aibridge/... ./internal/googleopenai
./codersdk` pass; golangci-lint clean on touched packages.
- New unit tests cover the effort-to-level mapping for every effort
value, per-model clamping on both paths, the effort-to-thinking_config
request swap (level, budget, pinned-config precedence, non-Gemini
passthrough), SSE and JSON thought-response rewriting (fixtures mirror
live captures, including tool-call and no-thought streams), an
end-to-end fake-SSE test through the fantasy pipeline proving reasoning
deltas emerge with markers stripped, and aibridge `extra_body`
preservation (including proof the typed round trip drops it).
- Verified live against `generativelanguage.googleapis.com` on
`gemini-3-flash-preview` and `gemini-2.5-flash` through the real
chatprovider stack: reasoning deltas stream through with markers
stripped and answer text intact.
- Live UAT on a dogfood deployment (remote Coder Agents chat, 4 rounds):
effort clamping verified end to end (rounds 1-2), the missing-thoughts
limitation isolated (round 3), and the fix validated (round 4):
expandable, readable thinking blocks render for
`gemini-3.1-pro-preview`, `gemini-3-flash-preview`, and
`gemini-2.5-flash` at effort=high; no `<thought>` marker leaks;
effort=none produces no block and no 400; multi-turn history replay
works with a thinking block on each turn.
- `make gen` regenerated `typesGenerated.ts` and
`chatModelOptionsGenerated.json`; `pnpm -C site lint:types` passes.
> 🤖 Mux acted on Mike's behalf to create this pull request.
<!-- mux-attribution: model=claude-opus-4-6 thinking=high -->
161 lines
6.4 KiB
Go
161 lines
6.4 KiB
Go
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 <thought> 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":"<thought>**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":"</thought>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":"<thought>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":"<thought>**Quick**\n\nThinking here.\n</thought>17 * 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":"<thought>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":"<thought>example XML</thought> 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))))
|
|
})
|
|
}
|