Gemini-3.7-flash: schema-constrained JSON output degenerates into repeated "0" until maxOutputTokens (regression vs gemini-3-flash-preview)

Summary

Under responseMimeType: "application/json" + responseSchema, gemini-3.7-flash
intermittently enters a decode loop on prompts containing many near-identical
items: it emits a valid JSON prefix, then an integer field’s digits repeat
(...“score”: 20000000000000...) endlessly until maxOutputTokens
(finishReason: MAX_TOKENS, truncated/unparseable JSON). Without an explicit
cap it runs to the model’s 65,536 default on every occurrence.

The identical request never degenerates on gemini-3-flash-preview.

Environment

  • Model: gemini-3.7-flash (fails) vs gemini-3-flash-preview (clean)
  • API: Gemini Developer API (generativelanguage), paid tier
  • SDK: @google/genai 1.37.0, Node.js 20
  • Config: responseMimeType: application/json, responseSchema (array of
    {id: STRING, score: INTEGER, comment: STRING} with min/maxItems),
    thinkingConfig: { thinkingLevel: LOW }

Reproduction

Attached standalone script (repro.js, fully synthetic data): 10 grading items
that differ only in a decimal value, schema-constrained JSON output.

  • gemini-3.7-flash: ~1 in 3 runs ends MAX_TOKENS with a tail of repeated
    zeros (measured 33% on the synthetic payload; up to 100% per attempt on
    equivalent production payloads).
  • gemini-3-flash-preview: 0 degenerate runs (dozens attempted).
  • temperature: 0 does NOT avoid it — the zero-loop appears to be a
    deterministic attractor for some inputs.

Expected: STOP with ~400 output tokens, as gemini-3-flash-preview produces.

Impact

Production education workload (grading student papers, JSON verdicts): 241
runaway calls across 3 days, each billed to the 65,536-token default cap
(~US$60 of unwanted output tokens) before we deployed maxOutputTokens caps,
per-chunk retries, and a fallback to gemini-3-flash-preview. The failure rate
made several papers temporarily ungradable (every retry re-entered the loop on
the same content).

Secondary observations on the same model/API (can file separately if preferred)

  1. Intermittent bare 400 INVALID_ARGUMENT (no detail) on well-formed
    generation requests — 3–10/day across our traffic; the identical request
    succeeds on immediate reissue.
  2. Rare 200 responses with an empty candidate (no text parts,
    finishReason: STOP) on requests that normally return full JSON.

Ask

Is the constrained-decoding repetition loop a known issue in the 3.7-flash
decoder, and is a fix planned? Happy to provide more failing payloads or run
canary tests.

Standalone reproduction script

// Minimal reproduction: gemini-3.7-flash degenerates into repeated "0" digits
// until maxOutputTokens under schema-constrained JSON decoding when the prompt
// contains many near-identical items. gemini-3-flash-preview never does on the
// identical request. Fully synthetic data — safe to share.
//
// Usage:  GEMINI_API_KEY=<key> node _gemini-37-degeneration-repro.js [trials]
// Expected: roughly 1 in 3 gemini-3.7-flash trials ends with
// finishReason=MAX_TOKENS and a tail of repeated zeros; all
// gemini-3-flash-preview trials end STOP with ~400 output tokens.
// temperature: 0 does NOT avoid the failure mode.
const { GoogleGenAI, ThinkingLevel, Type } = require('@google/genai')

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
const TRIALS = Number(process.argv[2] || 5)

// 10 near-identical grading items differing only in decimal values — the
// repetitive-input shape that triggers the decode loop.
const items = [18.9, 23.8, 7.25, 6.04, 12.5, 3.75, 9.2, 15.6, 4.85, 21.4].map((v, i) => ({
  id: 'q' + (i + 1),
  totalScore: 2,
  question:
    '<p>Convert the following decimal into the simplest fraction.<br />\\(' +
    v +
    ' =\\) __________</p>',
  studentAns: '<p><strong>' + v * 20 + '/20</strong></p>',
}))

const PROMPT =
  'You are an AI that checks the answers of students in a practice paper. ' +
  'For each question return a score between 0 and totalScore, and a concise ' +
  'HTML comment (use MathJax \\( \\) for math) explaining the score. ' +
  'The comment must be non-empty.'

const schema = {
  type: Type.ARRAY,
  minItems: 10,
  maxItems: 10,
  items: {
    type: Type.OBJECT,
    properties: {
      id: { type: Type.STRING },
      score: { type: Type.INTEGER },
      comment: { type: Type.STRING },
    },
  },
}

const runTrial = async (model) => {
  const r = await ai.models.generateContent({
    model,
    contents: [PROMPT, 'Questions and answers:\n' + JSON.stringify(items)],
    config: {
      responseMimeType: 'application/json',
      responseSchema: schema,
      // Cap only bounds the damage — without it the runaway emits zeros all
      // the way to the model's 65,536 default.
      maxOutputTokens: 8192,
      thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
    },
  })
  const fin = r.candidates[0].finishReason
  const out = (r.usageMetadata && r.usageMetadata.candidatesTokenCount) || 0
  const tail = String(r.text || '').slice(-40)
  return { fin, out, tail }
}

;(async () => {
  for (const model of ['gemini-3.7-flash', 'gemini-3-flash-preview']) {
    let degenerate = 0
    for (let t = 1; t <= TRIALS; t++) {
      const { fin, out, tail } = await runTrial(model)
      const bad = fin === 'MAX_TOKENS'
      if (bad) degenerate++
      console.log(
        model +
          ' trial ' +
          t +
          ': ' +
          fin +
          ' outputTokens=' +
          out +
          (bad ? ' tail=' + JSON.stringify(tail) : '')
      )
    }
    console.log('=> ' + model + ': ' + degenerate + '/' + TRIALS + ' degenerate runs\n')
  }
  process.exit(0)
})().catch((e) => {
  console.error(e)
  process.exit(1)
})