Interactions API v1: gemini-3.1-flash-lite sometimes returns "completed" with no model_output, but bills the answer's tokens

What happens

About 0.5% of non-streaming POST https://generativelanguage.googleapis.com/v1/interactions calls to gemini-3.1-flash-lite return:

  • HTTP 200 with "status": "completed" and no errors field;
  • steps holding a single thought step (signature only) and no model_output step.

The usage block still reports the text tokens of a full answer, output_tokens_by_modality: [{"modality": "text", "tokens": N}], and total_output_tokens is slightly higher (4–21 tokens more at thinking_level: minimal). So the answer seems to be generated and billed, but not returned.

The same request sent to v1/models/gemini-3.1-flash-lite:generateContent never came back without an answer in our runs. The same request to the Interactions API with gemini-3.5-flash-lite didn’t either.

Repro

repro.py (full source at the end) is Python standard library only: no SDK, plain HTTPS. It sends one text prompt plus one JPEG per call with thinking_level: minimal, max_output_tokens: 4096, a JSON response_format, store: false and image resolution: medium. It runs 16 calls in parallel and saves the raw body of every 200 reply that has no answer.

GEMINI_API_KEY=... python repro.py interactions short_prompt.txt images/ 1200 out/
GEMINI_API_KEY=... python repro.py generate_content short_prompt.txt images/ 1200 out_gc/
  • images/ contains one public-domain photo: Label with care symbols.JPG (Wikimedia Commons, 960 px version), a sweater label reading “100% WOOL”.

  • short_prompt.txt: Read the clothing label in this photo and extract the material composition. Return each section (for example shell or lining; null if the label has only one) with every material in English and its percentage.

Results (2026-10-30, 13:49–13:58 UTC, 1,200 calls per row)

Same photo and prompt in every row:

Request Model HTTP 200 200 with no answer
v1 interactions gemini-3.1-flash-lite 1,200 12 (1.0%)
v1 interactions, store: true gemini-3.1-flash-lite 1,200 8 (0.67%)
v1 generateContent gemini-3.1-flash-lite 1,200 0
v1 interactions gemini-3.5-flash-lite 1,200 0
v1 generateContent gemini-3.5-flash-lite 1,200 0

This photo’s normal answer is {"s":[{"c":[{"m":"wool","p":100}],"n":null}]}, 56 output tokens; 1,156 of the 1,188 answered calls returned exactly that. All 12 empty replies report output_tokens_by_modality text = 56 and total_output_tokens 73–75.

It isn’t tied to this photo or prompt. With 300 other images and a longer prompt, Interactions on 3.1-flash-lite returned 0.25–0.58% empty replies per 1,200-call run, while generateContent and 3.5-flash-lite returned none.

Changing one setting at a time on 3.1-flash-lite did not make it go away (1,200 calls each):

Setting changed Inputs Empty replies
none (baseline, same time as the three rows below) 300 images, long prompt 7
thinking_level: low 300 images, long prompt 8
no response_format 300 images, long prompt 2
default image resolution 300 images, long prompt 5
store: true public photo, short prompt 8

Empty reply (signature shortened; full body at the end)

{
  "status": "completed",
  "usage": {
    "total_tokens": 656,
    "total_input_tokens": 582,
    "input_tokens_by_modality": [{"modality": "image", "tokens": 540}, {"modality": "text", "tokens": 42}],
    "total_cached_tokens": 0,
    "total_output_tokens": 74,
    "output_tokens_by_modality": [{"modality": "text", "tokens": 56}],
    "initial_input_tokens": 861
  },
  "created": "2026-10-30T13:54:25Z",
  "updated": "2026-10-30T13:54:25Z",
  "service_tier": "standard",
  "steps": [{"signature": "EnMKcQFpFH0ThCZvZn66+pD0…", "type": "thought"}],
  "object": "interaction",
  "model": "gemini-3.1-flash-lite"
}

Normal reply, same photo and prompt (signature shortened; timestamps and service_tier left out; full body at the end)

{
  "status": "completed",
  "usage": {
    "total_tokens": 638,
    "total_input_tokens": 582,
    "input_tokens_by_modality": [{"modality": "image", "tokens": 540}, {"modality": "text", "tokens": 42}],
    "total_cached_tokens": 0,
    "total_output_tokens": 56,
    "total_tool_use_tokens": 0,
    "total_thought_tokens": 0,
    "initial_input_tokens": 861
  },
  "steps": [
    {"signature": "EnMKcQFpFH0TcTP4ghlhvI86…", "type": "thought"},
    {"type": "model_output", "content": [{"type": "text", "text": "{\n  \"s\": [\n    {\n      \"c\": [\n        {\n          \"m\": \"wool\",\n          \"p\": 100\n        }\n      ],\n      \"n\": null\n    }\n  ]\n}"}]}
  ],
  "object": "interaction",
  "model": "gemini-3.1-flash-lite"
}

The usage blocks differ in shape. At thinking_level: minimal, all 49 empty replies have output_tokens_by_modality but no total_thought_tokens or total_tool_use_tokens; all 13,137 normal replies have the reverse. (With thinking_level: low, the 8 empty replies also include total_thought_tokens.)

Expected

Either a model_output step with the answer, or a status other than completed (or an error) so the client knows to retry. Output that isn’t delivered shouldn’t be billed.

Environment

Gemini Developer API with an API key, service_tier: standard, non-streaming calls, Python 3.9 urllib.

repro.py
"""Gemini API v1: count calls that come back without an answer.

Sends the same prompt + one image per call to gemini-3.1-flash-lite on the stable v1
endpoint, either through the Interactions API or generateContent, with the same
settings on both. Python standard library only.

    GEMINI_API_KEY=... python repro.py interactions|generate_content <prompt.txt> <images_dir> <calls> <out_dir>

Writes every call to <out_dir>/calls.jsonl and every unanswered raw response to
<out_dir>/no_answer_N.json.
"""

import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

MODEL = "gemini-3.1-flash-lite"
BASE = "https://generativelanguage.googleapis.com/v1"
SCHEMA = {
    "type": "object",
    "properties": {
        "s": {
            "type": "array",
            "description": "Composition sections on the label. Usually 1; multi-layer items may have 2-3 (shell, lining, etc.)",
            "items": {
                "type": "object",
                "properties": {
                    "n": {"type": ["string", "null"], "description": "Section name if specified, else null"},
                    "c": {
                        "type": "array",
                        "minItems": 1,
                        "items": {
                            "type": "object",
                            "properties": {
                                "m": {"type": "string", "description": "Material name in English"},
                                "p": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Percentage"},
                            },
                            "required": ["m", "p"],
                        },
                    },
                },
                "required": ["c"],
            },
        }
    },
    "required": ["s"],
}

api, prompt, images, calls, out = sys.argv[1], Path(sys.argv[2]).read_text(), sorted(Path(sys.argv[3]).glob("*.jpg")), int(sys.argv[4]), Path(sys.argv[5])
out.mkdir(parents=True, exist_ok=True)


def request(image):
    data = base64.b64encode(image.read_bytes()).decode()
    if api == "interactions":
        return f"{BASE}/interactions", {
            "model": MODEL,
            "store": False,
            "input": [{"type": "user_input", "content": [
                {"type": "text", "text": prompt},
                {"type": "image", "mime_type": "image/jpeg", "data": data, "resolution": "medium"},
            ]}],
            "generation_config": {"thinking_level": "minimal", "max_output_tokens": 4096},
            "response_format": {"type": "text", "mime_type": "application/json", "schema": SCHEMA},
        }
    return f"{BASE}/models/{MODEL}:generateContent", {
        "contents": [{"role": "user", "parts": [{"text": prompt}, {"inline_data": {"mime_type": "image/jpeg", "data": data}}]}],
        "generation_config": {
            "thinking_config": {"thinking_level": "MINIMAL"},
            "max_output_tokens": 4096,
            "media_resolution": "MEDIA_RESOLUTION_MEDIUM",
            "response_mime_type": "application/json",
            "response_json_schema": SCHEMA,
        },
    }


def answer(body):
    if api == "interactions":
        steps = body.get("steps") or []
        parts = [c for s in steps if s.get("type") == "model_output" for c in s.get("content") or []]
    else:
        parts = [p for c in body.get("candidates") or [] for p in (c.get("content") or {}).get("parts") or [] if not p.get("thought")]
    return "".join(p.get("text") or "" for p in parts).strip()


def call(i):
    image = images[i % len(images)]
    url, payload = request(image)
    req = urllib.request.Request(url, json.dumps(payload).encode(), {"Content-Type": "application/json", "x-goog-api-key": os.environ["GEMINI_API_KEY"]})
    start = time.time()
    try:
        with urllib.request.urlopen(req, timeout=120) as response:
            http, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        http, raw = error.code, error.read()
    except (urllib.error.URLError, TimeoutError) as error:
        return {"call": i, "image": image.name, "http": None, "seconds": round(time.time() - start, 2), "error": repr(error)}
    body = json.loads(raw)
    row = {"call": i, "image": image.name, "http": http, "seconds": round(time.time() - start, 2),
           "answered": http == 200 and bool(answer(body)),
           "status": body.get("status") or [c.get("finishReason") for c in body.get("candidates") or []],
           "usage": body.get("usage") or body.get("usageMetadata"), "error": body.get("error")}
    if http == 200 and not row["answered"]:
        (out / f"no_answer_{i}.json").write_bytes(raw)
    elif row["answered"] and not (out / "answered_example.json").exists():
        (out / "answered_example.json").write_bytes(raw)
    return row


with ThreadPoolExecutor(16) as pool, open(out / "calls.jsonl", "w") as log:
    rows = []
    for row in pool.map(call, range(calls)):
        rows.append(row)
        log.write(json.dumps(row) + "\n")

ok = [r for r in rows if r["http"] == 200]
print(json.dumps({"api": api, "calls": len(rows), "http_200": len(ok), "http_errors": len(rows) - len(ok),
                  "200_without_answer": sum(not r["answered"] for r in ok)}))
Full raw body: empty reply
{
  "status": "completed",
  "usage": {
    "total_tokens": 656,
    "total_input_tokens": 582,
    "input_tokens_by_modality": [
      {
        "modality": "image",
        "tokens": 540
      },
      {
        "modality": "text",
        "tokens": 42
      }
    ],
    "total_cached_tokens": 0,
    "total_output_tokens": 74,
    "output_tokens_by_modality": [
      {
        "modality": "text",
        "tokens": 56
      }
    ],
    "initial_input_tokens": 861
  },
  "created": "2026-10-30T13:54:25Z",
  "updated": "2026-10-30T13:54:25Z",
  "service_tier": "standard",
  "steps": [
    {
      "signature": "EnMKcQFpFH0ThCZvZn66+pD0I2OGpHCbKgPAvA6TAwzi2TPxjMfpnXR1SiMmU/U+8YAoVAgn2Vv36DXHLgQ5+grCm0D/eiyKKnYhmeAhznyX5aQIJG/L6q2P7p4HkqO1yS/0z3iKR1EiXSmtm1oAosMofRB5",
      "type": "thought"
    }
  ],
  "object": "interaction",
  "model": "gemini-3.1-flash-lite"
}
Full raw body: normal reply
{
  "status": "completed",
  "usage": {
    "total_tokens": 638,
    "total_input_tokens": 582,
    "input_tokens_by_modality": [
      {
        "modality": "image",
        "tokens": 540
      },
      {
        "modality": "text",
        "tokens": 42
      }
    ],
    "total_cached_tokens": 0,
    "total_output_tokens": 56,
    "total_tool_use_tokens": 0,
    "total_thought_tokens": 0,
    "initial_input_tokens": 861
  },
  "created": "2026-10-30T13:52:51Z",
  "updated": "2026-10-30T13:52:51Z",
  "service_tier": "standard",
  "steps": [
    {
      "signature": "EnMKcQFpFH0TcTP4ghlhvI869d5iPZCf7u46jU0hsKnHl9Bx0/TqTWEbRa6/mEXGtioefVYHi61TDBhAp9QnkOejpnwsBiGufVPABDai0ihAI9pw2ggryDdEtxdWyhGVUEvVXGJJi4uIfU5kSPrVW7mIsPdw",
      "type": "thought"
    },
    {
      "content": [
        {
          "text": "{\n  \"s\": [\n    {\n      \"c\": [\n        {\n          \"m\": \"wool\",\n          \"p\": 100\n        }\n      ],\n      \"n\": null\n    }\n  ]\n}",
          "type": "text"
        }
      ],
      "type": "model_output"
    }
  ],
  "object": "interaction",
  "model": "gemini-3.1-flash-lite"
}

Hello,

We have made some changes. Could you please run your Interactions API test again and let us know if the issue still exists?