Metadata-Version: 2.4
Name: gemini-coax
Version: 0.2.2
Summary: 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-URL: Homepage, https://github.com/mreza0100/gemini-coax
Project-URL: Repository, https://github.com/mreza0100/gemini-coax
Project-URL: Issues, https://github.com/mreza0100/gemini-coax/issues
Author: mreza0100
License: MIT
License-File: LICENSE
Keywords: anyof,constrained-decoding,gemini,google-gemini,json-schema,langchain,langchain-google-genai,llm,pydantic,structured-output,validation,vertex-ai
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
Requires-Dist: langchain-google-genai<5,>=4.2; extra == 'langchain'
Requires-Dist: tenacity>=9.0; extra == 'langchain'
Description-Content-Type: text/markdown

# gemini-coax

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

[![PyPI](https://img.shields.io/pypi/v/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
[![Python](https://img.shields.io/pypi/pyversions/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)

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

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

```python
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:

```python
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:

```python
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. **Detect** — `finish_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:

```python
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.0** — `MAX_TOKENS` string-runaway detection + one-shot recovery.

## Releasing

Releases publish to PyPI **automatically** via
[`.github/workflows/release.yml`](./.github/workflows/release.yml), triggered on any
`v*` tag push — no API token, using PyPI
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) 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:
   ```bash
   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](https://pypi.org/manage/account/publishing/) — project
`gemini-coax`, owner `mreza0100`, repo `gemini-coax`, workflow `release.yml`,
environment `pypi` — plus a GitHub environment named `pypi`.

## License

MIT
