Skip to main content

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

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

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

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.1.1.tar.gz (15.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.1.1-py3-none-any.whl (15.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: gemini_coax-0.1.1.tar.gz
  • Upload date:
  • Size: 15.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.1.1.tar.gz
Algorithm Hash digest
SHA256 4488440855315e8f2f6191076b33f72ff1959a859cbb5013d327fbda96b681b3
MD5 f9fdb79b0b3da2148e709255100c34ab
BLAKE2b-256 a94d00106a90d293a77317d848b41f984dfa894c8f55e3dec2778b131202cf99

See more details on using hashes here.

Provenance

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

File metadata

  • Download URL: gemini_coax-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 15.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.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 20d99f02465c3fce35123be16418a29c2302527d1b5808da1bdaf80821a813ed
MD5 a90587792c23eae9d68c3fa6f4251100
BLAKE2b-256 35c4055c1420be7ed34c15dd488f9f35dddb143d3ebb7ab64aa406f88466e78c

See more details on using hashes here.

Provenance

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