Google's shim mislabels streamed tool calls finish_reason: "stop"

medium · inference · probed live 2026-07-26 · affects Google (OpenAI-compat)

finish_reason states why generation stopped; agent loops branch on it — "tool_calls" means execute tools and continue, "stop" means the turn is done. It must not depend on transport.

What was sent

A deterministic tool prompt; the streaming leg sends the identical body plus "stream": true (download):

stream-finish.google-openai.json
{
  "model": "gemma-4-31b-it",
  "messages": [
    {
      "role": "user",
      "content": "Call `get_weather` for Paris. Do not answer directly."
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Current weather for a city.",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {
              "type": "string"
            }
          },
          "required": [
            "city"
          ]
        }
      }
    }
  ],
  "reasoning_effort": "minimal",
  "temperature": 0,
  "max_tokens": 200
}

What came back

Non-streaming — the correct label:

response · non-streaming, choices[0]
{
  "finish_reason": "tool_calls",
  "message": {
    "role": "assistant",
    "tool_calls": [
      { "id": "t6afm4c1", "type": "function",
        "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } }
    ]
  }
}

Streaming — identical tool call, wrong label:

response · streamed, assembled choices[0]
{
  "finish_reason": "stop",
  "message": {
    "role": "assistant",
    "content": null,
    "tool_calls": [
      { "id": "xo93xcxj", "type": "function",
        "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } }
    ]
  }
}

Every other OpenAI-dialect provider probed returns "tool_calls" on both transports. The streamed leg also drops the thought_signature the non-streamed leg returns — the token Google's own dialect expects replayed on the next turn.

Why it matters

A harness that dispatches on finish_reason behaves differently depending on transport: streamed, it is told the turn finished cleanly and terminates with an unexecuted tool call — HTTP 200, well-formed body, silent.

Run it yourself

curl
curl "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" \
  -H "Authorization: Bearer $GEMINI_API_KEY" -H "Content-Type: application/json" \
  -d @stream-finish.google-openai.json

POST the body twice, with and without "stream": true; compare the final finish_reason.