polza-extras 0.2.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.
- polza_extras-0.2.0/.gitignore +9 -0
- polza_extras-0.2.0/CHANGELOG.md +24 -0
- polza_extras-0.2.0/LICENSE +21 -0
- polza_extras-0.2.0/PKG-INFO +189 -0
- polza_extras-0.2.0/README.md +163 -0
- polza_extras-0.2.0/pyproject.toml +80 -0
- polza_extras-0.2.0/src/polza_extras/__init__.py +57 -0
- polza_extras-0.2.0/src/polza_extras/_json.py +13 -0
- polza_extras-0.2.0/src/polza_extras/_models.py +166 -0
- polza_extras-0.2.0/src/polza_extras/chat.py +120 -0
- polza_extras-0.2.0/src/polza_extras/client.py +452 -0
- polza_extras-0.2.0/src/polza_extras/errors.py +161 -0
- polza_extras-0.2.0/src/polza_extras/py.typed +0 -0
- polza_extras-0.2.0/src/polza_extras/retry.py +51 -0
- polza_extras-0.2.0/tests/__init__.py +0 -0
- polza_extras-0.2.0/tests/conftest.py +63 -0
- polza_extras-0.2.0/tests/fake_server.py +128 -0
- polza_extras-0.2.0/tests/test_account.py +147 -0
- polza_extras-0.2.0/tests/test_async.py +149 -0
- polza_extras-0.2.0/tests/test_chat.py +294 -0
- polza_extras-0.2.0/tests/test_errors.py +107 -0
- polza_extras-0.2.0/tests/test_live.py +170 -0
- polza_extras-0.2.0/tests/test_retry.py +64 -0
- polza_extras-0.2.0/tests/test_stream.py +161 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
First release on PyPI: `uv add polza-extras`.
|
|
6
|
+
|
|
7
|
+
- Tool calls: `ChatResult.tool_calls` (`ToolCall` with `id`, `name`, `arguments` and `parsed_arguments()`). A reply with tool calls and no text no longer raises `EmptyResponseError`.
|
|
8
|
+
- Streaming tool calls: `StreamEvent.tool_calls` passes `ToolCallDelta` pieces (`index`, `id`, `name`, `arguments` fragment) through as they arrive.
|
|
9
|
+
- Usage details: `Usage.cached_tokens`, `Usage.cache_write_tokens`, `Usage.reasoning_tokens` (0 when Polza does not send them), in `chat()` and in the last stream event.
|
|
10
|
+
- Structured output: `chat(..., json_schema=..., schema_name="response")` and the same in `chat_stream()`; takes a JSON Schema dict or a pydantic model class and sends a strict `json_schema` response format. Combining it with `json=True` or `response_format` raises `ValueError`.
|
|
11
|
+
- CI runs on Python 3.11–3.14, plus 3.15 pre-releases as experimental.
|
|
12
|
+
- Release workflow: tags `v*` are built with `uv build` and published to PyPI with Trusted Publishing.
|
|
13
|
+
|
|
14
|
+
## 0.1.0 — 2026-10-24
|
|
15
|
+
|
|
16
|
+
First release. Not on PyPI yet; install from GitHub.
|
|
17
|
+
|
|
18
|
+
- `Polza` / `AsyncPolza` on top of the `openai` SDK, with `close()` and context managers.
|
|
19
|
+
- `chat()`: ruble cost (`cost_rub` as `Decimal`), reasoning text, JSON mode, Polza retry rules.
|
|
20
|
+
- `chat_stream()`: text and reasoning deltas, final usage and cost, `StreamError` for in-stream errors; abandoned streams are closed.
|
|
21
|
+
- Typed errors from Polza's error body (`AuthError`, `InsufficientFundsError`, `RateLimitError`, `NotFoundError`, …) with `trace_id` and the provider's real reason.
|
|
22
|
+
- `EmptyResponseError` when thinking used up `max_tokens`; it keeps usage, the raw response and the billed cost.
|
|
23
|
+
- Catalog: `models()`, `model()`, ruble pricing as `Decimal`, `Pricing.estimate`.
|
|
24
|
+
- Account: `balance()`, `key_info()`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 polza-extras contributors
|
|
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,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: polza-extras
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Polza.ai helpers on top of the OpenAI SDK: ruble cost, typed errors, retry rules, model catalog, balance
|
|
5
|
+
Project-URL: Homepage, https://github.com/still-coding/polza-extras
|
|
6
|
+
Project-URL: Issues, https://github.com/still-coding/polza-extras/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/still-coding/polza-extras/blob/main/CHANGELOG.md
|
|
8
|
+
Author: still-coding
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: llm,openai,polza,polza.ai,rubles,russia
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: openai>=1.50
|
|
24
|
+
Requires-Dist: pydantic>=2.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# polza-extras
|
|
28
|
+
|
|
29
|
+
Small helpers for [Polza.ai](https://polza.ai) on top of the official `openai` SDK.
|
|
30
|
+
|
|
31
|
+
Polza is OpenAI-compatible, so for chat you can use the `openai` SDK directly. This library covers what the SDK doesn't know about:
|
|
32
|
+
|
|
33
|
+
- **Ruble cost** of each call (`usage.cost_rub`) as `Decimal`.
|
|
34
|
+
- **Typed errors** parsed from Polza's error body, with `trace_id` and the provider's real reason (`metadata.raw`), which is often hidden behind a generic «Некорректный запрос».
|
|
35
|
+
- **Polza's retry rules** (which statuses to retry, `Retry-After`, own vs provider rate limits).
|
|
36
|
+
- **Reasoning models**: a clear `EmptyResponseError` when thinking used up `max_tokens`, instead of an empty string.
|
|
37
|
+
- **Tool calls, usage details, structured output**: parsed `tool_calls`, cached/reasoning token counts, `json_schema=` with a dict or a pydantic model.
|
|
38
|
+
- **Catalog, balance and key info**: `models()`, `model()`, `balance()`, `key_info()`, with ruble prices as `Decimal` and a cost estimate.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add polza-extras # or: pip install polza-extras
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Python 3.11+. Dependencies: `openai`, `pydantic`.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from polza_extras import Polza
|
|
52
|
+
|
|
53
|
+
polza = Polza() # reads POLZA_AI_API_KEY; base URL https://polza.ai/api/v1
|
|
54
|
+
|
|
55
|
+
result = polza.chat(
|
|
56
|
+
"anthropic/claude-haiku-5.5", # always provider/model; a bare name is a 404
|
|
57
|
+
[{"role": "user", "content": "Привет!"}],
|
|
58
|
+
max_tokens=200,
|
|
59
|
+
reasoning={"type": "disabled"}, # any extra Polza field goes into the request body
|
|
60
|
+
)
|
|
61
|
+
print(result.text, result.cost_rub) # 'Привет! …' Decimal('0.0123')
|
|
62
|
+
|
|
63
|
+
data = polza.chat(model, messages, json=True) # response_format={"type": "json_object"}
|
|
64
|
+
|
|
65
|
+
polza.openai # the configured openai.OpenAI client, for anything else
|
|
66
|
+
polza.close() # or: with Polza() as polza: ...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Tool calls
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
result = polza.chat(model, messages, tools=[weather_tool]) # tools pass through as is
|
|
73
|
+
for call in result.tool_calls: # [] for a plain text reply
|
|
74
|
+
call.id, call.name, call.arguments # arguments: the JSON string from the model
|
|
75
|
+
args = call.parsed_arguments() # dict; ValueError if not a JSON object
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A reply with tool calls usually has no text: `result.text == ""` and `finish_reason == "tool_calls"`. `EmptyResponseError` is raised only when there is neither text nor tool calls.
|
|
79
|
+
|
|
80
|
+
In a stream, `event.tool_calls` holds the raw `ToolCallDelta` pieces: the first piece of a call has `index`, `id` and `name`; join the `arguments` fragments of one `index` to get the full JSON.
|
|
81
|
+
|
|
82
|
+
### Usage details
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
u = result.usage
|
|
86
|
+
u.prompt_tokens, u.completion_tokens, u.total_tokens
|
|
87
|
+
u.cached_tokens # prompt tokens read from the prompt cache
|
|
88
|
+
u.cache_write_tokens # prompt tokens written to the prompt cache
|
|
89
|
+
u.reasoning_tokens # completion tokens spent on thinking
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
They are 0 when Polza does not report them. The last stream event carries the same `usage`.
|
|
93
|
+
|
|
94
|
+
### Structured output
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pydantic import BaseModel
|
|
98
|
+
|
|
99
|
+
class City(BaseModel):
|
|
100
|
+
name: str
|
|
101
|
+
population_millions: float
|
|
102
|
+
|
|
103
|
+
result = polza.chat(model, messages, json_schema=City, schema_name="city")
|
|
104
|
+
city = City.model_validate_json(result.text)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`json_schema` takes a pydantic model class or a JSON Schema dict and sends `response_format={"type": "json_schema", "json_schema": {"name": schema_name, "schema": ..., "strict": True}}`. Not every provider supports it: check `json_schema_strict` in the model's provider capabilities. Strict mode on some providers also wants `"additionalProperties": false` and every property in `required`; pass a dict when you need full control. Use either `json=True` (any JSON object) or `json_schema`, not both.
|
|
108
|
+
|
|
109
|
+
### Streaming
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
for event in polza.chat_stream("anthropic/claude-haiku-5.5", messages, max_tokens=200):
|
|
113
|
+
print(event.delta, end="", flush=True) # event.reasoning holds thinking deltas, if any
|
|
114
|
+
print(event.cost_rub) # the last event carries usage and cost
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Errors before the first chunk are retried like `chat()`. An error event inside the stream raises `StreamError`; text already yielded stays yielded, and the request is not retried. Stopping early (`break`) closes the connection.
|
|
118
|
+
|
|
119
|
+
### Async
|
|
120
|
+
|
|
121
|
+
`AsyncPolza` has the same methods, awaited (`async for` for `chat_stream`):
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from polza_extras import AsyncPolza
|
|
125
|
+
|
|
126
|
+
polza = AsyncPolza()
|
|
127
|
+
result = await polza.chat("anthropic/claude-haiku-5.5", messages)
|
|
128
|
+
async for event in polza.chat_stream("anthropic/claude-haiku-5.5", messages):
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Catalog, balance, key
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
page = polza.models(search="claude", type="chat", limit=10) # one catalog page
|
|
136
|
+
for m in page.items:
|
|
137
|
+
print(m.id, m.pricing.prompt_per_million, m.reasoning.mandatory)
|
|
138
|
+
|
|
139
|
+
sonnet = polza.model("anthropic/claude-sonnet-5.5") # NotFoundError for unknown ids
|
|
140
|
+
sonnet.pricing.estimate(prompt_tokens=1500, completion_tokens=80) # Decimal rubles
|
|
141
|
+
|
|
142
|
+
polza.balance() # Balance(amount, available, reserved, spent, updated_at)
|
|
143
|
+
polza.key_info() # KeyInfo(label, limit, limit_remaining, limit_reset, usage, usage_daily, ...)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Errors
|
|
147
|
+
|
|
148
|
+
All errors subclass `PolzaError` (`status`, `code`, `message`, `trace_id`, `metadata`, `details`, `retry_after`).
|
|
149
|
+
|
|
150
|
+
| Status | Class | Retried by default |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| 401 | `AuthError` | no |
|
|
153
|
+
| 402 | `InsufficientFundsError` | no (opt-in `retry_402` for parallel-load reserve conflicts) |
|
|
154
|
+
| 403 | `ForbiddenError` | no |
|
|
155
|
+
| 404 | `NotFoundError` | no |
|
|
156
|
+
| 400, 413, other 4xx | `RequestError` | no |
|
|
157
|
+
| 429 with `Retry-After` | `RateLimitError(scope="own")` | yes, waits `Retry-After` (fails fast if above `max_wait`) |
|
|
158
|
+
| 429 without `Retry-After` | `RateLimitError(scope="provider")` | yes, backoff 1, 2, 4, 8 s |
|
|
159
|
+
| 408, 502 | `ServerError` | yes, backoff |
|
|
160
|
+
| 503 | `ServerError` | yes, honors `Retry-After` |
|
|
161
|
+
| connection failure | `PolzaConnectionError` | yes, backoff |
|
|
162
|
+
| 200 with no text and no tool calls | `EmptyResponseError` | no; carries `finish_reason`, `reasoning`, `usage`, `cost_rub` (still billed), `raw` |
|
|
163
|
+
| error event inside a stream | `StreamError` | no |
|
|
164
|
+
|
|
165
|
+
Polza doesn't charge for failed requests, so retrying is safe. Tune with `Polza(retry=RetryPolicy(max_retries=..., base_delay=..., max_wait=...))`.
|
|
166
|
+
|
|
167
|
+
### Reasoning models
|
|
168
|
+
|
|
169
|
+
Thinking tokens count against `max_tokens`. If the model spends them all on thinking, `content` comes back empty and `chat()` raises `EmptyResponseError` with `finish_reason="length"`. Fixes:
|
|
170
|
+
|
|
171
|
+
- turn thinking off: `reasoning={"type": "disabled"}` (where allowed),
|
|
172
|
+
- lower it: `reasoning={"effort": "low"}`,
|
|
173
|
+
- raise `max_tokens`.
|
|
174
|
+
|
|
175
|
+
Some models (e.g. `anthropic/claude-sonnet-5.5`) make thinking mandatory; `model(id).reasoning.mandatory` tells you.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv sync
|
|
181
|
+
uv run pytest # offline, against an in-process fake server; fails below 95% coverage
|
|
182
|
+
POLZA_AI_API_KEY=... uv run pytest -m live --no-cov # real API smoke tests, costs ~0.1 ₽
|
|
183
|
+
uv run ruff check src tests && uv run ruff format --check src tests && uv run pyright
|
|
184
|
+
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit # all of the above before each commit
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# polza-extras
|
|
2
|
+
|
|
3
|
+
Small helpers for [Polza.ai](https://polza.ai) on top of the official `openai` SDK.
|
|
4
|
+
|
|
5
|
+
Polza is OpenAI-compatible, so for chat you can use the `openai` SDK directly. This library covers what the SDK doesn't know about:
|
|
6
|
+
|
|
7
|
+
- **Ruble cost** of each call (`usage.cost_rub`) as `Decimal`.
|
|
8
|
+
- **Typed errors** parsed from Polza's error body, with `trace_id` and the provider's real reason (`metadata.raw`), which is often hidden behind a generic «Некорректный запрос».
|
|
9
|
+
- **Polza's retry rules** (which statuses to retry, `Retry-After`, own vs provider rate limits).
|
|
10
|
+
- **Reasoning models**: a clear `EmptyResponseError` when thinking used up `max_tokens`, instead of an empty string.
|
|
11
|
+
- **Tool calls, usage details, structured output**: parsed `tool_calls`, cached/reasoning token counts, `json_schema=` with a dict or a pydantic model.
|
|
12
|
+
- **Catalog, balance and key info**: `models()`, `model()`, `balance()`, `key_info()`, with ruble prices as `Decimal` and a cost estimate.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv add polza-extras # or: pip install polza-extras
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Python 3.11+. Dependencies: `openai`, `pydantic`.
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from polza_extras import Polza
|
|
26
|
+
|
|
27
|
+
polza = Polza() # reads POLZA_AI_API_KEY; base URL https://polza.ai/api/v1
|
|
28
|
+
|
|
29
|
+
result = polza.chat(
|
|
30
|
+
"anthropic/claude-haiku-5.5", # always provider/model; a bare name is a 404
|
|
31
|
+
[{"role": "user", "content": "Привет!"}],
|
|
32
|
+
max_tokens=200,
|
|
33
|
+
reasoning={"type": "disabled"}, # any extra Polza field goes into the request body
|
|
34
|
+
)
|
|
35
|
+
print(result.text, result.cost_rub) # 'Привет! …' Decimal('0.0123')
|
|
36
|
+
|
|
37
|
+
data = polza.chat(model, messages, json=True) # response_format={"type": "json_object"}
|
|
38
|
+
|
|
39
|
+
polza.openai # the configured openai.OpenAI client, for anything else
|
|
40
|
+
polza.close() # or: with Polza() as polza: ...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Tool calls
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
result = polza.chat(model, messages, tools=[weather_tool]) # tools pass through as is
|
|
47
|
+
for call in result.tool_calls: # [] for a plain text reply
|
|
48
|
+
call.id, call.name, call.arguments # arguments: the JSON string from the model
|
|
49
|
+
args = call.parsed_arguments() # dict; ValueError if not a JSON object
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A reply with tool calls usually has no text: `result.text == ""` and `finish_reason == "tool_calls"`. `EmptyResponseError` is raised only when there is neither text nor tool calls.
|
|
53
|
+
|
|
54
|
+
In a stream, `event.tool_calls` holds the raw `ToolCallDelta` pieces: the first piece of a call has `index`, `id` and `name`; join the `arguments` fragments of one `index` to get the full JSON.
|
|
55
|
+
|
|
56
|
+
### Usage details
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
u = result.usage
|
|
60
|
+
u.prompt_tokens, u.completion_tokens, u.total_tokens
|
|
61
|
+
u.cached_tokens # prompt tokens read from the prompt cache
|
|
62
|
+
u.cache_write_tokens # prompt tokens written to the prompt cache
|
|
63
|
+
u.reasoning_tokens # completion tokens spent on thinking
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
They are 0 when Polza does not report them. The last stream event carries the same `usage`.
|
|
67
|
+
|
|
68
|
+
### Structured output
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pydantic import BaseModel
|
|
72
|
+
|
|
73
|
+
class City(BaseModel):
|
|
74
|
+
name: str
|
|
75
|
+
population_millions: float
|
|
76
|
+
|
|
77
|
+
result = polza.chat(model, messages, json_schema=City, schema_name="city")
|
|
78
|
+
city = City.model_validate_json(result.text)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`json_schema` takes a pydantic model class or a JSON Schema dict and sends `response_format={"type": "json_schema", "json_schema": {"name": schema_name, "schema": ..., "strict": True}}`. Not every provider supports it: check `json_schema_strict` in the model's provider capabilities. Strict mode on some providers also wants `"additionalProperties": false` and every property in `required`; pass a dict when you need full control. Use either `json=True` (any JSON object) or `json_schema`, not both.
|
|
82
|
+
|
|
83
|
+
### Streaming
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
for event in polza.chat_stream("anthropic/claude-haiku-5.5", messages, max_tokens=200):
|
|
87
|
+
print(event.delta, end="", flush=True) # event.reasoning holds thinking deltas, if any
|
|
88
|
+
print(event.cost_rub) # the last event carries usage and cost
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Errors before the first chunk are retried like `chat()`. An error event inside the stream raises `StreamError`; text already yielded stays yielded, and the request is not retried. Stopping early (`break`) closes the connection.
|
|
92
|
+
|
|
93
|
+
### Async
|
|
94
|
+
|
|
95
|
+
`AsyncPolza` has the same methods, awaited (`async for` for `chat_stream`):
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from polza_extras import AsyncPolza
|
|
99
|
+
|
|
100
|
+
polza = AsyncPolza()
|
|
101
|
+
result = await polza.chat("anthropic/claude-haiku-5.5", messages)
|
|
102
|
+
async for event in polza.chat_stream("anthropic/claude-haiku-5.5", messages):
|
|
103
|
+
...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Catalog, balance, key
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
page = polza.models(search="claude", type="chat", limit=10) # one catalog page
|
|
110
|
+
for m in page.items:
|
|
111
|
+
print(m.id, m.pricing.prompt_per_million, m.reasoning.mandatory)
|
|
112
|
+
|
|
113
|
+
sonnet = polza.model("anthropic/claude-sonnet-5.5") # NotFoundError for unknown ids
|
|
114
|
+
sonnet.pricing.estimate(prompt_tokens=1500, completion_tokens=80) # Decimal rubles
|
|
115
|
+
|
|
116
|
+
polza.balance() # Balance(amount, available, reserved, spent, updated_at)
|
|
117
|
+
polza.key_info() # KeyInfo(label, limit, limit_remaining, limit_reset, usage, usage_daily, ...)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
All errors subclass `PolzaError` (`status`, `code`, `message`, `trace_id`, `metadata`, `details`, `retry_after`).
|
|
123
|
+
|
|
124
|
+
| Status | Class | Retried by default |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| 401 | `AuthError` | no |
|
|
127
|
+
| 402 | `InsufficientFundsError` | no (opt-in `retry_402` for parallel-load reserve conflicts) |
|
|
128
|
+
| 403 | `ForbiddenError` | no |
|
|
129
|
+
| 404 | `NotFoundError` | no |
|
|
130
|
+
| 400, 413, other 4xx | `RequestError` | no |
|
|
131
|
+
| 429 with `Retry-After` | `RateLimitError(scope="own")` | yes, waits `Retry-After` (fails fast if above `max_wait`) |
|
|
132
|
+
| 429 without `Retry-After` | `RateLimitError(scope="provider")` | yes, backoff 1, 2, 4, 8 s |
|
|
133
|
+
| 408, 502 | `ServerError` | yes, backoff |
|
|
134
|
+
| 503 | `ServerError` | yes, honors `Retry-After` |
|
|
135
|
+
| connection failure | `PolzaConnectionError` | yes, backoff |
|
|
136
|
+
| 200 with no text and no tool calls | `EmptyResponseError` | no; carries `finish_reason`, `reasoning`, `usage`, `cost_rub` (still billed), `raw` |
|
|
137
|
+
| error event inside a stream | `StreamError` | no |
|
|
138
|
+
|
|
139
|
+
Polza doesn't charge for failed requests, so retrying is safe. Tune with `Polza(retry=RetryPolicy(max_retries=..., base_delay=..., max_wait=...))`.
|
|
140
|
+
|
|
141
|
+
### Reasoning models
|
|
142
|
+
|
|
143
|
+
Thinking tokens count against `max_tokens`. If the model spends them all on thinking, `content` comes back empty and `chat()` raises `EmptyResponseError` with `finish_reason="length"`. Fixes:
|
|
144
|
+
|
|
145
|
+
- turn thinking off: `reasoning={"type": "disabled"}` (where allowed),
|
|
146
|
+
- lower it: `reasoning={"effort": "low"}`,
|
|
147
|
+
- raise `max_tokens`.
|
|
148
|
+
|
|
149
|
+
Some models (e.g. `anthropic/claude-sonnet-5.5`) make thinking mandatory; `model(id).reasoning.mandatory` tells you.
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv sync
|
|
155
|
+
uv run pytest # offline, against an in-process fake server; fails below 95% coverage
|
|
156
|
+
POLZA_AI_API_KEY=... uv run pytest -m live --no-cov # real API smoke tests, costs ~0.1 ₽
|
|
157
|
+
uv run ruff check src tests && uv run ruff format --check src tests && uv run pyright
|
|
158
|
+
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit # all of the above before each commit
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "polza-extras"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Polza.ai helpers on top of the OpenAI SDK: ruble cost, typed errors, retry rules, model catalog, balance"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "still-coding" }]
|
|
9
|
+
keywords = ["polza", "polza.ai", "openai", "llm", "russia", "rubles"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"openai>=1.50",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
"pydantic>=2.7",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-asyncio>=0.23",
|
|
31
|
+
"ruff>=0.6",
|
|
32
|
+
"pytest-cov>=7.1.0",
|
|
33
|
+
"pyright>=1.1.414",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/still-coding/polza-extras"
|
|
42
|
+
Issues = "https://github.com/still-coding/polza-extras/issues"
|
|
43
|
+
Changelog = "https://github.com/still-coding/polza-extras/blob/main/CHANGELOG.md"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/polza_extras"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
markers = ["live: hits the real Polza API (needs POLZA_AI_API_KEY)"]
|
|
55
|
+
# Coverage is gated: plain `uv run pytest` (and CI) fails below fail_under.
|
|
56
|
+
# Live smoke tests cover little on purpose; run them with `--no-cov`.
|
|
57
|
+
addopts = "-m 'not live' --cov=polza_extras --cov-branch --cov-report=term-missing:skip-covered"
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
fail_under = 95
|
|
61
|
+
|
|
62
|
+
# ruff replaces flake8 (E, W, F + bugbear B), isort (I) and black (`ruff format`).
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
target-version = "py311"
|
|
66
|
+
src = ["src", "tests"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.format]
|
|
69
|
+
exclude = ["*.md"] # README examples keep their aligned comments
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["E", "W", "F", "I", "B", "UP", "C4", "SIM"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.isort]
|
|
75
|
+
known-first-party = ["polza_extras", "tests"]
|
|
76
|
+
|
|
77
|
+
[tool.pyright]
|
|
78
|
+
include = ["src"]
|
|
79
|
+
pythonVersion = "3.11"
|
|
80
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from ._models import (
|
|
2
|
+
Balance,
|
|
3
|
+
ChatResult,
|
|
4
|
+
KeyInfo,
|
|
5
|
+
ModelInfo,
|
|
6
|
+
ModelPage,
|
|
7
|
+
Pricing,
|
|
8
|
+
ReasoningInfo,
|
|
9
|
+
StreamEvent,
|
|
10
|
+
ToolCall,
|
|
11
|
+
ToolCallDelta,
|
|
12
|
+
Usage,
|
|
13
|
+
)
|
|
14
|
+
from .client import DEFAULT_BASE_URL, AsyncPolza, Polza
|
|
15
|
+
from .errors import (
|
|
16
|
+
AuthError,
|
|
17
|
+
EmptyResponseError,
|
|
18
|
+
ForbiddenError,
|
|
19
|
+
InsufficientFundsError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PolzaConnectionError,
|
|
22
|
+
PolzaError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
RequestError,
|
|
25
|
+
ServerError,
|
|
26
|
+
StreamError,
|
|
27
|
+
)
|
|
28
|
+
from .retry import RetryPolicy
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_BASE_URL",
|
|
32
|
+
"AsyncPolza",
|
|
33
|
+
"AuthError",
|
|
34
|
+
"Balance",
|
|
35
|
+
"ChatResult",
|
|
36
|
+
"EmptyResponseError",
|
|
37
|
+
"ForbiddenError",
|
|
38
|
+
"InsufficientFundsError",
|
|
39
|
+
"KeyInfo",
|
|
40
|
+
"ModelInfo",
|
|
41
|
+
"ModelPage",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"Polza",
|
|
44
|
+
"PolzaConnectionError",
|
|
45
|
+
"PolzaError",
|
|
46
|
+
"Pricing",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"ReasoningInfo",
|
|
49
|
+
"RequestError",
|
|
50
|
+
"RetryPolicy",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"StreamError",
|
|
53
|
+
"StreamEvent",
|
|
54
|
+
"ToolCall",
|
|
55
|
+
"ToolCallDelta",
|
|
56
|
+
"Usage",
|
|
57
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Typed access to parsed JSON: containers get a known shape; a missing or wrong one is empty."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def obj(value: Any) -> dict[str, Any]:
|
|
9
|
+
return cast("dict[str, Any]", value) if isinstance(value, dict) else {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def arr(value: Any) -> list[Any]:
|
|
13
|
+
return cast("list[Any]", value) if isinstance(value, list) else []
|