mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
## Problem
When Gemini's OpenAI-compatible endpoint rejects a model-generated
function call server-side, it ends the SSE stream cleanly with the
nonstandard finish reason `function_call_filter:
MALFORMED_FUNCTION_CALL` after streaming only thought summaries; the
rejected call never reaches the wire. chatd treated this as a normal
completion: fantasy maps the unrecognized finish reason to `unknown`,
the reasoning block never closes (the only non-thought delta is the
`</thought>` marker, which the transport seam strips to an empty string,
and the openaicompat hook only ends reasoning on a non-empty content
delta), so the step accumulates no content and the generation loop
finishes the turn as complete. The user sees the model think for ~40
seconds and then nothing: no assistant message, no `last_error`, chat
status `waiting`. Observed twice in a row in production on
gemini-3.7-flash, with "Resume" reproducing it identically.
## Fix
Two independent layers:
- `coderd/x/googleopenai`: the stream rewrite now converts any chunk
whose `finish_reason` starts with `function_call_filter` into an
OpenAI-style SSE `{"error": ...}` event embedding the raw reason.
openai-go turns error-bearing events into stream errors, so the failure
rides the existing stream-error path instead of ending the stream
cleanly.
- `coderd/x/chatd/chaterror`: classifies that injected error as
retryable (kind `generic`, provider `google`) with a clear user-facing
message, so the existing generation retry machinery re-runs the step and
persists a `last_error` if retries exhaust.
- `coderd/x/chatd/chatloop`: provider-agnostic guard: a step that
produced no user-visible content and no tool calls under a finish reason
of `unknown`, `error`, `other`, or `tool-calls` (a tool-calls finish
that delivered zero calls) now returns a retryable error instead of
silently completing the turn. `stop` and `length` finishes keep their
existing semantics.
Tests cover the seam rewrite (live-capture SSE shape plus standard
finish reason passthrough), the new classification, and the chatloop
guard (error cases plus preserved stop, length, text, and tool-call
behavior). Each layer was red-green verified independently.
Remote dogfood UAT ran against this exact commit: normal reasoning and
tool-call chats on a real model complete cleanly with no spurious guard
errors and no retry loops. The Google-side failure itself is not
deterministically triggerable against live Gemini and is owned by the
unit tests.
> Xum acted on Mike's (@ibetitsmike) behalf.
<!-- xum-attribution: model=claude-opus, thinking=enabled -->
242 lines
7.4 KiB
Go
242 lines
7.4 KiB
Go
package googleopenai
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"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
|
|
}
|
|
if errPayload := functionCallFilterErrorPayload(choices); errPayload != nil {
|
|
return assembleDataLine(errPayload, suffix)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return assembleDataLine(out, suffix)
|
|
}
|
|
|
|
func assembleDataLine(payload, suffix []byte) []byte {
|
|
rewritten := make([]byte, 0, len(streamDataPrefix)+len(payload)+len(suffix))
|
|
rewritten = append(rewritten, streamDataPrefix...)
|
|
rewritten = append(rewritten, payload...)
|
|
rewritten = append(rewritten, suffix...)
|
|
return rewritten
|
|
}
|
|
|
|
// Gemini uses this nonstandard finish-reason prefix when it discards a
|
|
// malformed generated function call.
|
|
const functionCallFilterFinishReasonPrefix = "function_call_filter"
|
|
|
|
// MalformedFunctionCallMessagePrefix distinguishes adapter-generated errors
|
|
// from unrelated provider errors.
|
|
const MalformedFunctionCallMessagePrefix = "gemini dropped the model's generated function call"
|
|
|
|
type streamErrorEvent struct {
|
|
Error streamErrorDetail `json:"error"`
|
|
}
|
|
|
|
type streamErrorDetail struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// functionCallFilterErrorPayload turns the nonstandard finish reason into an
|
|
// SSE error so consumers do not accept the empty stream as successful.
|
|
func functionCallFilterErrorPayload(choices gjson.Result) []byte {
|
|
for _, choice := range choices.Array() {
|
|
reason := choice.Get("finish_reason")
|
|
if reason.Type != gjson.String || !strings.HasPrefix(reason.Str, functionCallFilterFinishReasonPrefix) {
|
|
continue
|
|
}
|
|
payload, err := json.Marshal(streamErrorEvent{Error: streamErrorDetail{
|
|
Message: MalformedFunctionCallMessagePrefix + " (finish_reason " + strconv.Quote(reason.Str) + ")",
|
|
Type: "invalid_response_error",
|
|
Code: "malformed_function_call",
|
|
}})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return payload
|
|
}
|
|
return nil
|
|
}
|