Files
coder/coderd/x/googleopenai/thoughts.go
T
Michael Suchacz 8eb1e43f0b feat: apply reasoning effort and surface thinking blocks for Google chat models (#28273)
## 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 -->
2026-08-20 16:36:57 +02:00

195 lines
5.8 KiB
Go

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 <thought>...</thought>. OpenAI-compatible clients expect
// reasoning in the reasoning_content field instead, so these helpers
// rewrite responses at the transport boundary.
const (
thoughtOpenMarker = "<thought>"
thoughtCloseMarker = "</thought>"
)
// 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 <thought>-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 <thought> and </thought> 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
}