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.
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
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 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.
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:
- 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.
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4488440855315e8f2f6191076b33f72ff1959a859cbb5013d327fbda96b681b3
|
|
| MD5 |
f9fdb79b0b3da2148e709255100c34ab
|
|
| BLAKE2b-256 |
a94d00106a90d293a77317d848b41f984dfa894c8f55e3dec2778b131202cf99
|
Provenance
The following attestation bundles were made for gemini_coax-0.1.1.tar.gz:
Publisher:
release.yml on mreza0100/gemini-coax
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gemini_coax-0.1.1.tar.gz -
Subject digest:
4488440855315e8f2f6191076b33f72ff1959a859cbb5013d327fbda96b681b3 - Sigstore transparency entry: 1898261666
- Sigstore integration time:
-
Permalink:
mreza0100/gemini-coax@7203a8c4f288b7fed5ea95e242aac296d064e1ec -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/mreza0100
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7203a8c4f288b7fed5ea95e242aac296d064e1ec -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
20d99f02465c3fce35123be16418a29c2302527d1b5808da1bdaf80821a813ed
|
|
| MD5 |
a90587792c23eae9d68c3fa6f4251100
|
|
| BLAKE2b-256 |
35c4055c1420be7ed34c15dd488f9f35dddb143d3ebb7ab64aa406f88466e78c
|
Provenance
The following attestation bundles were made for gemini_coax-0.1.1-py3-none-any.whl:
Publisher:
release.yml on mreza0100/gemini-coax
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gemini_coax-0.1.1-py3-none-any.whl -
Subject digest:
20d99f02465c3fce35123be16418a29c2302527d1b5808da1bdaf80821a813ed - Sigstore transparency entry: 1898261756
- Sigstore integration time:
-
Permalink:
mreza0100/gemini-coax@7203a8c4f288b7fed5ea95e242aac296d064e1ec -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/mreza0100
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7203a8c4f288b7fed5ea95e242aac296d064e1ec -
Trigger Event:
push
-
Statement type: