gemini-coax 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gemini_coax-0.1.0/.github/workflows/ci.yml +25 -0
- gemini_coax-0.1.0/.github/workflows/release.yml +57 -0
- gemini_coax-0.1.0/.gitignore +28 -0
- gemini_coax-0.1.0/LICENSE +21 -0
- gemini_coax-0.1.0/PKG-INFO +146 -0
- gemini_coax-0.1.0/README.md +111 -0
- gemini_coax-0.1.0/pyproject.toml +77 -0
- gemini_coax-0.1.0/src/gemini_coax/__init__.py +99 -0
- gemini_coax-0.1.0/src/gemini_coax/langchain.py +171 -0
- gemini_coax-0.1.0/src/gemini_coax/py.typed +0 -0
- gemini_coax-0.1.0/src/gemini_coax/repair.py +321 -0
- gemini_coax-0.1.0/src/gemini_coax/schema.py +223 -0
- gemini_coax-0.1.0/tests/test_repair.py +57 -0
- gemini_coax-0.1.0/tests/test_schema.py +106 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- name: Install
|
|
21
|
+
run: pip install -e ".[dev]"
|
|
22
|
+
- name: Lint
|
|
23
|
+
run: ruff check src tests
|
|
24
|
+
- name: Test
|
|
25
|
+
run: pytest -q
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build distribution
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
|
|
18
|
+
- name: Verify tag matches package version
|
|
19
|
+
run: |
|
|
20
|
+
TAG="${GITHUB_REF_NAME#v}"
|
|
21
|
+
PKG=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
22
|
+
echo "tag=$TAG pyproject=$PKG"
|
|
23
|
+
if [ "$TAG" != "$PKG" ]; then
|
|
24
|
+
echo "::error::Tag v$TAG does not match pyproject version $PKG"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: |
|
|
30
|
+
pip install -e ".[dev]"
|
|
31
|
+
ruff check src tests
|
|
32
|
+
pytest -q
|
|
33
|
+
|
|
34
|
+
- name: Build
|
|
35
|
+
run: |
|
|
36
|
+
pip install build
|
|
37
|
+
python -m build
|
|
38
|
+
|
|
39
|
+
- uses: actions/upload-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
publish:
|
|
45
|
+
name: Publish to PyPI
|
|
46
|
+
needs: build
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
environment: pypi
|
|
49
|
+
permissions:
|
|
50
|
+
id-token: write # OIDC token for Trusted Publishing — no API token needed
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/download-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
name: dist
|
|
55
|
+
path: dist/
|
|
56
|
+
- name: Publish
|
|
57
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# Virtual envs
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# uv
|
|
23
|
+
uv.lock
|
|
24
|
+
|
|
25
|
+
# Editors / OS
|
|
26
|
+
.vscode/
|
|
27
|
+
.idea/
|
|
28
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mreza0100
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gemini-coax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make Google Gemini structured output actually validate against your Pydantic models — fixes the anyOf/enum drop, ignored numeric & length bounds, and degraded array tails.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mreza0100/gemini-coax
|
|
6
|
+
Project-URL: Repository, https://github.com/mreza0100/gemini-coax
|
|
7
|
+
Project-URL: Issues, https://github.com/mreza0100/gemini-coax/issues
|
|
8
|
+
Author: mreza0100
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anyof,constrained-decoding,gemini,google-gemini,json-schema,langchain,langchain-google-genai,llm,pydantic,structured-output,validation,vertex-ai
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Provides-Extra: langchain
|
|
31
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
32
|
+
Requires-Dist: langchain-google-genai<5,>=4.2; extra == 'langchain'
|
|
33
|
+
Requires-Dist: tenacity>=9.0; extra == 'langchain'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# gemini-coax
|
|
37
|
+
|
|
38
|
+
**Make Google Gemini structured output actually validate against your Pydantic models.**
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/gemini-coax/)
|
|
41
|
+
[](https://pypi.org/project/gemini-coax/)
|
|
42
|
+
[](./LICENSE)
|
|
43
|
+
|
|
44
|
+
Gemini's `response_json_schema` promises structured output, then quietly breaks
|
|
45
|
+
its own promise. It enforces *shape* (types, properties, required) but **silently
|
|
46
|
+
ignores value-level constraints** — so the model hallucinates enum values, blows
|
|
47
|
+
past your numeric bounds, and trails off into half-formed objects at the end of
|
|
48
|
+
long arrays. Pydantic then rejects the *entire* response over one bad field.
|
|
49
|
+
|
|
50
|
+
`gemini-coax` coaxes the output back into shape. No retries, no extra LLM calls
|
|
51
|
+
for the common cases — just targeted repair at the validation seam.
|
|
52
|
+
|
|
53
|
+
If you've hit any of these, this library is for you:
|
|
54
|
+
|
|
55
|
+
- `ValueError: AnyOf is not supported in the response schema for the Gemini API`
|
|
56
|
+
- `Input should be 'a', 'b' or 'c' [type=literal_error]` on a value the schema *defined*
|
|
57
|
+
- A nullable `Literal[...] | None` field where Gemini invents values off-menu
|
|
58
|
+
- `ge`/`le`/`max_length`/`max_items` constraints ignored, failing validation
|
|
59
|
+
- Empty `{}` or truncated objects at the tail of a long list, killing the whole array
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install gemini-coax # core — pure, depends only on pydantic
|
|
65
|
+
pip install "gemini-coax[langchain]" # + the drop-in ChatGoogleGenerativeAI
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Use it — LangChain (`langchain-google-genai`)
|
|
69
|
+
|
|
70
|
+
Swap `ChatGoogleGenerativeAI` for `GeminiSafe`. That's the whole change. Every
|
|
71
|
+
`with_structured_output()` call is now coaxed; no edits in your chains.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from typing import Literal
|
|
75
|
+
from pydantic import BaseModel, Field
|
|
76
|
+
from gemini_coax import GeminiSafe # was: ChatGoogleGenerativeAI
|
|
77
|
+
|
|
78
|
+
class Finding(BaseModel):
|
|
79
|
+
label: Literal["bug", "smell", "nit"] | None # nullable enum — Gemini drops the enum
|
|
80
|
+
severity: int = Field(ge=1, le=5) # bounds Gemini ignores
|
|
81
|
+
|
|
82
|
+
class Report(BaseModel):
|
|
83
|
+
findings: list[Finding] # long array → degraded tail
|
|
84
|
+
|
|
85
|
+
llm = GeminiSafe(model="gemini-2.5-flash", temperature=0)
|
|
86
|
+
report = llm.with_structured_output(Report).invoke("Review this diff: ...")
|
|
87
|
+
# Validates. The anyOf-enum is stripped before send, out-of-range
|
|
88
|
+
# severities are clamped, and a broken trailing finding is salvaged away.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It also retries transient transport faults (`ConnectionResetError`, aiohttp
|
|
92
|
+
`ClientOSError`, `ServerDisconnectedError`) that the google-genai SDK leaves
|
|
93
|
+
uncaught — at the single async seam every call funnels through.
|
|
94
|
+
|
|
95
|
+
## Use it — raw `google-genai` SDK (no LangChain)
|
|
96
|
+
|
|
97
|
+
One call. Hand it the decoded dict and your model:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from gemini_coax import coax
|
|
101
|
+
|
|
102
|
+
raw = json.loads(response.text) # whatever Gemini gave you
|
|
103
|
+
report = coax(raw, Report) # clamp → fill nullables → validate → repair enums → salvage lists
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or compose the pieces yourself:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from gemini_coax import (
|
|
110
|
+
strip_nullable_anyof, # rewrite the schema BEFORE you send it
|
|
111
|
+
clamp_to_constraints, # clamp ignored numeric / length / array bounds
|
|
112
|
+
fill_missing_nullables, # inject None for nullables Gemini omitted
|
|
113
|
+
repair_enums, # fuzzy-match close-but-wrong enum values
|
|
114
|
+
salvage_lists, # drop broken tail entries, keep the valid ones
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
schema = strip_nullable_anyof(Report.model_json_schema()) # send THIS to Gemini
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## What it does
|
|
121
|
+
|
|
122
|
+
| Gemini misbehavior | gemini-coax response |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| Drops `enum` inside `anyOf` (nullable `Literal`) → hallucinated values | `strip_nullable_anyof` rewrites the schema to a plain enum + drops it from `required` before send |
|
|
125
|
+
| Ignores `ge/le/gt/lt`, `max_length`, `max_items` | `clamp_to_constraints` clamps raw values to the model's field metadata |
|
|
126
|
+
| Omits a now-optional nullable field | `fill_missing_nullables` injects `None` so re-validation passes |
|
|
127
|
+
| Close-but-wrong enum at the array tail (`"defensiveness"` vs `"defensiveness-tone"`) | `repair_enums` fuzzy-matches it back (zero-cost `difflib`) |
|
|
128
|
+
| Empty `{}` / truncated objects when the token budget runs out | `salvage_lists` validates entries individually, keeps the good ones |
|
|
129
|
+
| Transient transport fault before any HTTP status | `GeminiSafe` retries with exponential backoff + jitter |
|
|
130
|
+
|
|
131
|
+
A full-chain retry is 100–300× more expensive than these repairs — and often
|
|
132
|
+
makes things worse. Repair beats re-roll.
|
|
133
|
+
|
|
134
|
+
## Design
|
|
135
|
+
|
|
136
|
+
Two layers, so the value isn't hostage to any framework's release notes:
|
|
137
|
+
|
|
138
|
+
- **Core** (`gemini_coax.schema`, `gemini_coax.repair`, `coax`) — pure functions
|
|
139
|
+
over `dict` + Pydantic. Only dependency is `pydantic`. Works with the raw SDK,
|
|
140
|
+
Vertex AI, or anything that hands you a dict.
|
|
141
|
+
- **Adapter** (`gemini_coax.langchain.GeminiSafe`) — the LangChain drop-in.
|
|
142
|
+
Pulled in only by the `[langchain]` extra; pins `langchain-google-genai>=4.2,<5`.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# gemini-coax
|
|
2
|
+
|
|
3
|
+
**Make Google Gemini structured output actually validate against your Pydantic models.**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/gemini-coax/)
|
|
6
|
+
[](https://pypi.org/project/gemini-coax/)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
Gemini's `response_json_schema` promises structured output, then quietly breaks
|
|
10
|
+
its own promise. It enforces *shape* (types, properties, required) but **silently
|
|
11
|
+
ignores value-level constraints** — so the model hallucinates enum values, blows
|
|
12
|
+
past your numeric bounds, and trails off into half-formed objects at the end of
|
|
13
|
+
long arrays. Pydantic then rejects the *entire* response over one bad field.
|
|
14
|
+
|
|
15
|
+
`gemini-coax` coaxes the output back into shape. No retries, no extra LLM calls
|
|
16
|
+
for the common cases — just targeted repair at the validation seam.
|
|
17
|
+
|
|
18
|
+
If you've hit any of these, this library is for you:
|
|
19
|
+
|
|
20
|
+
- `ValueError: AnyOf is not supported in the response schema for the Gemini API`
|
|
21
|
+
- `Input should be 'a', 'b' or 'c' [type=literal_error]` on a value the schema *defined*
|
|
22
|
+
- A nullable `Literal[...] | None` field where Gemini invents values off-menu
|
|
23
|
+
- `ge`/`le`/`max_length`/`max_items` constraints ignored, failing validation
|
|
24
|
+
- Empty `{}` or truncated objects at the tail of a long list, killing the whole array
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install gemini-coax # core — pure, depends only on pydantic
|
|
30
|
+
pip install "gemini-coax[langchain]" # + the drop-in ChatGoogleGenerativeAI
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use it — LangChain (`langchain-google-genai`)
|
|
34
|
+
|
|
35
|
+
Swap `ChatGoogleGenerativeAI` for `GeminiSafe`. That's the whole change. Every
|
|
36
|
+
`with_structured_output()` call is now coaxed; no edits in your chains.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from typing import Literal
|
|
40
|
+
from pydantic import BaseModel, Field
|
|
41
|
+
from gemini_coax import GeminiSafe # was: ChatGoogleGenerativeAI
|
|
42
|
+
|
|
43
|
+
class Finding(BaseModel):
|
|
44
|
+
label: Literal["bug", "smell", "nit"] | None # nullable enum — Gemini drops the enum
|
|
45
|
+
severity: int = Field(ge=1, le=5) # bounds Gemini ignores
|
|
46
|
+
|
|
47
|
+
class Report(BaseModel):
|
|
48
|
+
findings: list[Finding] # long array → degraded tail
|
|
49
|
+
|
|
50
|
+
llm = GeminiSafe(model="gemini-2.5-flash", temperature=0)
|
|
51
|
+
report = llm.with_structured_output(Report).invoke("Review this diff: ...")
|
|
52
|
+
# Validates. The anyOf-enum is stripped before send, out-of-range
|
|
53
|
+
# severities are clamped, and a broken trailing finding is salvaged away.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It also retries transient transport faults (`ConnectionResetError`, aiohttp
|
|
57
|
+
`ClientOSError`, `ServerDisconnectedError`) that the google-genai SDK leaves
|
|
58
|
+
uncaught — at the single async seam every call funnels through.
|
|
59
|
+
|
|
60
|
+
## Use it — raw `google-genai` SDK (no LangChain)
|
|
61
|
+
|
|
62
|
+
One call. Hand it the decoded dict and your model:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from gemini_coax import coax
|
|
66
|
+
|
|
67
|
+
raw = json.loads(response.text) # whatever Gemini gave you
|
|
68
|
+
report = coax(raw, Report) # clamp → fill nullables → validate → repair enums → salvage lists
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or compose the pieces yourself:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from gemini_coax import (
|
|
75
|
+
strip_nullable_anyof, # rewrite the schema BEFORE you send it
|
|
76
|
+
clamp_to_constraints, # clamp ignored numeric / length / array bounds
|
|
77
|
+
fill_missing_nullables, # inject None for nullables Gemini omitted
|
|
78
|
+
repair_enums, # fuzzy-match close-but-wrong enum values
|
|
79
|
+
salvage_lists, # drop broken tail entries, keep the valid ones
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
schema = strip_nullable_anyof(Report.model_json_schema()) # send THIS to Gemini
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## What it does
|
|
86
|
+
|
|
87
|
+
| Gemini misbehavior | gemini-coax response |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| Drops `enum` inside `anyOf` (nullable `Literal`) → hallucinated values | `strip_nullable_anyof` rewrites the schema to a plain enum + drops it from `required` before send |
|
|
90
|
+
| Ignores `ge/le/gt/lt`, `max_length`, `max_items` | `clamp_to_constraints` clamps raw values to the model's field metadata |
|
|
91
|
+
| Omits a now-optional nullable field | `fill_missing_nullables` injects `None` so re-validation passes |
|
|
92
|
+
| Close-but-wrong enum at the array tail (`"defensiveness"` vs `"defensiveness-tone"`) | `repair_enums` fuzzy-matches it back (zero-cost `difflib`) |
|
|
93
|
+
| Empty `{}` / truncated objects when the token budget runs out | `salvage_lists` validates entries individually, keeps the good ones |
|
|
94
|
+
| Transient transport fault before any HTTP status | `GeminiSafe` retries with exponential backoff + jitter |
|
|
95
|
+
|
|
96
|
+
A full-chain retry is 100–300× more expensive than these repairs — and often
|
|
97
|
+
makes things worse. Repair beats re-roll.
|
|
98
|
+
|
|
99
|
+
## Design
|
|
100
|
+
|
|
101
|
+
Two layers, so the value isn't hostage to any framework's release notes:
|
|
102
|
+
|
|
103
|
+
- **Core** (`gemini_coax.schema`, `gemini_coax.repair`, `coax`) — pure functions
|
|
104
|
+
over `dict` + Pydantic. Only dependency is `pydantic`. Works with the raw SDK,
|
|
105
|
+
Vertex AI, or anything that hands you a dict.
|
|
106
|
+
- **Adapter** (`gemini_coax.langchain.GeminiSafe`) — the LangChain drop-in.
|
|
107
|
+
Pulled in only by the `[langchain]` extra; pins `langchain-google-genai>=4.2,<5`.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gemini-coax"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Make Google Gemini structured output actually validate against your Pydantic models — fixes the anyOf/enum drop, ignored numeric & length bounds, and degraded array tails."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "mreza0100" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"gemini",
|
|
11
|
+
"google-gemini",
|
|
12
|
+
"structured-output",
|
|
13
|
+
"pydantic",
|
|
14
|
+
"json-schema",
|
|
15
|
+
"langchain",
|
|
16
|
+
"langchain-google-genai",
|
|
17
|
+
"vertex-ai",
|
|
18
|
+
"llm",
|
|
19
|
+
"anyof",
|
|
20
|
+
"constrained-decoding",
|
|
21
|
+
"validation",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
]
|
|
37
|
+
dependencies = [
|
|
38
|
+
"pydantic>=2.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
langchain = [
|
|
43
|
+
"langchain-google-genai>=4.2,<5",
|
|
44
|
+
"langchain-core>=0.3",
|
|
45
|
+
"tenacity>=9.0",
|
|
46
|
+
]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=8.0",
|
|
49
|
+
"ruff>=0.6",
|
|
50
|
+
"mypy>=1.10",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/mreza0100/gemini-coax"
|
|
55
|
+
Repository = "https://github.com/mreza0100/gemini-coax"
|
|
56
|
+
Issues = "https://github.com/mreza0100/gemini-coax/issues"
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["hatchling"]
|
|
60
|
+
build-backend = "hatchling.build"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/gemini_coax"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py310"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.10"
|
|
74
|
+
strict = true
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""gemini-coax — make Gemini structured output actually validate.
|
|
2
|
+
|
|
3
|
+
Gemini's ``response_json_schema`` enforces structure but silently ignores
|
|
4
|
+
``anyOf`` enums, numeric/length/array bounds, and degrades at the tail of long
|
|
5
|
+
arrays — so Pydantic rejects otherwise-good output. ``gemini-coax`` coaxes it
|
|
6
|
+
into shape.
|
|
7
|
+
|
|
8
|
+
Two layers:
|
|
9
|
+
|
|
10
|
+
* **Core** (this module + :mod:`gemini_coax.schema` / :mod:`gemini_coax.repair`)
|
|
11
|
+
— pure functions over ``dict`` + Pydantic, no provider SDK. Use :func:`coax`
|
|
12
|
+
with the raw ``google-genai`` SDK.
|
|
13
|
+
* **Adapter** (:mod:`gemini_coax.langchain`, optional ``[langchain]`` extra) —
|
|
14
|
+
:class:`~gemini_coax.langchain.GeminiSafe`, a drop-in ``ChatGoogleGenerativeAI``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ValidationError
|
|
22
|
+
|
|
23
|
+
from .repair import repair_enums, salvage_list, salvage_lists
|
|
24
|
+
from .schema import clamp_to_constraints, fill_missing_nullables, strip_nullable_anyof
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
# Static binding for type checkers: at runtime ``GeminiSafe`` is supplied
|
|
28
|
+
# lazily by ``__getattr__`` below (so the core import stays free of
|
|
29
|
+
# langchain), but mypy/pyright cannot follow a runtime ``__getattr__`` and
|
|
30
|
+
# would type ``from gemini_coax import GeminiSafe`` as ``Any``. This
|
|
31
|
+
# re-export (``as GeminiSafe``) gives them the concrete class without
|
|
32
|
+
# importing langchain at runtime.
|
|
33
|
+
from .langchain import GeminiSafe as GeminiSafe
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"coax",
|
|
39
|
+
"strip_nullable_anyof",
|
|
40
|
+
"clamp_to_constraints",
|
|
41
|
+
"fill_missing_nullables",
|
|
42
|
+
"repair_enums",
|
|
43
|
+
"salvage_list",
|
|
44
|
+
"salvage_lists",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def __getattr__(name: str) -> Any:
|
|
50
|
+
"""Lazily expose the optional LangChain adapter at the top level.
|
|
51
|
+
|
|
52
|
+
``from gemini_coax import GeminiSafe`` works without forcing the core import
|
|
53
|
+
to depend on ``langchain`` — the adapter (and its ``langchain`` requirement)
|
|
54
|
+
is only imported the moment ``GeminiSafe`` is actually accessed. With the
|
|
55
|
+
``[langchain]`` extra absent, that access raises the adapter's helpful
|
|
56
|
+
ImportError; plain ``import gemini_coax`` stays clean either way.
|
|
57
|
+
"""
|
|
58
|
+
if name == "GeminiSafe":
|
|
59
|
+
from .langchain import GeminiSafe
|
|
60
|
+
|
|
61
|
+
return GeminiSafe
|
|
62
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
63
|
+
raise AttributeError(msg)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def coax(raw: dict[str, Any], model: type[BaseModel]) -> BaseModel:
|
|
67
|
+
"""Coax a raw Gemini dict into a validated model instance.
|
|
68
|
+
|
|
69
|
+
Runs the full framework-free pipeline: clamp out-of-range values, fill
|
|
70
|
+
omitted nullables, then validate. If validation still fails, repair wrong
|
|
71
|
+
enum values, then salvage broken list tails. Raises the original
|
|
72
|
+
``ValidationError`` only if nothing could be recovered.
|
|
73
|
+
|
|
74
|
+
This is the one-call entry point for the raw ``google-genai`` SDK. LangChain
|
|
75
|
+
users should use :class:`gemini_coax.langchain.GeminiSafe` instead, which
|
|
76
|
+
applies the same pipeline transparently inside ``with_structured_output``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
raw: The decoded JSON dict Gemini returned.
|
|
80
|
+
model: The Pydantic model you expected.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A validated instance of ``model``.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValidationError: If the output could not be coaxed into the schema.
|
|
87
|
+
"""
|
|
88
|
+
clamped = clamp_to_constraints(raw, model)
|
|
89
|
+
clamped = fill_missing_nullables(clamped, model)
|
|
90
|
+
try:
|
|
91
|
+
return model.model_validate(clamped)
|
|
92
|
+
except ValidationError as error:
|
|
93
|
+
repaired = repair_enums(error, clamped, model)
|
|
94
|
+
if repaired is not None:
|
|
95
|
+
return repaired
|
|
96
|
+
salvaged = salvage_lists(clamped, model)
|
|
97
|
+
if salvaged is not None:
|
|
98
|
+
return salvaged
|
|
99
|
+
raise
|