Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.4 — fix: TimeoutError is a builtin OSError subclass, 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 excludes TimeoutError explicitly — genuine transport faults (ConnectionError, non-timeout OSErrors like aiohttp's ClientOSError) 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_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

About

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

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages