Make Google Gemini structured output actually validate against your Pydantic models.
Gemini's response_json_schema promises structured output, then quietly breaks
its own promise. It enforces shape (types, properties, required) but silently
ignores value-level constraints — so the model hallucinates enum values, blows
past your numeric bounds, and trails off into half-formed objects at the end of
long arrays. Pydantic then rejects the entire response over one bad field.
gemini-coax coaxes the output back into shape. No retries, no extra LLM calls
for the common cases — just targeted repair at the validation seam.
If you've hit any of these, this library is for you:
ValueError: AnyOf is not supported in the response schema for the Gemini APIInput should be 'a', 'b' or 'c' [type=literal_error]on a value the schema defined- A nullable
Literal[...] | Nonefield where Gemini invents values off-menu ge/le/max_length/max_itemsconstraints ignored, failing validation- Empty
{}or truncated objects at the tail of a long list, killing the whole array - A free-text
strfield that loops the same phrase untilfinish_reason=MAX_TOKENS, truncating the JSON mid-array so an N-item extraction collapses to one
pip install gemini-coax # core — pure, depends only on pydantic
pip install "gemini-coax[langchain]" # + the drop-in ChatGoogleGenerativeAISwap ChatGoogleGenerativeAI for GeminiSafe. That's the whole change. Every
with_structured_output() call is now coaxed; no edits in your chains.
from typing import Literal
from pydantic import BaseModel, Field
from gemini_coax import GeminiSafe # was: ChatGoogleGenerativeAI
class Finding(BaseModel):
label: Literal["bug", "smell", "nit"] | None # nullable enum — Gemini drops the enum
severity: int = Field(ge=1, le=5) # bounds Gemini ignores
class Report(BaseModel):
findings: list[Finding] # long array → degraded tail
llm = GeminiSafe(model="gemini-2.5-flash", temperature=0)
report = llm.with_structured_output(Report).invoke("Review this diff: ...")
# Validates. The anyOf-enum is stripped before send, out-of-range
# severities are clamped, and a broken trailing finding is salvaged away.It also retries transient transport faults (ConnectionResetError, aiohttp
ClientOSError, ServerDisconnectedError) that the google-genai SDK leaves
uncaught — at the single async seam every call funnels through.
One call. Hand it the decoded dict and your model:
from gemini_coax import coax
raw = json.loads(response.text) # whatever Gemini gave you
report = coax(raw, Report) # clamp → fill nullables → validate → repair enums → salvage listsOr compose the pieces yourself:
from gemini_coax import (
strip_nullable_anyof, # rewrite the schema BEFORE you send it
clamp_to_constraints, # clamp ignored numeric / length / array bounds
fill_missing_nullables, # inject None for nullables Gemini omitted
repair_enums, # fuzzy-match close-but-wrong enum values
salvage_lists, # drop broken tail entries, keep the valid ones
)
schema = strip_nullable_anyof(Report.model_json_schema()) # send THIS to Gemini| Gemini misbehavior | gemini-coax response |
|---|---|
Drops enum inside anyOf (nullable Literal) → hallucinated values |
strip_nullable_anyof rewrites the schema to a plain enum + drops it from required before send |
Ignores ge/le/gt/lt, max_length, max_items |
clamp_to_constraints clamps raw values to the model's field metadata |
| Omits a now-optional nullable field | fill_missing_nullables injects None so re-validation passes |
Close-but-wrong enum at the array tail ("defensiveness" vs "defensiveness-tone") |
repair_enums fuzzy-matches it back (zero-cost difflib) |
Empty {} / truncated objects when the token budget runs out |
salvage_lists validates entries individually, keeps the good ones |
Unbounded free-string field loops until MAX_TOKENS, truncating the array |
GeminiSafe detects the truncation and re-issues once with the runaway-prone string fields stripped, then re-homes onto your model |
| Transient transport fault before any HTTP status | GeminiSafe retries with exponential backoff + jitter |
A full-chain retry is 100–300× more expensive than these repairs — and often
makes things worse. Repair beats re-roll. The one case that does warrant a
second call is the MAX_TOKENS string runaway below: the array never finished
generating, so there is nothing to repair — only to regenerate without the field
the decoder looped on.
Gemini's constrained decoder, while generating an unbounded free-string field
(a str with no enum/Literal and a maxLength it ignores), can fall into a
degenerate repetition loop — emitting the same phrase thousands of times until it
exhausts max_output_tokens and returns finish_reason == "MAX_TOKENS". The JSON
is then truncated mid-array: every list entry after the runaway is lost, and
salvage_lists can only recover the one or two entries that completed before it.
A 16-row extraction silently returns 1 row.
salvage_lists cannot fix this — the rows were never emitted. GeminiSafe handles
it at the call seam:
- Detect —
finish_reason == "MAX_TOKENS"means the output is truncated and untrustworthy. - Recover — re-issue the same prompt once with the runaway-prone string fields stripped from the response schema. With no unbounded string to loop on, the decoder completes the array normally.
- Re-home — validate the recovered rows back onto your original model. Stripped
fields fill from their default, then
Noneif nullable, then""for a required string (the value the runaway destroyed anyway).
This only arms when your model actually has a runaway-prone string field, and only
fires on an observed MAX_TOKENS truncation — clean calls cost exactly one request,
as before. Compose the pieces directly with the raw SDK:
from gemini_coax import (
runaway_prone_string_fields, # which str fields can loop (no enum/pattern/tight max_length)
strip_runaway_strings, # build a recovery twin model with those fields removed
rehome_to_original, # validate recovered rows back onto your strict model
MAX_TOKENS_FINISH_REASONS, # {"MAX_TOKENS", "LENGTH", ...}
)Two layers, so the value isn't hostage to any framework's release notes:
- Core (
gemini_coax.schema,gemini_coax.repair,coax) — pure functions overdict+ Pydantic. Only dependency ispydantic. Works with the raw SDK, Vertex AI, or anything that hands you a dict. - Adapter (
gemini_coax.langchain.GeminiSafe) — the LangChain drop-in. Pulled in only by the[langchain]extra; pinslangchain-google-genai>=4.2,<5.
- 0.2.4 — fix:
TimeoutErroris a builtinOSErrorsubclass, so the transient-transport retry predicate was matching it and retrying a call that had already burned its own timeout budget once, up to 3× that same budget. The retry predicate now excludesTimeoutErrorexplicitly — genuine transport faults (ConnectionError, non-timeoutOSErrors like aiohttp'sClientOSError) still retry; a timeout propagates on the first attempt. - 0.2.1 — fix: restore raise/error-envelope on total parse failure (regression
from 0.2.0's runaway refactor); add
GeminiSafe.with_structured_outputtotal-failure coverage. - 0.2.0 —
MAX_TOKENSstring-runaway detection + one-shot recovery.
Releases publish to PyPI automatically via
.github/workflows/release.yml, triggered on any
v* tag push — no API token, using PyPI
Trusted Publishing over OIDC.
To cut a release:
- Bump the version in both
pyproject.toml(version) andsrc/gemini_coax/__init__.py(__version__) — they must match. - Commit, then tag and push:
git tag v0.2.0 && git push origin v0.2.0 - The workflow verifies the tag equals the package version, runs ruff + the test
suite, builds the wheel + sdist, and publishes to PyPI. A mismatched tag (e.g.
v0.2.0whilepyprojectsays0.1.0) fails before any upload.
One-time setup (already done for this repo): a PyPI
pending publisher — project
gemini-coax, owner mreza0100, repo gemini-coax, workflow release.yml,
environment pypi — plus a GitHub environment named pypi.
MIT