fintom8 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
+ # Copy to .env in your project. Never commit .env. Set LLM_MODEL + matching key(s).
2
+
3
+ LLM_MODEL=gemini/gemini-3.5-flash
4
+ LLM_TEMPERATURE=0.0
5
+
6
+ # Gemini API (default)
7
+ # LLM_MODEL=gemini/gemini-3.5-flash
8
+ # GEMINI_API_KEY=
9
+
10
+ # Vertex AI
11
+ # LLM_MODEL=vertex_ai/gemini-3.5-flash
12
+ # VERTEXAI_PROJECT=
13
+ # VERTEXAI_LOCATION=eu
14
+ # Also run: gcloud auth application-default login
15
+
16
+ # OpenAI
17
+ # LLM_MODEL=gpt-4o
18
+ # OPENAI_API_KEY=
19
+ # OPENAI_API_BASE=
20
+
21
+ # Azure OpenAI (use your deployment name)
22
+ # LLM_MODEL=azure/<deployment-name>
23
+ # AZURE_API_KEY=
24
+ # AZURE_API_BASE=https://<resource>.openai.azure.com
25
+ # AZURE_API_VERSION=2024-10-21
@@ -0,0 +1,8 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .venv/
7
+ .env
8
+ *.pyc
fintom8-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: fintom8
3
+ Version: 0.1.0
4
+ Summary: LiteLLM connector for Gemini, Vertex AI, OpenAI, and Azure — chat, stream, and document extract.
5
+ Project-URL: Homepage, https://github.com/fintom8/f_templates
6
+ Project-URL: Documentation, https://github.com/fintom8/f_templates/tree/main/fintom8
7
+ License: MIT
8
+ Keywords: azure,gemini,litellm,llm,openai,vertex
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: litellm<2.0.0,>=1.94.0
20
+ Requires-Dist: python-dotenv>=1.0.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # fintom8
29
+
30
+ LiteLLM connector for **Gemini / Vertex AI / OpenAI / Azure**. Chat, stream, and document extract. Students install with pip and call a few methods — keys stay in `.env`.
31
+
32
+ ```bash
33
+ pip install fintom8
34
+ ```
35
+
36
+ ```python
37
+ from fintom8 import LLM
38
+
39
+ llm = LLM() # reads .env / environment
40
+ print(llm.chat("Summarize this invoice").text)
41
+ ```
42
+
43
+ Local development from this repo:
44
+
45
+ ```bash
46
+ pip install -e ./fintom8
47
+ # or: pip install -e "./fintom8[dev]"
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Resolution order: **constructor kwargs / `LLMConfig` > environment > defaults**.
53
+
54
+ Copy [`.env.example`](./.env.example) to `.env` in your project (never commit it).
55
+
56
+ | Param | Env | Default | When needed |
57
+ |-------|-----|---------|-------------|
58
+ | `model` | `LLM_MODEL` | `gemini/gemini-3.5-flash` | always |
59
+ | `temperature` | `LLM_TEMPERATURE` | `0.0` | optional |
60
+ | `num_retries` | — | `3` | optional |
61
+ | `api_key` | `GEMINI_API_KEY` / `OPENAI_API_KEY` / `AZURE_API_KEY` (from model prefix) | unset | Gemini / OpenAI / Azure |
62
+ | `api_base` | `AZURE_API_BASE` / `OPENAI_API_BASE` | unset | Azure (required) |
63
+ | `api_version` | `AZURE_API_VERSION` | `2024-10-21` | Azure |
64
+ | `vertex_project` | `VERTEXAI_PROJECT` | unset | Vertex |
65
+ | `vertex_location` | `VERTEXAI_LOCATION` | `eu` | Vertex |
66
+
67
+ ```python
68
+ from fintom8 import LLM, LLMConfig
69
+
70
+ llm = LLM() # env defaults
71
+ llm = LLM(model="gpt-4o", api_key="sk-...", temperature=0)
72
+ llm = LLM(LLMConfig(
73
+ model="azure/my-deploy",
74
+ api_key="...",
75
+ api_base="https://....openai.azure.com",
76
+ api_version="2024-10-21",
77
+ ))
78
+ ```
79
+
80
+ ### Switch provider
81
+
82
+ | `LLM_MODEL` | Env |
83
+ |-------------|-----|
84
+ | `gemini/gemini-3.5-flash` | `GEMINI_API_KEY` |
85
+ | `vertex_ai/gemini-3.5-flash` | `VERTEXAI_PROJECT` + `VERTEXAI_LOCATION` + ADC (`gcloud auth application-default login`) |
86
+ | `gpt-4o` | `OPENAI_API_KEY` |
87
+ | `azure/<deployment>` | `AZURE_API_KEY` + `AZURE_API_BASE` + `AZURE_API_VERSION` |
88
+
89
+ ## Usage
90
+
91
+ ```python
92
+ from fintom8 import LLM
93
+
94
+ llm = LLM()
95
+
96
+ resp = llm.chat("Hello")
97
+ resp = llm.chat(
98
+ [{"role": "user", "content": "Extract the invoice total"}],
99
+ response_schema={
100
+ "type": "object",
101
+ "properties": {"total": {"type": "number"}},
102
+ "required": ["total"],
103
+ },
104
+ schema_name="Invoice",
105
+ )
106
+ print(resp.text, resp.data, resp.usage)
107
+
108
+ for chunk in llm.stream([{"role": "user", "content": "Write a haiku"}]):
109
+ print(chunk, end="", flush=True)
110
+
111
+ resp = llm.extract("invoice.pdf", response_schema={...})
112
+ ```
113
+
114
+ Async twins for FastAPI / async scripts: `achat`, `astream`, `aextract`.
115
+
116
+ Structured-output helpers (also used internally): `json_schema_response_format`, `enforce_strict`, `inline_refs`.
117
+
118
+ Failures raise `Fintom8Error`.
119
+
120
+ See [`examples/chat.py`](./examples/chat.py) and [`examples/extract.py`](./examples/extract.py).
121
+
122
+ ## Publish (maintainers)
123
+
124
+ 1. Install dev extras and run tests:
125
+
126
+ ```bash
127
+ cd fintom8
128
+ pip install -e ".[dev]"
129
+ pytest
130
+ python -c "from fintom8 import LLM"
131
+ ```
132
+
133
+ 2. Build:
134
+
135
+ ```bash
136
+ python -m build
137
+ ```
138
+
139
+ 3. Upload to TestPyPI first, then PyPI:
140
+
141
+ ```bash
142
+ python -m twine upload --repository testpypi dist/*
143
+ python -m twine upload dist/*
144
+ ```
145
+
146
+ 4. Tag for CI Trusted Publishing (OIDC). Create the PyPI project once and add a GitHub environment `pypi` with Trusted Publisher pointing at `.github/workflows/publish-fintom8.yml`. Then:
147
+
148
+ ```bash
149
+ git tag fintom8-v0.1.0
150
+ git push origin fintom8-v0.1.0
151
+ ```
@@ -0,0 +1,124 @@
1
+ # fintom8
2
+
3
+ LiteLLM connector for **Gemini / Vertex AI / OpenAI / Azure**. Chat, stream, and document extract. Students install with pip and call a few methods — keys stay in `.env`.
4
+
5
+ ```bash
6
+ pip install fintom8
7
+ ```
8
+
9
+ ```python
10
+ from fintom8 import LLM
11
+
12
+ llm = LLM() # reads .env / environment
13
+ print(llm.chat("Summarize this invoice").text)
14
+ ```
15
+
16
+ Local development from this repo:
17
+
18
+ ```bash
19
+ pip install -e ./fintom8
20
+ # or: pip install -e "./fintom8[dev]"
21
+ ```
22
+
23
+ ## Configuration
24
+
25
+ Resolution order: **constructor kwargs / `LLMConfig` > environment > defaults**.
26
+
27
+ Copy [`.env.example`](./.env.example) to `.env` in your project (never commit it).
28
+
29
+ | Param | Env | Default | When needed |
30
+ |-------|-----|---------|-------------|
31
+ | `model` | `LLM_MODEL` | `gemini/gemini-3.5-flash` | always |
32
+ | `temperature` | `LLM_TEMPERATURE` | `0.0` | optional |
33
+ | `num_retries` | — | `3` | optional |
34
+ | `api_key` | `GEMINI_API_KEY` / `OPENAI_API_KEY` / `AZURE_API_KEY` (from model prefix) | unset | Gemini / OpenAI / Azure |
35
+ | `api_base` | `AZURE_API_BASE` / `OPENAI_API_BASE` | unset | Azure (required) |
36
+ | `api_version` | `AZURE_API_VERSION` | `2024-10-21` | Azure |
37
+ | `vertex_project` | `VERTEXAI_PROJECT` | unset | Vertex |
38
+ | `vertex_location` | `VERTEXAI_LOCATION` | `eu` | Vertex |
39
+
40
+ ```python
41
+ from fintom8 import LLM, LLMConfig
42
+
43
+ llm = LLM() # env defaults
44
+ llm = LLM(model="gpt-4o", api_key="sk-...", temperature=0)
45
+ llm = LLM(LLMConfig(
46
+ model="azure/my-deploy",
47
+ api_key="...",
48
+ api_base="https://....openai.azure.com",
49
+ api_version="2024-10-21",
50
+ ))
51
+ ```
52
+
53
+ ### Switch provider
54
+
55
+ | `LLM_MODEL` | Env |
56
+ |-------------|-----|
57
+ | `gemini/gemini-3.5-flash` | `GEMINI_API_KEY` |
58
+ | `vertex_ai/gemini-3.5-flash` | `VERTEXAI_PROJECT` + `VERTEXAI_LOCATION` + ADC (`gcloud auth application-default login`) |
59
+ | `gpt-4o` | `OPENAI_API_KEY` |
60
+ | `azure/<deployment>` | `AZURE_API_KEY` + `AZURE_API_BASE` + `AZURE_API_VERSION` |
61
+
62
+ ## Usage
63
+
64
+ ```python
65
+ from fintom8 import LLM
66
+
67
+ llm = LLM()
68
+
69
+ resp = llm.chat("Hello")
70
+ resp = llm.chat(
71
+ [{"role": "user", "content": "Extract the invoice total"}],
72
+ response_schema={
73
+ "type": "object",
74
+ "properties": {"total": {"type": "number"}},
75
+ "required": ["total"],
76
+ },
77
+ schema_name="Invoice",
78
+ )
79
+ print(resp.text, resp.data, resp.usage)
80
+
81
+ for chunk in llm.stream([{"role": "user", "content": "Write a haiku"}]):
82
+ print(chunk, end="", flush=True)
83
+
84
+ resp = llm.extract("invoice.pdf", response_schema={...})
85
+ ```
86
+
87
+ Async twins for FastAPI / async scripts: `achat`, `astream`, `aextract`.
88
+
89
+ Structured-output helpers (also used internally): `json_schema_response_format`, `enforce_strict`, `inline_refs`.
90
+
91
+ Failures raise `Fintom8Error`.
92
+
93
+ See [`examples/chat.py`](./examples/chat.py) and [`examples/extract.py`](./examples/extract.py).
94
+
95
+ ## Publish (maintainers)
96
+
97
+ 1. Install dev extras and run tests:
98
+
99
+ ```bash
100
+ cd fintom8
101
+ pip install -e ".[dev]"
102
+ pytest
103
+ python -c "from fintom8 import LLM"
104
+ ```
105
+
106
+ 2. Build:
107
+
108
+ ```bash
109
+ python -m build
110
+ ```
111
+
112
+ 3. Upload to TestPyPI first, then PyPI:
113
+
114
+ ```bash
115
+ python -m twine upload --repository testpypi dist/*
116
+ python -m twine upload dist/*
117
+ ```
118
+
119
+ 4. Tag for CI Trusted Publishing (OIDC). Create the PyPI project once and add a GitHub environment `pypi` with Trusted Publisher pointing at `.github/workflows/publish-fintom8.yml`. Then:
120
+
121
+ ```bash
122
+ git tag fintom8-v0.1.0
123
+ git push origin fintom8-v0.1.0
124
+ ```
@@ -0,0 +1,7 @@
1
+ """Minimal chat example. Set LLM_MODEL + matching key in .env or the environment."""
2
+ from fintom8 import LLM
3
+
4
+ llm = LLM()
5
+ response = llm.chat("Say hello in one short sentence.")
6
+ print(response.text)
7
+ print("usage:", response.usage)
@@ -0,0 +1,20 @@
1
+ """Document extract example. Pass a PDF or image path as the first argument."""
2
+ import json
3
+ import sys
4
+
5
+ from fintom8 import LLM
6
+
7
+ schema = {
8
+ "type": "object",
9
+ "properties": {
10
+ "title": {"type": "string"},
11
+ "summary": {"type": "string"},
12
+ },
13
+ "required": ["title", "summary"],
14
+ }
15
+
16
+ if __name__ == "__main__":
17
+ path = sys.argv[1] if len(sys.argv) > 1 else "invoice.pdf"
18
+ llm = LLM()
19
+ response = llm.extract(path, response_schema=schema, schema_name="Doc")
20
+ print(json.dumps(response.data or {"text": response.text}, indent=2))
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fintom8"
7
+ version = "0.1.0"
8
+ description = "LiteLLM connector for Gemini, Vertex AI, OpenAI, and Azure — chat, stream, and document extract."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["llm", "litellm", "gemini", "openai", "azure", "vertex"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+ dependencies = [
25
+ "litellm>=1.94.0,<2.0.0",
26
+ "python-dotenv>=1.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8.0.0",
32
+ "pytest-asyncio>=0.24.0",
33
+ "build>=1.2.0",
34
+ "twine>=5.0.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/fintom8/f_templates"
39
+ Documentation = "https://github.com/fintom8/f_templates/tree/main/fintom8"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/fintom8"]
43
+
44
+ [tool.hatch.build.targets.wheel.sources]
45
+ "src" = ""
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = [
49
+ "/src",
50
+ "/tests",
51
+ "/examples",
52
+ "/README.md",
53
+ "/.env.example",
54
+ ]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ pythonpath = ["src"]
59
+ asyncio_mode = "auto"
60
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,18 @@
1
+ """fintom8 — LiteLLM connector for Gemini, Vertex AI, OpenAI, and Azure."""
2
+
3
+ from fintom8.client import LLM
4
+ from fintom8.config import LLMConfig
5
+ from fintom8.exceptions import Fintom8Error
6
+ from fintom8.schema import enforce_strict, inline_refs, json_schema_response_format
7
+ from fintom8.types import ChatResponse
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = [
11
+ "LLM",
12
+ "LLMConfig",
13
+ "ChatResponse",
14
+ "Fintom8Error",
15
+ "json_schema_response_format",
16
+ "enforce_strict",
17
+ "inline_refs",
18
+ ]
@@ -0,0 +1,263 @@
1
+ """LiteLLM connector — chat, stream, and document extract."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import mimetypes
7
+ from collections.abc import AsyncIterator, Iterator
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import litellm
12
+
13
+ from fintom8.config import LLMConfig, resolve_config
14
+ from fintom8.exceptions import Fintom8Error
15
+ from fintom8.schema import json_schema_response_format
16
+ from fintom8.types import ChatResponse
17
+
18
+ ALLOWED_MIME = {"application/pdf", "image/png", "image/jpeg", "image/webp", "image/gif"}
19
+ MAX_FILE_BYTES = 20 << 20
20
+ _SUFFIX_MIME = {
21
+ ".pdf": "application/pdf",
22
+ ".png": "image/png",
23
+ ".jpg": "image/jpeg",
24
+ ".jpeg": "image/jpeg",
25
+ ".webp": "image/webp",
26
+ ".gif": "image/gif",
27
+ }
28
+
29
+
30
+ class LLM:
31
+ """Student-facing LiteLLM client. Sync-first; async twins for FastAPI/scripts."""
32
+
33
+ def __init__(
34
+ self,
35
+ config: LLMConfig | None = None,
36
+ *,
37
+ model: str | None = None,
38
+ temperature: float | None = None,
39
+ num_retries: int | None = None,
40
+ api_key: str | None = None,
41
+ api_base: str | None = None,
42
+ api_version: str | None = None,
43
+ vertex_project: str | None = None,
44
+ vertex_location: str | None = None,
45
+ load_env: bool = True,
46
+ ) -> None:
47
+ self.config = resolve_config(
48
+ config,
49
+ model=model,
50
+ temperature=temperature,
51
+ num_retries=num_retries,
52
+ api_key=api_key,
53
+ api_base=api_base,
54
+ api_version=api_version,
55
+ vertex_project=vertex_project,
56
+ vertex_location=vertex_location,
57
+ load_env=load_env,
58
+ )
59
+ litellm.drop_params = True
60
+
61
+ def chat(
62
+ self,
63
+ messages: str | list[dict[str, Any]],
64
+ *,
65
+ response_schema: dict[str, Any] | None = None,
66
+ schema_name: str = "Response",
67
+ ) -> ChatResponse:
68
+ kw = self._completion_kwargs(_normalize_messages(messages), response_schema, schema_name)
69
+ try:
70
+ return _pack(litellm.completion(**kw), self.config.model or "", response_schema is not None)
71
+ except Fintom8Error:
72
+ raise
73
+ except Exception as exc:
74
+ raise Fintom8Error(str(exc)) from exc
75
+
76
+ async def achat(
77
+ self,
78
+ messages: str | list[dict[str, Any]],
79
+ *,
80
+ response_schema: dict[str, Any] | None = None,
81
+ schema_name: str = "Response",
82
+ ) -> ChatResponse:
83
+ kw = self._completion_kwargs(_normalize_messages(messages), response_schema, schema_name)
84
+ try:
85
+ return _pack(await litellm.acompletion(**kw), self.config.model or "", response_schema is not None)
86
+ except Fintom8Error:
87
+ raise
88
+ except Exception as exc:
89
+ raise Fintom8Error(str(exc)) from exc
90
+
91
+ def stream(self, messages: str | list[dict[str, Any]]) -> Iterator[str]:
92
+ kw = self._completion_kwargs(_normalize_messages(messages), None, "Response")
93
+ kw["stream"] = True
94
+ try:
95
+ stream = litellm.completion(**kw)
96
+ for chunk in stream:
97
+ piece = _delta_text(chunk)
98
+ if piece:
99
+ yield piece
100
+ except Fintom8Error:
101
+ raise
102
+ except Exception as exc:
103
+ raise Fintom8Error(str(exc)) from exc
104
+
105
+ async def astream(self, messages: str | list[dict[str, Any]]) -> AsyncIterator[str]:
106
+ kw = self._completion_kwargs(_normalize_messages(messages), None, "Response")
107
+ kw["stream"] = True
108
+ try:
109
+ stream = await litellm.acompletion(**kw)
110
+ async for chunk in stream:
111
+ piece = _delta_text(chunk)
112
+ if piece:
113
+ yield piece
114
+ except Fintom8Error:
115
+ raise
116
+ except Exception as exc:
117
+ raise Fintom8Error(str(exc)) from exc
118
+
119
+ def extract(
120
+ self,
121
+ file: str | Path | bytes,
122
+ *,
123
+ response_schema: dict[str, Any],
124
+ instructions: str = "Extract every field from this document.",
125
+ schema_name: str = "Extraction",
126
+ mime: str | None = None,
127
+ filename: str | None = None,
128
+ ) -> ChatResponse:
129
+ messages = _extract_messages(file, instructions, mime=mime, filename=filename)
130
+ return self.chat(messages, response_schema=response_schema, schema_name=schema_name)
131
+
132
+ async def aextract(
133
+ self,
134
+ file: str | Path | bytes,
135
+ *,
136
+ response_schema: dict[str, Any],
137
+ instructions: str = "Extract every field from this document.",
138
+ schema_name: str = "Extraction",
139
+ mime: str | None = None,
140
+ filename: str | None = None,
141
+ ) -> ChatResponse:
142
+ messages = _extract_messages(file, instructions, mime=mime, filename=filename)
143
+ return await self.achat(messages, response_schema=response_schema, schema_name=schema_name)
144
+
145
+ def _completion_kwargs(
146
+ self,
147
+ messages: list[dict[str, Any]],
148
+ response_schema: dict[str, Any] | None,
149
+ schema_name: str,
150
+ ) -> dict[str, Any]:
151
+ cfg = self.config
152
+ model = cfg.model or ""
153
+ kw: dict[str, Any] = {
154
+ "model": model,
155
+ "messages": messages,
156
+ "temperature": cfg.temperature,
157
+ "num_retries": cfg.num_retries,
158
+ }
159
+ if cfg.api_key:
160
+ kw["api_key"] = cfg.api_key
161
+ if cfg.api_base:
162
+ kw["api_base"] = cfg.api_base
163
+ lowered = model.lower()
164
+ if lowered.startswith("azure/") and cfg.api_version:
165
+ kw["api_version"] = cfg.api_version
166
+ if lowered.startswith("vertex_ai/") or lowered.startswith("vertex_ai_beta/"):
167
+ if cfg.vertex_project:
168
+ kw["vertex_project"] = cfg.vertex_project
169
+ if cfg.vertex_location:
170
+ kw["vertex_location"] = cfg.vertex_location
171
+ if response_schema is not None:
172
+ kw["response_format"] = json_schema_response_format(schema_name, response_schema)
173
+ return kw
174
+
175
+
176
+ def _normalize_messages(messages: str | list[dict[str, Any]]) -> list[dict[str, Any]]:
177
+ if isinstance(messages, str):
178
+ if not messages.strip():
179
+ raise Fintom8Error("messages must not be empty.")
180
+ return [{"role": "user", "content": messages}]
181
+ if not messages:
182
+ raise Fintom8Error("messages must not be empty.")
183
+ return list(messages)
184
+
185
+
186
+ def _delta_text(chunk: Any) -> str | None:
187
+ choices = getattr(chunk, "choices", None) or []
188
+ if not choices:
189
+ return None
190
+ delta = getattr(choices[0], "delta", None)
191
+ piece = getattr(delta, "content", None) if delta is not None else None
192
+ return piece or None
193
+
194
+
195
+ def _pack(response: Any, model: str, want_json: bool) -> ChatResponse:
196
+ text = ""
197
+ try:
198
+ text = response.choices[0].message.content or ""
199
+ except (AttributeError, IndexError) as exc:
200
+ raise Fintom8Error("Unexpected LLM response shape.") from exc
201
+ usage_obj = getattr(response, "usage", None)
202
+ data = None
203
+ if want_json:
204
+ try:
205
+ parsed = json.loads(text)
206
+ data = parsed if isinstance(parsed, dict) else None
207
+ except json.JSONDecodeError:
208
+ pass
209
+ return ChatResponse(
210
+ text=text,
211
+ data=data,
212
+ model=model,
213
+ usage={
214
+ "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0,
215
+ "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0,
216
+ "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0,
217
+ },
218
+ )
219
+
220
+
221
+ def _extract_messages(
222
+ file: str | Path | bytes,
223
+ instructions: str,
224
+ *,
225
+ mime: str | None,
226
+ filename: str | None,
227
+ ) -> list[dict[str, Any]]:
228
+ raw, inferred_name = _read_file(file)
229
+ if not raw:
230
+ raise Fintom8Error("Empty file.")
231
+ if len(raw) > MAX_FILE_BYTES:
232
+ raise Fintom8Error("File exceeds 20 MB.")
233
+ name = filename or inferred_name or "doc"
234
+ resolved_mime = mime or _guess_mime(name)
235
+ if resolved_mime is None:
236
+ raise Fintom8Error("Could not infer MIME type; pass mime= explicitly.")
237
+ if resolved_mime not in ALLOWED_MIME:
238
+ raise Fintom8Error(f"Unsupported type {resolved_mime!r}.")
239
+ url = f"data:{resolved_mime};base64,{base64.b64encode(raw).decode()}"
240
+ part: dict[str, Any]
241
+ if resolved_mime.startswith("image/"):
242
+ part = {"type": "image_url", "image_url": {"url": url}}
243
+ else:
244
+ part = {
245
+ "type": "file",
246
+ "file": {"file_data": url, "format": resolved_mime, "filename": name},
247
+ }
248
+ return [{"role": "user", "content": [{"type": "text", "text": instructions}, part]}]
249
+
250
+
251
+ def _read_file(file: str | Path | bytes) -> tuple[bytes, str | None]:
252
+ if isinstance(file, bytes):
253
+ return file, None
254
+ path = Path(file)
255
+ return path.read_bytes(), path.name
256
+
257
+
258
+ def _guess_mime(name: str) -> str | None:
259
+ suffix = Path(name).suffix.lower()
260
+ if suffix in _SUFFIX_MIME:
261
+ return _SUFFIX_MIME[suffix]
262
+ guessed, _ = mimetypes.guess_type(name)
263
+ return guessed
@@ -0,0 +1,122 @@
1
+ """LLM settings: constructor kwargs / LLMConfig > environment > defaults."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ DEFAULT_MODEL = "gemini/gemini-3.5-flash"
10
+ DEFAULT_TEMPERATURE = 0.0
11
+ DEFAULT_NUM_RETRIES = 3
12
+ DEFAULT_API_VERSION = "2024-10-21"
13
+ DEFAULT_VERTEX_LOCATION = "eu"
14
+
15
+
16
+ @dataclass
17
+ class LLMConfig:
18
+ """Optional fields are `None` until resolved (unset → fall through to env / defaults)."""
19
+
20
+ model: str | None = None
21
+ temperature: float | None = None
22
+ num_retries: int | None = None
23
+ api_key: str | None = None
24
+ api_base: str | None = None
25
+ api_version: str | None = None
26
+ vertex_project: str | None = None
27
+ vertex_location: str | None = None
28
+
29
+
30
+ def _env(name: str) -> str | None:
31
+ value = os.getenv(name)
32
+ if value is None or value.strip() == "":
33
+ return None
34
+ return value
35
+
36
+
37
+ def env_api_key_for_model(model: str) -> str | None:
38
+ lowered = model.lower()
39
+ if lowered.startswith("gemini/") or lowered.startswith("gemini-"):
40
+ return _env("GEMINI_API_KEY")
41
+ if lowered.startswith("azure/"):
42
+ return _env("AZURE_API_KEY")
43
+ if lowered.startswith("vertex_ai/") or lowered.startswith("vertex_ai_beta/"):
44
+ return None
45
+ return _env("OPENAI_API_KEY")
46
+
47
+
48
+ def env_api_base_for_model(model: str) -> str | None:
49
+ if model.lower().startswith("azure/"):
50
+ return _env("AZURE_API_BASE")
51
+ return _env("OPENAI_API_BASE")
52
+
53
+
54
+ def resolve_config(
55
+ config: LLMConfig | None = None,
56
+ *,
57
+ model: str | None = None,
58
+ temperature: float | None = None,
59
+ num_retries: int | None = None,
60
+ api_key: str | None = None,
61
+ api_base: str | None = None,
62
+ api_version: str | None = None,
63
+ vertex_project: str | None = None,
64
+ vertex_location: str | None = None,
65
+ load_env: bool = True,
66
+ ) -> LLMConfig:
67
+ if load_env:
68
+ load_dotenv()
69
+
70
+ cfg = config or LLMConfig()
71
+
72
+ resolved_model = model or cfg.model or _env("LLM_MODEL") or DEFAULT_MODEL
73
+
74
+ env_temp = _env("LLM_TEMPERATURE")
75
+ resolved_temp = (
76
+ temperature
77
+ if temperature is not None
78
+ else cfg.temperature
79
+ if cfg.temperature is not None
80
+ else float(env_temp)
81
+ if env_temp is not None
82
+ else DEFAULT_TEMPERATURE
83
+ )
84
+
85
+ resolved_retries = (
86
+ num_retries
87
+ if num_retries is not None
88
+ else cfg.num_retries
89
+ if cfg.num_retries is not None
90
+ else DEFAULT_NUM_RETRIES
91
+ )
92
+
93
+ resolved_key = api_key if api_key is not None else cfg.api_key
94
+ if resolved_key is None:
95
+ resolved_key = env_api_key_for_model(resolved_model)
96
+
97
+ resolved_base = api_base if api_base is not None else cfg.api_base
98
+ if resolved_base is None:
99
+ resolved_base = env_api_base_for_model(resolved_model)
100
+
101
+ resolved_version = api_version if api_version is not None else cfg.api_version
102
+ if resolved_version is None:
103
+ resolved_version = _env("AZURE_API_VERSION") or DEFAULT_API_VERSION
104
+
105
+ resolved_project = vertex_project if vertex_project is not None else cfg.vertex_project
106
+ if resolved_project is None:
107
+ resolved_project = _env("VERTEXAI_PROJECT")
108
+
109
+ resolved_location = vertex_location if vertex_location is not None else cfg.vertex_location
110
+ if resolved_location is None:
111
+ resolved_location = _env("VERTEXAI_LOCATION") or DEFAULT_VERTEX_LOCATION
112
+
113
+ return LLMConfig(
114
+ model=resolved_model,
115
+ temperature=resolved_temp,
116
+ num_retries=resolved_retries,
117
+ api_key=resolved_key,
118
+ api_base=resolved_base,
119
+ api_version=resolved_version,
120
+ vertex_project=resolved_project,
121
+ vertex_location=resolved_location,
122
+ )
@@ -0,0 +1,5 @@
1
+ """Public errors for the fintom8 LLM connector."""
2
+
3
+
4
+ class Fintom8Error(Exception):
5
+ """Raised when configuration is invalid or an LLM call fails."""
@@ -0,0 +1,75 @@
1
+ """JSON Schema → OpenAI response_format (Gemini/Vertex/OpenAI/Azure via LiteLLM).
2
+
3
+ enforce_strict: all props required + additionalProperties false; optionals → nullable.
4
+ inline_refs: flatten $ref/$defs (Gemini rejects them).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ from typing import Any
10
+
11
+ _C = ("anyOf", "oneOf", "allOf")
12
+
13
+
14
+ def json_schema_response_format(name: str, schema: dict[str, Any], *, strict: bool = True) -> dict[str, Any]:
15
+ s = enforce_strict(schema) if strict else copy.deepcopy(schema)
16
+ return {"type": "json_schema", "json_schema": {"name": name, "strict": strict, "schema": s}}
17
+
18
+
19
+ def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
20
+ defs = {**(schema.get("$defs") or {}), **(schema.get("definitions") or {})}
21
+
22
+ def go(n: Any, seen: frozenset[str]) -> Any:
23
+ if isinstance(n, list):
24
+ return [go(i, seen) for i in n]
25
+ if not isinstance(n, dict):
26
+ return n
27
+ if "$ref" in n:
28
+ k = n["$ref"].rsplit("/", 1)[-1]
29
+ if k not in defs:
30
+ raise ValueError(f"Cannot resolve $ref {n['$ref']!r}")
31
+ if k in seen:
32
+ raise ValueError(f"Recursive $ref {n['$ref']!r}")
33
+ return {**go(defs[k], seen | {k}), **{a: go(b, seen) for a, b in n.items() if a != "$ref"}}
34
+ return {a: go(b, seen) for a, b in n.items() if a not in ("$defs", "definitions")}
35
+
36
+ return go(schema, frozenset())
37
+
38
+
39
+ def enforce_strict(schema: dict[str, Any]) -> dict[str, Any]:
40
+ def go(n: Any) -> Any:
41
+ if isinstance(n, list):
42
+ return [go(i) for i in n]
43
+ if not isinstance(n, dict):
44
+ return n
45
+ out = {k: go(v) for k, v in n.items()}
46
+ for c in _C:
47
+ if c in out:
48
+ out[c] = [go(i) for i in out[c]]
49
+ props = out.get("properties") or {}
50
+ obj = out.get("type") == "object" or (
51
+ isinstance(out.get("type"), list) and "object" in out["type"]
52
+ ) or ("properties" in out and "type" not in out)
53
+ if props and obj:
54
+ req = set(out.get("required") or [])
55
+ out["properties"] = {k: (v if k in req else _null(v)) for k, v in props.items()}
56
+ out["required"], out["additionalProperties"] = list(props), False
57
+ return out
58
+
59
+ return go(inline_refs(schema))
60
+
61
+
62
+ def _null(s: Any) -> Any:
63
+ if not isinstance(s, dict):
64
+ return s
65
+ n, t = dict(s), s.get("type")
66
+ if isinstance(t, str) and t != "null":
67
+ return {**n, "type": [t, "null"]}
68
+ if isinstance(t, list) and "null" not in t:
69
+ return {**n, "type": [*t, "null"]}
70
+ for c in _C:
71
+ if isinstance(n.get(c), list):
72
+ if not any(isinstance(m, dict) and m.get("type") == "null" for m in n[c]):
73
+ n[c] = [*n[c], {"type": "null"}]
74
+ return n
75
+ return {"anyOf": [n, {"type": "null"}]}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ChatResponse:
9
+ text: str
10
+ data: dict[str, Any] | None
11
+ model: str
12
+ usage: dict[str, int] = field(default_factory=dict)
File without changes
@@ -0,0 +1,213 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+
5
+ from fintom8 import Fintom8Error, LLM
6
+ from fintom8.client import _pack
7
+
8
+
9
+ def _response(text: str, prompt=1, completion=2, total=3):
10
+ return SimpleNamespace(
11
+ choices=[SimpleNamespace(message=SimpleNamespace(content=text))],
12
+ usage=SimpleNamespace(prompt_tokens=prompt, completion_tokens=completion, total_tokens=total),
13
+ )
14
+
15
+
16
+ def _chunk(text: str):
17
+ return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text))])
18
+
19
+
20
+ def test_chat_string_and_pack(monkeypatch):
21
+ captured = {}
22
+
23
+ def fake_completion(**kwargs):
24
+ captured.update(kwargs)
25
+ return _response("hello")
26
+
27
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
28
+ llm = LLM(model="gpt-4o", api_key="sk-test", temperature=0.2, load_env=False)
29
+ resp = llm.chat("Hi there")
30
+ assert resp.text == "hello"
31
+ assert resp.model == "gpt-4o"
32
+ assert resp.usage["total_tokens"] == 3
33
+ assert captured["messages"] == [{"role": "user", "content": "Hi there"}]
34
+ assert captured["api_key"] == "sk-test"
35
+ assert captured["temperature"] == 0.2
36
+ assert "response_format" not in captured
37
+
38
+
39
+ def test_chat_with_schema_parses_json(monkeypatch):
40
+ def fake_completion(**kwargs):
41
+ assert "response_format" in kwargs
42
+ assert kwargs["response_format"]["type"] == "json_schema"
43
+ return _response('{"total": 12.5}')
44
+
45
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
46
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
47
+ resp = llm.chat(
48
+ [{"role": "user", "content": "extract"}],
49
+ response_schema={
50
+ "type": "object",
51
+ "properties": {"total": {"type": "number"}},
52
+ "required": ["total"],
53
+ },
54
+ schema_name="Invoice",
55
+ )
56
+ assert resp.data == {"total": 12.5}
57
+
58
+
59
+ def test_chat_wraps_errors(monkeypatch):
60
+ def fake_completion(**kwargs):
61
+ raise RuntimeError("provider down")
62
+
63
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
64
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
65
+ with pytest.raises(Fintom8Error, match="provider down"):
66
+ llm.chat("hi")
67
+
68
+
69
+ def test_stream_yields_text(monkeypatch):
70
+ def fake_completion(**kwargs):
71
+ assert kwargs["stream"] is True
72
+ return iter([_chunk("Hel"), _chunk("lo"), _chunk("")])
73
+
74
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
75
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
76
+ assert "".join(llm.stream("x")) == "Hello"
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_achat_and_astream(monkeypatch):
81
+ async def fake_acompletion(**kwargs):
82
+ if kwargs.get("stream"):
83
+ async def gen():
84
+ yield _chunk("A")
85
+ yield _chunk("B")
86
+
87
+ return gen()
88
+ return _response("ok")
89
+
90
+ monkeypatch.setattr("fintom8.client.litellm.acompletion", fake_acompletion)
91
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
92
+ assert (await llm.achat("q")).text == "ok"
93
+ chunks = []
94
+ async for piece in llm.astream("q"):
95
+ chunks.append(piece)
96
+ assert chunks == ["A", "B"]
97
+
98
+
99
+ def test_extract_pdf_message_shape(monkeypatch, tmp_path):
100
+ pdf = tmp_path / "invoice.pdf"
101
+ pdf.write_bytes(b"%PDF-1.4 fake")
102
+ captured = {}
103
+
104
+ def fake_completion(**kwargs):
105
+ captured.update(kwargs)
106
+ return _response('{"title": "Inv"}')
107
+
108
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
109
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
110
+ resp = llm.extract(
111
+ pdf,
112
+ response_schema={"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]},
113
+ )
114
+ assert resp.data == {"title": "Inv"}
115
+ content = captured["messages"][0]["content"]
116
+ assert content[0]["type"] == "text"
117
+ assert content[1]["type"] == "file"
118
+ assert content[1]["file"]["format"] == "application/pdf"
119
+ assert content[1]["file"]["filename"] == "invoice.pdf"
120
+ assert "response_format" in captured
121
+
122
+
123
+ def test_extract_image_uses_image_url(monkeypatch):
124
+ captured = {}
125
+
126
+ def fake_completion(**kwargs):
127
+ captured.update(kwargs)
128
+ return _response("{}")
129
+
130
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
131
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
132
+ llm.extract(
133
+ b"\x89PNG",
134
+ mime="image/png",
135
+ filename="shot.png",
136
+ response_schema={"type": "object", "properties": {}},
137
+ )
138
+ part = captured["messages"][0]["content"][1]
139
+ assert part["type"] == "image_url"
140
+ assert part["image_url"]["url"].startswith("data:image/png;base64,")
141
+
142
+
143
+ def test_extract_rejects_empty_and_bad_mime():
144
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
145
+ schema = {"type": "object", "properties": {}}
146
+ with pytest.raises(Fintom8Error, match="Empty file"):
147
+ llm.extract(b"", mime="application/pdf", response_schema=schema)
148
+ with pytest.raises(Fintom8Error, match="Unsupported"):
149
+ llm.extract(b"xx", mime="text/plain", response_schema=schema)
150
+ with pytest.raises(Fintom8Error, match="infer MIME"):
151
+ llm.extract(b"xx", response_schema=schema)
152
+
153
+
154
+ def test_vertex_kwargs_passed(monkeypatch):
155
+ captured = {}
156
+
157
+ def fake_completion(**kwargs):
158
+ captured.update(kwargs)
159
+ return _response("v")
160
+
161
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
162
+ llm = LLM(
163
+ model="vertex_ai/gemini-3.5-flash",
164
+ vertex_project="my-proj",
165
+ vertex_location="us-central1",
166
+ load_env=False,
167
+ )
168
+ llm.chat("hi")
169
+ assert captured["vertex_project"] == "my-proj"
170
+ assert captured["vertex_location"] == "us-central1"
171
+ assert "api_key" not in captured
172
+
173
+
174
+ def test_azure_kwargs_passed(monkeypatch):
175
+ captured = {}
176
+
177
+ def fake_completion(**kwargs):
178
+ captured.update(kwargs)
179
+ return _response("a")
180
+
181
+ monkeypatch.setattr("fintom8.client.litellm.completion", fake_completion)
182
+ llm = LLM(
183
+ model="azure/my-deploy",
184
+ api_key="az",
185
+ api_base="https://res.openai.azure.com",
186
+ api_version="2024-10-21",
187
+ load_env=False,
188
+ )
189
+ llm.chat("hi")
190
+ assert captured["api_key"] == "az"
191
+ assert captured["api_base"] == "https://res.openai.azure.com"
192
+ assert captured["api_version"] == "2024-10-21"
193
+
194
+
195
+ def test_pack_handles_invalid_json():
196
+ resp = _pack(_response("not-json"), "gpt-4o", True)
197
+ assert resp.data is None
198
+ assert resp.text == "not-json"
199
+
200
+
201
+ def test_empty_messages_raise():
202
+ llm = LLM(model="gpt-4o", api_key="sk-test", load_env=False)
203
+ with pytest.raises(Fintom8Error, match="empty"):
204
+ llm.chat(" ")
205
+ with pytest.raises(Fintom8Error, match="empty"):
206
+ llm.chat([])
207
+
208
+
209
+ def test_drop_params_enabled():
210
+ import litellm
211
+
212
+ LLM(model="gpt-4o", api_key="sk-test", load_env=False)
213
+ assert litellm.drop_params is True
@@ -0,0 +1,64 @@
1
+ from fintom8.config import LLMConfig, resolve_config
2
+
3
+
4
+ def test_defaults_when_env_empty(monkeypatch):
5
+ monkeypatch.delenv("LLM_MODEL", raising=False)
6
+ monkeypatch.delenv("LLM_TEMPERATURE", raising=False)
7
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
8
+ cfg = resolve_config(load_env=False)
9
+ assert cfg.model == "gemini/gemini-3.5-flash"
10
+ assert cfg.temperature == 0.0
11
+ assert cfg.num_retries == 3
12
+ assert cfg.api_key is None
13
+ assert cfg.vertex_location == "eu"
14
+ assert cfg.api_version == "2024-10-21"
15
+
16
+
17
+ def test_env_overrides_defaults(monkeypatch):
18
+ monkeypatch.setenv("LLM_MODEL", "gpt-4o")
19
+ monkeypatch.setenv("LLM_TEMPERATURE", "0.4")
20
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
21
+ monkeypatch.setenv("OPENAI_API_BASE", "https://example.test/v1")
22
+ cfg = resolve_config(load_env=False)
23
+ assert cfg.model == "gpt-4o"
24
+ assert cfg.temperature == 0.4
25
+ assert cfg.api_key == "sk-env"
26
+ assert cfg.api_base == "https://example.test/v1"
27
+
28
+
29
+ def test_kwargs_override_env_and_config(monkeypatch):
30
+ monkeypatch.setenv("LLM_MODEL", "gpt-4o")
31
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
32
+ cfg = resolve_config(
33
+ LLMConfig(model="azure/deploy", api_key="sk-cfg"),
34
+ model="gemini/gemini-3.5-flash",
35
+ api_key="sk-kw",
36
+ load_env=False,
37
+ )
38
+ assert cfg.model == "gemini/gemini-3.5-flash"
39
+ assert cfg.api_key == "sk-kw"
40
+
41
+
42
+ def test_config_overrides_env(monkeypatch):
43
+ monkeypatch.setenv("LLM_MODEL", "gpt-4o")
44
+ monkeypatch.setenv("LLM_TEMPERATURE", "0.9")
45
+ cfg = resolve_config(LLMConfig(model="azure/x", temperature=0.1), load_env=False)
46
+ assert cfg.model == "azure/x"
47
+ assert cfg.temperature == 0.1
48
+
49
+
50
+ def test_model_prefix_selects_provider_key(monkeypatch):
51
+ monkeypatch.setenv("GEMINI_API_KEY", "g-key")
52
+ monkeypatch.setenv("OPENAI_API_KEY", "o-key")
53
+ monkeypatch.setenv("AZURE_API_KEY", "a-key")
54
+ monkeypatch.setenv("AZURE_API_BASE", "https://res.openai.azure.com")
55
+ gemini = resolve_config(model="gemini/gemini-3.5-flash", load_env=False)
56
+ openai = resolve_config(model="gpt-4o", load_env=False)
57
+ azure = resolve_config(model="azure/my-deploy", load_env=False)
58
+ vertex = resolve_config(model="vertex_ai/gemini-3.5-flash", vertex_project="proj", load_env=False)
59
+ assert gemini.api_key == "g-key"
60
+ assert openai.api_key == "o-key"
61
+ assert azure.api_key == "a-key"
62
+ assert azure.api_base == "https://res.openai.azure.com"
63
+ assert vertex.api_key is None
64
+ assert vertex.vertex_project == "proj"
@@ -0,0 +1,48 @@
1
+ from fintom8.schema import enforce_strict, inline_refs, json_schema_response_format
2
+
3
+
4
+ def test_inline_refs_flattens_defs():
5
+ schema = {
6
+ "type": "object",
7
+ "properties": {"item": {"$ref": "#/$defs/Item"}},
8
+ "$defs": {
9
+ "Item": {
10
+ "type": "object",
11
+ "properties": {"name": {"type": "string"}},
12
+ "required": ["name"],
13
+ }
14
+ },
15
+ }
16
+ out = inline_refs(schema)
17
+ assert "$defs" not in out
18
+ assert "$ref" not in out["properties"]["item"]
19
+ assert out["properties"]["item"]["properties"]["name"]["type"] == "string"
20
+
21
+
22
+ def test_enforce_strict_requires_all_props_and_nulls_optionals():
23
+ schema = {
24
+ "type": "object",
25
+ "properties": {
26
+ "total": {"type": "number"},
27
+ "note": {"type": "string"},
28
+ },
29
+ "required": ["total"],
30
+ }
31
+ out = enforce_strict(schema)
32
+ assert out["required"] == ["total", "note"]
33
+ assert out["additionalProperties"] is False
34
+ assert out["properties"]["total"]["type"] == "number"
35
+ assert out["properties"]["note"]["type"] == ["string", "null"]
36
+
37
+
38
+ def test_json_schema_response_format_wraps_strict_schema():
39
+ schema = {
40
+ "type": "object",
41
+ "properties": {"ok": {"type": "boolean"}},
42
+ "required": ["ok"],
43
+ }
44
+ fmt = json_schema_response_format("Flag", schema)
45
+ assert fmt["type"] == "json_schema"
46
+ assert fmt["json_schema"]["name"] == "Flag"
47
+ assert fmt["json_schema"]["strict"] is True
48
+ assert fmt["json_schema"]["schema"]["additionalProperties"] is False