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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./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
+ [![PyPI](https://img.shields.io/pypi/v/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/gemini-coax.svg)](https://pypi.org/project/gemini-coax/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./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