Skip to main content

Make Google Gemini structured output actually validate against your Pydantic models — fixes the anyOf/enum drop, ignored numeric & length bounds, degraded array tails, and MAX_TOKENS string-runaway collapse.

Project description

gemini-coax

Make Google Gemini structured output actually validate against your Pydantic models.

PyPI Python License: MIT

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 API
  • Input should be 'a', 'b' or 'c' [type=literal_error] on a value the schema defined
  • A nullable Literal[...] | None field where Gemini invents values off-menu
  • ge/le/max_length/max_items constraints ignored, failing validation
  • Empty {} or truncated objects at the tail of a long list, killing the whole array
  • A free-text str field that loops the same phrase until finish_reason=MAX_TOKENS, truncating the JSON mid-array so an N-item extraction collapses to one

Install

pip install gemini-coax                  # core — pure, depends only on pydantic
pip install "gemini-coax[langchain]"     # + the drop-in ChatGoogleGenerativeAI

Use it — LangChain (langchain-google-genai)

Swap 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.

Use it — raw google-genai SDK (no LangChain)

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 lists

Or 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

What it does

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.

The MAX_TOKENS string runaway

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:

  1. Detectfinish_reason == "MAX_TOKENS" means the output is truncated and untrustworthy.
  2. 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.
  3. Re-home — validate the recovered rows back onto your original model. Stripped fields fill from their default, then None if 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", ...}
)

Design

Two layers, so the value isn't hostage to any framework's release notes:

  • Core (gemini_coax.schema, gemini_coax.repair, coax) — pure functions over dict + Pydantic. Only dependency is pydantic. 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; pins langchain-google-genai>=4.2,<5.

Releasing

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:

  1. Bump the version in both pyproject.toml (version) and src/gemini_coax/__init__.py (__version__) — they must match.
  2. Commit, then tag and push:
    git tag v0.2.0 && git push origin v0.2.0
    
  3. 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.0 while pyproject says 0.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.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

gemini_coax-0.2.0.tar.gz (20.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

gemini_coax-0.2.0-py3-none-any.whl (20.7 kB view details)

Uploaded Python 3

File details

Details for the file gemini_coax-0.2.0.tar.gz.

File metadata

  • Download URL: gemini_coax-0.2.0.tar.gz
  • Upload date:
  • Size: 20.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for gemini_coax-0.2.0.tar.gz
Algorithm Hash digest
SHA256 a3d8350698a26edff7f5e0ccf5f5913c0abc0e40414644d790d0e40ea3c2d94e
MD5 768a24cbc9a9284eb50f17fdf80aeea6
BLAKE2b-256 12ce191c3fc72df2a5e89fa5fbad0f30298524ee5f0edaf5b4d984caa82fde9d

See more details on using hashes here.

Provenance

The following attestation bundles were made for gemini_coax-0.2.0.tar.gz:

Publisher: release.yml on mreza0100/gemini-coax

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gemini_coax-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: gemini_coax-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 20.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for gemini_coax-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e07483e37795a3009ebe429eaf1ace8b799cee3f68286c44ed17a833876f836f
MD5 cf263f2e32244fa5b90330588c84a42a
BLAKE2b-256 47428e0e74da355cd0f07de31e8e7f4653337e496fe3f4238efefa57fb53ddf2

See more details on using hashes here.

Provenance

The following attestation bundles were made for gemini_coax-0.2.0-py3-none-any.whl:

Publisher: release.yml on mreza0100/gemini-coax

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page