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.

Changelog

  • 0.2.1 — fix: restore raise/error-envelope on total parse failure (regression from 0.2.0's runaway refactor); add GeminiSafe.with_structured_output total-failure coverage.
  • 0.2.0MAX_TOKENS string-runaway detection + one-shot recovery.

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.3.tar.gz (22.9 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.3-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: gemini_coax-0.2.3.tar.gz
  • Upload date:
  • Size: 22.9 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.3.tar.gz
Algorithm Hash digest
SHA256 28309f0f3ad6ff7f110f006c50d2e92ff685513fcbca8b771884bb5206dc4ecf
MD5 a5f50e027a8a78a2011ffdbd1e424aa6
BLAKE2b-256 153bbce0d3c97f54450af93cfa469b226292990a0994692b8e8c1b8233f0e66a

See more details on using hashes here.

Provenance

The following attestation bundles were made for gemini_coax-0.2.3.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.3-py3-none-any.whl.

File metadata

  • Download URL: gemini_coax-0.2.3-py3-none-any.whl
  • Upload date:
  • Size: 21.5 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.3-py3-none-any.whl
Algorithm Hash digest
SHA256 212166aa625f7068cdfde7f07d2a14fc1e6a3e8bda043fa0ab35cfd6a684f9ed
MD5 3c626a14f943db453f776f56bc2c08a3
BLAKE2b-256 5210e3a192fa4e434f2da714209dcfd4e9a3f8da18761c657b1290d4a4d295fa

See more details on using hashes here.

Provenance

The following attestation bundles were made for gemini_coax-0.2.3-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