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.
- fintom8-0.1.0/.env.example +25 -0
- fintom8-0.1.0/.gitignore +8 -0
- fintom8-0.1.0/PKG-INFO +151 -0
- fintom8-0.1.0/README.md +124 -0
- fintom8-0.1.0/examples/chat.py +7 -0
- fintom8-0.1.0/examples/extract.py +20 -0
- fintom8-0.1.0/pyproject.toml +60 -0
- fintom8-0.1.0/src/fintom8/__init__.py +18 -0
- fintom8-0.1.0/src/fintom8/client.py +263 -0
- fintom8-0.1.0/src/fintom8/config.py +122 -0
- fintom8-0.1.0/src/fintom8/exceptions.py +5 -0
- fintom8-0.1.0/src/fintom8/schema.py +75 -0
- fintom8-0.1.0/src/fintom8/types.py +12 -0
- fintom8-0.1.0/tests/__init__.py +0 -0
- fintom8-0.1.0/tests/test_client.py +213 -0
- fintom8-0.1.0/tests/test_config.py +64 -0
- fintom8-0.1.0/tests/test_schema.py +48 -0
|
@@ -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
|
fintom8-0.1.0/.gitignore
ADDED
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
|
+
```
|
fintom8-0.1.0/README.md
ADDED
|
@@ -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,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,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"}]}
|
|
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
|