polza-extras 0.2.0__py3-none-any.whl

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,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
+ ]
polza_extras/_json.py ADDED
@@ -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 []
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from decimal import Decimal
6
+ from typing import Any, cast
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from ._json import obj
11
+
12
+
13
+ class Usage(BaseModel):
14
+ """Token counts. `cached_tokens` and `cache_write_tokens` are part of `prompt_tokens`;
15
+ `reasoning_tokens` are part of `completion_tokens`."""
16
+
17
+ prompt_tokens: int = 0
18
+ completion_tokens: int = 0
19
+ total_tokens: int = 0
20
+ cached_tokens: int = 0
21
+ cache_write_tokens: int = 0
22
+ reasoning_tokens: int = 0
23
+
24
+
25
+ class ToolCall(BaseModel):
26
+ """A function call the model asked for; `arguments` is the JSON string as the model wrote it."""
27
+
28
+ id: str
29
+ name: str
30
+ arguments: str = ""
31
+
32
+ def parsed_arguments(self) -> dict[str, Any]:
33
+ """`arguments` decoded; raises ValueError when they are not a JSON object."""
34
+ value: Any = json.loads(self.arguments or "{}")
35
+ if not isinstance(value, dict):
36
+ raise ValueError(f"tool call arguments are not a JSON object: {self.arguments!r}")
37
+ return cast("dict[str, Any]", value)
38
+
39
+
40
+ class ChatResult(BaseModel):
41
+ text: str
42
+ tool_calls: list[ToolCall] = Field(default_factory=list[ToolCall])
43
+ reasoning: str | None = None
44
+ finish_reason: str | None = None
45
+ usage: Usage
46
+ cost_rub: Decimal | None = None
47
+ model: str | None = None
48
+ provider: str | None = None
49
+ id: str | None = None
50
+ raw: dict[str, Any]
51
+
52
+
53
+ class ToolCallDelta(BaseModel):
54
+ """A streamed piece of a tool call. The first piece of call `index` has `id` and `name`;
55
+ the `arguments` fragments of one index concatenate to the full JSON string."""
56
+
57
+ index: int
58
+ id: str | None = None
59
+ name: str | None = None
60
+ arguments: str = ""
61
+
62
+
63
+ class StreamEvent(BaseModel):
64
+ """One streamed chunk: a text delta, or the final chunk with usage and ruble cost."""
65
+
66
+ delta: str = ""
67
+ tool_calls: list[ToolCallDelta] = Field(default_factory=list[ToolCallDelta])
68
+ reasoning: str | None = None
69
+ finish_reason: str | None = None
70
+ usage: Usage | None = None
71
+ cost_rub: Decimal | None = None
72
+
73
+
74
+ _MILLION = Decimal(1_000_000)
75
+
76
+
77
+ class Pricing(BaseModel):
78
+ """Model prices in rubles per million tokens (None when the catalog does not list a price)."""
79
+
80
+ prompt_per_million: Decimal | None = None
81
+ completion_per_million: Decimal | None = None
82
+ internal_reasoning_per_million: Decimal | None = None
83
+ input_cache_read_per_million: Decimal | None = None
84
+ input_cache_write_per_million: Decimal | None = None
85
+ currency: str = "RUB"
86
+
87
+ def estimate(
88
+ self, prompt_tokens: int, completion_tokens: int, cached_tokens: int = 0
89
+ ) -> Decimal:
90
+ """Approximate cost of one call; `cached_tokens` are billed at the cache-read price."""
91
+ prompt = self.prompt_per_million or Decimal(0)
92
+ cache_read = (
93
+ self.input_cache_read_per_million
94
+ if self.input_cache_read_per_million is not None
95
+ else prompt
96
+ )
97
+ completion = self.completion_per_million or Decimal(0)
98
+ total = (
99
+ prompt * (prompt_tokens - cached_tokens)
100
+ + cache_read * cached_tokens
101
+ + completion * completion_tokens
102
+ )
103
+ return total / _MILLION
104
+
105
+
106
+ class ReasoningInfo(BaseModel):
107
+ mandatory: bool = False
108
+ default_enabled: bool | None = None
109
+ supported_efforts: list[str] = Field(default_factory=list)
110
+ default_effort: str | None = None
111
+
112
+
113
+ class ModelInfo(BaseModel):
114
+ """A catalog entry; pricing and parameters come from the model's top provider."""
115
+
116
+ id: str
117
+ name: str = ""
118
+ type: str | None = None
119
+ context_length: int | None = None
120
+ max_completion_tokens: int | None = None
121
+ reasoning: ReasoningInfo | None = None
122
+ pricing: Pricing | None = None
123
+ supported_parameters: list[str] = Field(default_factory=list)
124
+ raw: dict[str, Any] = Field(default_factory=dict, repr=False)
125
+
126
+ @classmethod
127
+ def from_api(cls, data: dict[str, Any]) -> ModelInfo:
128
+ top = obj(data.get("top_provider"))
129
+ return cls(
130
+ id=data["id"],
131
+ name=data.get("name") or "",
132
+ type=data.get("type"),
133
+ context_length=data.get("context_length"),
134
+ max_completion_tokens=data.get("max_completion_tokens"),
135
+ reasoning=data.get("reasoning") or top.get("reasoning"),
136
+ pricing=top.get("pricing") or data.get("pricing"),
137
+ supported_parameters=top.get("supported_parameters") or [],
138
+ raw=data,
139
+ )
140
+
141
+
142
+ class ModelPage(BaseModel):
143
+ items: list[ModelInfo]
144
+ page: int = 1
145
+ limit: int | None = None
146
+ total: int | None = None
147
+ total_pages: int | None = None
148
+
149
+
150
+ class Balance(BaseModel):
151
+ amount: Decimal
152
+ available: Decimal
153
+ reserved: Decimal
154
+ spent: Decimal
155
+ updated_at: datetime | None = None
156
+
157
+
158
+ class KeyInfo(BaseModel):
159
+ label: str | None = None
160
+ limit: Decimal | None = None
161
+ limit_remaining: Decimal | None = None
162
+ limit_reset: str | None = None
163
+ usage: Decimal | None = None
164
+ usage_daily: Decimal | None = None
165
+ usage_weekly: Decimal | None = None
166
+ usage_monthly: Decimal | None = None
polza_extras/chat.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal
4
+ from typing import Any
5
+
6
+ from ._json import arr, obj
7
+ from ._models import ChatResult, StreamEvent, ToolCall, ToolCallDelta, Usage
8
+ from .errors import EmptyResponseError
9
+
10
+
11
+ def _cost_rub(usage: dict[str, Any]) -> Decimal | None:
12
+ for key in ("cost_rub", "cost"):
13
+ value = usage.get(key)
14
+ if isinstance(value, (int, float, str)):
15
+ return Decimal(str(value))
16
+ return None
17
+
18
+
19
+ def _int(data: dict[str, Any], key: str) -> int:
20
+ return int(data.get(key) or 0)
21
+
22
+
23
+ def _usage(usage: dict[str, Any]) -> Usage:
24
+ prompt_details = obj(usage.get("prompt_tokens_details"))
25
+ completion_details = obj(usage.get("completion_tokens_details"))
26
+ return Usage(
27
+ prompt_tokens=_int(usage, "prompt_tokens"),
28
+ completion_tokens=_int(usage, "completion_tokens"),
29
+ total_tokens=_int(usage, "total_tokens"),
30
+ cached_tokens=_int(prompt_details, "cached_tokens"),
31
+ cache_write_tokens=_int(prompt_details, "cache_write_tokens"),
32
+ reasoning_tokens=_int(completion_details, "reasoning_tokens"),
33
+ )
34
+
35
+
36
+ def _tool_calls(message: dict[str, Any]) -> list[ToolCall]:
37
+ calls: list[ToolCall] = []
38
+ for item in arr(message.get("tool_calls")):
39
+ call = obj(item)
40
+ function = obj(call.get("function"))
41
+ calls.append(
42
+ ToolCall(
43
+ id=call.get("id") or "",
44
+ name=function.get("name") or "",
45
+ arguments=function.get("arguments") or "",
46
+ )
47
+ )
48
+ return calls
49
+
50
+
51
+ def parse_chat(data: dict[str, Any], reasoning_fallback: bool = False) -> ChatResult:
52
+ choices = arr(data.get("choices"))
53
+ if not choices:
54
+ raise EmptyResponseError("Polza returned a response without choices")
55
+ choice = obj(choices[0])
56
+ message = obj(choice.get("message"))
57
+ finish_reason: str | None = choice.get("finish_reason")
58
+ reasoning: str | None = message.get("reasoning") or message.get("reasoning_content") or None
59
+ text: str = message.get("content") or ""
60
+ tool_calls = _tool_calls(message)
61
+ usage = obj(data.get("usage"))
62
+ parsed_usage = _usage(usage)
63
+ if not text and not tool_calls:
64
+ if reasoning and reasoning_fallback:
65
+ text = reasoning
66
+ else:
67
+ hint = ""
68
+ if finish_reason == "length" and (reasoning or message.get("reasoning_details")):
69
+ hint = "; max_tokens was spent on thinking"
70
+ raise EmptyResponseError(
71
+ f"Polza returned empty content (finish_reason={finish_reason}{hint})",
72
+ finish_reason=finish_reason,
73
+ reasoning=reasoning,
74
+ usage=parsed_usage,
75
+ raw=data,
76
+ cost_rub=_cost_rub(usage),
77
+ )
78
+ return ChatResult(
79
+ text=text,
80
+ tool_calls=tool_calls,
81
+ reasoning=reasoning,
82
+ finish_reason=finish_reason,
83
+ usage=parsed_usage,
84
+ cost_rub=_cost_rub(usage),
85
+ model=data.get("model"),
86
+ provider=data.get("provider"),
87
+ id=data.get("id"),
88
+ raw=data,
89
+ )
90
+
91
+
92
+ def _tool_call_deltas(delta: dict[str, Any]) -> list[ToolCallDelta]:
93
+ deltas: list[ToolCallDelta] = []
94
+ for position, item in enumerate(arr(delta.get("tool_calls"))):
95
+ call = obj(item)
96
+ function = obj(call.get("function"))
97
+ deltas.append(
98
+ ToolCallDelta(
99
+ index=call.get("index", position),
100
+ id=call.get("id"),
101
+ name=function.get("name"),
102
+ arguments=function.get("arguments") or "",
103
+ )
104
+ )
105
+ return deltas
106
+
107
+
108
+ def parse_stream_chunk(data: dict[str, Any]) -> StreamEvent:
109
+ choices = arr(data.get("choices"))
110
+ choice = obj(choices[0]) if choices else {}
111
+ delta = obj(choice.get("delta"))
112
+ usage = obj(data.get("usage"))
113
+ return StreamEvent(
114
+ delta=delta.get("content") or "",
115
+ tool_calls=_tool_call_deltas(delta),
116
+ reasoning=delta.get("reasoning") or delta.get("reasoning_content") or None,
117
+ finish_reason=choice.get("finish_reason"),
118
+ usage=_usage(usage) if usage else None,
119
+ cost_rub=_cost_rub(usage) if usage else None,
120
+ )
polza_extras/client.py ADDED
@@ -0,0 +1,452 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import random
6
+ import time
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
8
+ from typing import Any, Self, TypeVar
9
+
10
+ import openai
11
+ from pydantic import BaseModel
12
+
13
+ from ._json import arr, obj
14
+ from ._models import Balance, ChatResult, KeyInfo, ModelInfo, ModelPage, StreamEvent
15
+ from .chat import parse_chat, parse_stream_chunk
16
+ from .errors import PolzaConnectionError, PolzaError, StreamError, error_from_response
17
+ from .retry import RetryPolicy
18
+
19
+ DEFAULT_BASE_URL = "https://polza.ai/api/v1"
20
+ API_KEY_ENV = "POLZA_AI_API_KEY"
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ # --- shared, I/O-free pieces of Polza and AsyncPolza ---------------------------------------------
26
+
27
+
28
+ def _api_key(api_key: str | None) -> str:
29
+ key = api_key or os.environ.get(API_KEY_ENV)
30
+ if not key:
31
+ raise ValueError(f"Polza API key missing: pass api_key or set {API_KEY_ENV}")
32
+ return key
33
+
34
+
35
+ def _translate(exc: openai.APIStatusError | openai.APIConnectionError) -> PolzaError:
36
+ """The PolzaError for an openai SDK request error."""
37
+ if isinstance(exc, openai.APIConnectionError):
38
+ return PolzaConnectionError(str(exc))
39
+ try:
40
+ body = exc.response.json()
41
+ except ValueError:
42
+ body = None
43
+ return error_from_response(exc.status_code, body, dict(exc.response.headers))
44
+
45
+
46
+ def _stream_error(exc: openai.APIError) -> StreamError:
47
+ body = obj(exc.body)
48
+ return StreamError(
49
+ 200,
50
+ str(body.get("message") or exc.message),
51
+ code=body.get("code"),
52
+ trace_id=body.get("trace_id"),
53
+ metadata=body.get("metadata"),
54
+ )
55
+
56
+
57
+ def _schema_format(json_schema: dict[str, Any] | type[BaseModel], name: str) -> dict[str, Any]:
58
+ schema = json_schema if isinstance(json_schema, dict) else json_schema.model_json_schema()
59
+ return {"type": "json_schema", "json_schema": {"name": name, "schema": schema, "strict": True}}
60
+
61
+
62
+ def _chat_params(
63
+ model: str,
64
+ messages: list[dict[str, Any]],
65
+ max_tokens: int | None,
66
+ temperature: float | None,
67
+ json: bool,
68
+ extra: dict[str, Any],
69
+ stream: bool = False,
70
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
71
+ schema_name: str = "response",
72
+ ) -> dict[str, Any]:
73
+ params: dict[str, Any] = {"model": model, "messages": messages}
74
+ if max_tokens is not None:
75
+ params["max_tokens"] = max_tokens
76
+ if temperature is not None:
77
+ params["temperature"] = temperature
78
+ if json_schema is not None:
79
+ if json or "response_format" in extra:
80
+ raise ValueError("pass json_schema alone, without json=True or response_format")
81
+ extra["response_format"] = _schema_format(json_schema, schema_name)
82
+ if json:
83
+ extra.setdefault("response_format", {"type": "json_object"})
84
+ if extra:
85
+ params["extra_body"] = extra
86
+ if stream:
87
+ params["stream"] = True
88
+ params["stream_options"] = {"include_usage": True}
89
+ return params
90
+
91
+
92
+ def _get_options(
93
+ params: dict[str, Any] | None, language: str | None
94
+ ) -> Any: # openai RequestOptions
95
+ headers = {"accept-language": language} if language else {}
96
+ return {
97
+ "params": {k: v for k, v in (params or {}).items() if v is not None},
98
+ "headers": headers,
99
+ }
100
+
101
+
102
+ def _catalog_query(
103
+ search: str | None,
104
+ type: str | None,
105
+ providers: list[str] | None,
106
+ page: int,
107
+ limit: int | None,
108
+ filters: dict[str, Any],
109
+ ) -> dict[str, Any]:
110
+ return {
111
+ "search": search,
112
+ "type": type,
113
+ "providers": ",".join(providers) if providers else None,
114
+ "page": page,
115
+ "limit": limit,
116
+ **filters,
117
+ }
118
+
119
+
120
+ def _parse_page(data: Any, page: int, limit: int | None) -> ModelPage:
121
+ data = obj(data)
122
+ meta = obj(data.get("meta"))
123
+ return ModelPage(
124
+ items=[ModelInfo.from_api(obj(m)) for m in arr(data.get("data"))],
125
+ page=meta.get("page", page),
126
+ limit=meta.get("limit", limit),
127
+ total=meta.get("total"),
128
+ total_pages=meta.get("totalPages"),
129
+ )
130
+
131
+
132
+ def _balance_url(base_url: str) -> str:
133
+ return base_url.rstrip("/").removesuffix("/v1") + "/v2/balance"
134
+
135
+
136
+ def _parse_balance(data: Any) -> Balance:
137
+ return Balance(
138
+ amount=data["amount"],
139
+ available=data["available"],
140
+ reserved=data["reservedAmount"],
141
+ spent=data["spentAmount"],
142
+ updated_at=data.get("updatedAt"),
143
+ )
144
+
145
+
146
+ class Polza:
147
+ """Polza.ai facade: an OpenAI SDK client plus Polza-specific cost, errors and retries."""
148
+
149
+ def __init__(
150
+ self,
151
+ api_key: str | None = None,
152
+ base_url: str = DEFAULT_BASE_URL,
153
+ retry: RetryPolicy | None = None,
154
+ timeout: float = 60.0,
155
+ sleep: Callable[[float], None] = time.sleep,
156
+ http_client: Any | None = None,
157
+ ) -> None:
158
+ self.base_url = base_url
159
+ self.retry = retry or RetryPolicy()
160
+ self._sleep = sleep
161
+ self._rng = random.Random()
162
+ # Use the SDK's own client flavor (httpx or its vendored httpx2, depending on the openai
163
+ # version) so errors are translated correctly; the Polza-only endpoints reuse it.
164
+ self.http: Any = http_client or openai.DefaultHttpxClient(timeout=timeout)
165
+ # SDK retries are off: Polza has its own retry rules (see RetryPolicy).
166
+ self.openai = openai.OpenAI(
167
+ base_url=base_url, api_key=_api_key(api_key), max_retries=0, http_client=self.http
168
+ )
169
+ # Typed as Any: create(**params) cannot be matched against the SDK's overloads.
170
+ self._completions: Any = self.openai.chat.completions
171
+
172
+ def close(self) -> None:
173
+ """Close the HTTP connections (also the http_client passed in)."""
174
+ self.openai.close()
175
+
176
+ def __enter__(self) -> Self:
177
+ return self
178
+
179
+ def __exit__(self, *exc: object) -> None:
180
+ self.close()
181
+
182
+ def _with_retries(self, call: Callable[[], T]) -> T:
183
+ attempt = 0
184
+ while True:
185
+ try:
186
+ return call()
187
+ except (openai.APIStatusError, openai.APIConnectionError) as exc:
188
+ error = _translate(exc)
189
+ delay = self.retry.delay_for(error, attempt, self._rng)
190
+ if delay is None:
191
+ raise error
192
+ self._sleep(delay)
193
+ attempt += 1
194
+
195
+ def chat(
196
+ self,
197
+ model: str,
198
+ messages: list[dict[str, Any]],
199
+ max_tokens: int | None = None,
200
+ temperature: float | None = None,
201
+ reasoning_fallback: bool = False,
202
+ json: bool = False,
203
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
204
+ schema_name: str = "response",
205
+ **extra: Any,
206
+ ) -> ChatResult:
207
+ """One chat completion. `json=True` asks for any JSON object; `json_schema` (a JSON Schema
208
+ dict or a pydantic model class) asks for strict structured output under `schema_name`.
209
+ Other keyword arguments (tools, reasoning, provider, ...) go to Polza as is."""
210
+ params = _chat_params(
211
+ model,
212
+ messages,
213
+ max_tokens,
214
+ temperature,
215
+ json,
216
+ extra,
217
+ stream=False,
218
+ json_schema=json_schema,
219
+ schema_name=schema_name,
220
+ )
221
+
222
+ def call() -> dict[str, Any]:
223
+ raw = self._completions.with_raw_response.create(**params)
224
+ data: dict[str, Any] = raw.http_response.json()
225
+ return data
226
+
227
+ return parse_chat(self._with_retries(call), reasoning_fallback)
228
+
229
+ def chat_stream(
230
+ self,
231
+ model: str,
232
+ messages: list[dict[str, Any]],
233
+ max_tokens: int | None = None,
234
+ temperature: float | None = None,
235
+ json: bool = False,
236
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
237
+ schema_name: str = "response",
238
+ **extra: Any,
239
+ ) -> Iterator[StreamEvent]:
240
+ """Stream a chat completion. The last event carries `usage` and `cost_rub`.
241
+
242
+ Errors before the first chunk are retried like `chat()`; an error event inside the stream
243
+ raises StreamError (text already yielded stays yielded).
244
+ """
245
+ params = _chat_params(
246
+ model,
247
+ messages,
248
+ max_tokens,
249
+ temperature,
250
+ json,
251
+ extra,
252
+ stream=True,
253
+ json_schema=json_schema,
254
+ schema_name=schema_name,
255
+ )
256
+ stream = self._with_retries(lambda: self._completions.create(**params))
257
+ try:
258
+ for chunk in stream:
259
+ yield parse_stream_chunk(chunk.to_dict())
260
+ except openai.APIError as exc:
261
+ raise _stream_error(exc) from exc
262
+ finally:
263
+ stream.close() # also when the caller stops reading early
264
+
265
+ # --- Polza-only endpoints ------------------------------------------------
266
+
267
+ def _get(
268
+ self, path: str, params: dict[str, Any] | None = None, language: str | None = None
269
+ ) -> Any:
270
+ """GET a Polza endpoint through the SDK client, so errors and retries match chat()."""
271
+ options = _get_options(params, language)
272
+ return self._with_retries(lambda: self.openai.get(path, cast_to=object, options=options))
273
+
274
+ def models(
275
+ self,
276
+ search: str | None = None,
277
+ type: str | None = None,
278
+ providers: list[str] | None = None,
279
+ page: int = 1,
280
+ limit: int | None = None,
281
+ language: str = "ru",
282
+ **filters: Any,
283
+ ) -> ModelPage:
284
+ """One page of /models/catalog. Extra filters (e.g. sortBy, contextLengthMin) pass as is."""
285
+ data = self._get(
286
+ "/models/catalog",
287
+ _catalog_query(search, type, providers, page, limit, filters),
288
+ language,
289
+ )
290
+ return _parse_page(data, page, limit)
291
+
292
+ def model(self, model_id: str, language: str = "ru") -> ModelInfo:
293
+ """Details of one model; raises NotFoundError for an unknown id."""
294
+ data = self._get(f"/models/{model_id}", language=language)
295
+ return ModelInfo.from_api(data.get("data", data))
296
+
297
+ def balance(self) -> Balance:
298
+ """Organization balance in rubles (GET /api/v2/balance)."""
299
+ return _parse_balance(self._get(_balance_url(self.base_url)))
300
+
301
+ def key_info(self) -> KeyInfo:
302
+ """Limits and usage of the current API key (GET /v1/key)."""
303
+ data = self._get("/key")
304
+ return KeyInfo.model_validate(data.get("data", data))
305
+
306
+
307
+ class AsyncPolza:
308
+ """Async mirror of Polza on openai.AsyncOpenAI; same methods, awaited."""
309
+
310
+ def __init__(
311
+ self,
312
+ api_key: str | None = None,
313
+ base_url: str = DEFAULT_BASE_URL,
314
+ retry: RetryPolicy | None = None,
315
+ timeout: float = 60.0,
316
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
317
+ http_client: Any | None = None,
318
+ ) -> None:
319
+ self.base_url = base_url
320
+ self.retry = retry or RetryPolicy()
321
+ self._sleep = sleep
322
+ self._rng = random.Random()
323
+ self.http: Any = http_client or openai.DefaultAsyncHttpxClient(timeout=timeout)
324
+ self.openai = openai.AsyncOpenAI(
325
+ base_url=base_url, api_key=_api_key(api_key), max_retries=0, http_client=self.http
326
+ )
327
+ # Typed as Any: create(**params) cannot be matched against the SDK's overloads.
328
+ self._completions: Any = self.openai.chat.completions
329
+
330
+ async def close(self) -> None:
331
+ await self.openai.close()
332
+
333
+ async def __aenter__(self) -> Self:
334
+ return self
335
+
336
+ async def __aexit__(self, *exc: object) -> None:
337
+ await self.close()
338
+
339
+ async def _with_retries(self, call: Callable[[], Awaitable[T]]) -> T:
340
+ attempt = 0
341
+ while True:
342
+ try:
343
+ return await call()
344
+ except (openai.APIStatusError, openai.APIConnectionError) as exc:
345
+ error = _translate(exc)
346
+ delay = self.retry.delay_for(error, attempt, self._rng)
347
+ if delay is None:
348
+ raise error
349
+ await self._sleep(delay)
350
+ attempt += 1
351
+
352
+ async def chat(
353
+ self,
354
+ model: str,
355
+ messages: list[dict[str, Any]],
356
+ max_tokens: int | None = None,
357
+ temperature: float | None = None,
358
+ reasoning_fallback: bool = False,
359
+ json: bool = False,
360
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
361
+ schema_name: str = "response",
362
+ **extra: Any,
363
+ ) -> ChatResult:
364
+ """Async version of Polza.chat."""
365
+ params = _chat_params(
366
+ model,
367
+ messages,
368
+ max_tokens,
369
+ temperature,
370
+ json,
371
+ extra,
372
+ stream=False,
373
+ json_schema=json_schema,
374
+ schema_name=schema_name,
375
+ )
376
+
377
+ async def call() -> dict[str, Any]:
378
+ raw = await self._completions.with_raw_response.create(**params)
379
+ data: dict[str, Any] = raw.http_response.json()
380
+ return data
381
+
382
+ return parse_chat(await self._with_retries(call), reasoning_fallback)
383
+
384
+ async def chat_stream(
385
+ self,
386
+ model: str,
387
+ messages: list[dict[str, Any]],
388
+ max_tokens: int | None = None,
389
+ temperature: float | None = None,
390
+ json: bool = False,
391
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
392
+ schema_name: str = "response",
393
+ **extra: Any,
394
+ ) -> AsyncIterator[StreamEvent]:
395
+ """Async version of Polza.chat_stream: `async for event in polza.chat_stream(...)`."""
396
+ params = _chat_params(
397
+ model,
398
+ messages,
399
+ max_tokens,
400
+ temperature,
401
+ json,
402
+ extra,
403
+ stream=True,
404
+ json_schema=json_schema,
405
+ schema_name=schema_name,
406
+ )
407
+
408
+ async def call() -> Any:
409
+ return await self._completions.create(**params)
410
+
411
+ stream = await self._with_retries(call)
412
+ try:
413
+ async for chunk in stream:
414
+ yield parse_stream_chunk(chunk.to_dict())
415
+ except openai.APIError as exc:
416
+ raise _stream_error(exc) from exc
417
+ finally:
418
+ await stream.close()
419
+
420
+ async def _get(
421
+ self, path: str, params: dict[str, Any] | None = None, language: str | None = None
422
+ ) -> Any:
423
+ options = _get_options(params, language)
424
+
425
+ async def call() -> Any:
426
+ return await self.openai.get(path, cast_to=object, options=options)
427
+
428
+ return await self._with_retries(call)
429
+
430
+ async def models(
431
+ self,
432
+ search: str | None = None,
433
+ type: str | None = None,
434
+ providers: list[str] | None = None,
435
+ page: int = 1,
436
+ limit: int | None = None,
437
+ language: str = "ru",
438
+ **filters: Any,
439
+ ) -> ModelPage:
440
+ query = _catalog_query(search, type, providers, page, limit, filters)
441
+ return _parse_page(await self._get("/models/catalog", query, language), page, limit)
442
+
443
+ async def model(self, model_id: str, language: str = "ru") -> ModelInfo:
444
+ data = await self._get(f"/models/{model_id}", language=language)
445
+ return ModelInfo.from_api(data.get("data", data))
446
+
447
+ async def balance(self) -> Balance:
448
+ return _parse_balance(await self._get(_balance_url(self.base_url)))
449
+
450
+ async def key_info(self) -> KeyInfo:
451
+ data = await self._get("/key")
452
+ return KeyInfo.model_validate(data.get("data", data))
polza_extras/errors.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ from ._json import obj
7
+
8
+ if TYPE_CHECKING:
9
+ from decimal import Decimal
10
+
11
+ from ._models import Usage
12
+
13
+
14
+ class PolzaError(Exception):
15
+ """Any error returned by the Polza API, parsed from its error body."""
16
+
17
+ def __init__(
18
+ self,
19
+ status: int,
20
+ message: str,
21
+ code: str | None = None,
22
+ trace_id: str | None = None,
23
+ metadata: dict[str, Any] | None = None,
24
+ details: dict[str, Any] | None = None,
25
+ retry_after: float | None = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.retry_after = retry_after
29
+ self.status = status
30
+ self.message = message
31
+ self.code = code
32
+ self.trace_id = trace_id
33
+ self.metadata = metadata or {}
34
+ self.details = details or {}
35
+
36
+ def __str__(self) -> str:
37
+ parts = [str(self.status)]
38
+ if self.code:
39
+ parts.append(self.code)
40
+ text = f"[{' '.join(parts)}] {self.message}"
41
+ raw = self.metadata.get("raw")
42
+ if raw and str(raw) not in self.message:
43
+ text += f": {raw}"
44
+ if self.trace_id:
45
+ text += f" (trace_id={self.trace_id})"
46
+ return text
47
+
48
+
49
+ class AuthError(PolzaError):
50
+ """401: missing, invalid or revoked API key."""
51
+
52
+
53
+ class InsufficientFundsError(PolzaError):
54
+ """402: balance or spending limit exhausted (or a parallel-request reserve conflict)."""
55
+
56
+
57
+ class ForbiddenError(PolzaError):
58
+ """403: access denied, including requests rejected by moderation."""
59
+
60
+
61
+ class RateLimitError(PolzaError):
62
+ """429. `scope` is "own" for Polza's per-organization limit (has Retry-After), or "provider"."""
63
+
64
+ @property
65
+ def scope(self) -> Literal["own", "provider"]:
66
+ return "own" if self.retry_after is not None else "provider"
67
+
68
+
69
+ class ServerError(PolzaError):
70
+ """408, 5xx: provider timeout, bad gateway, unavailable."""
71
+
72
+
73
+ class RequestError(PolzaError):
74
+ """Other 4xx: bad request, not found, payload too large."""
75
+
76
+
77
+ class NotFoundError(RequestError):
78
+ """404: unknown model or endpoint (a model id without the provider/ prefix is a 404 too)."""
79
+
80
+
81
+ class PolzaConnectionError(PolzaError):
82
+ """The request never got an HTTP response (DNS, connect, timeout)."""
83
+
84
+ def __init__(self, message: str) -> None:
85
+ super().__init__(0, message)
86
+
87
+
88
+ class StreamError(PolzaError):
89
+ """An error event inside a 200 stream (Polza sends `error` + `finish_reason: "error"`).
90
+
91
+ Text received before it was already yielded; the request is not retried mid-stream.
92
+ """
93
+
94
+
95
+ class EmptyResponseError(PolzaError):
96
+ """A 2xx response without usable text: no choices, or empty content.
97
+
98
+ `reasoning` carries the model's reasoning text, if any; with `finish_reason == "length"` it
99
+ usually means `max_tokens` was spent on thinking. `usage` and `raw` (the response body) are
100
+ kept because the request is still billed.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ message: str,
106
+ finish_reason: str | None = None,
107
+ reasoning: str | None = None,
108
+ usage: Usage | None = None,
109
+ raw: dict[str, Any] | None = None,
110
+ cost_rub: Decimal | None = None,
111
+ ) -> None:
112
+ super().__init__(200, message)
113
+ self.finish_reason = finish_reason
114
+ self.reasoning = reasoning
115
+ self.usage = usage
116
+ self.raw = raw or {}
117
+ self.cost_rub = cost_rub # what the failed call was billed
118
+
119
+
120
+ _BY_STATUS: dict[int, type[PolzaError]] = {
121
+ 401: AuthError,
122
+ 402: InsufficientFundsError,
123
+ 403: ForbiddenError,
124
+ 404: NotFoundError,
125
+ 408: ServerError,
126
+ }
127
+
128
+
129
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
130
+ for key, value in headers.items():
131
+ if key.lower() == name.lower():
132
+ return value
133
+ return None
134
+
135
+
136
+ def _retry_after(headers: Mapping[str, str]) -> float | None:
137
+ raw = _header(headers, "retry-after")
138
+ try:
139
+ return float(raw) if raw is not None else None
140
+ except ValueError:
141
+ return None
142
+
143
+
144
+ def error_from_response(status: int, body: Any, headers: Mapping[str, str]) -> PolzaError:
145
+ error = obj(obj(body).get("error"))
146
+ top_trace = obj(body).get("trace_id")
147
+ kwargs: dict[str, Any] = {
148
+ "message": str(error.get("message") or f"Polza API returned HTTP {status}"),
149
+ "code": error.get("code"),
150
+ "trace_id": error.get("trace_id") or top_trace,
151
+ "metadata": error.get("metadata"),
152
+ "details": error.get("details"),
153
+ }
154
+ kwargs["retry_after"] = _retry_after(headers)
155
+ if status == 429:
156
+ return RateLimitError(status, **kwargs)
157
+ if status in _BY_STATUS:
158
+ return _BY_STATUS[status](status, **kwargs)
159
+ if status >= 500:
160
+ return ServerError(status, **kwargs)
161
+ return RequestError(status, **kwargs)
polza_extras/py.typed ADDED
File without changes
polza_extras/retry.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass
5
+
6
+ from .errors import PolzaError, RateLimitError
7
+
8
+ _BACKOFF_STATUSES = frozenset({0, 408, 500, 502})
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RetryPolicy:
13
+ """Retry rules from the Polza docs.
14
+
15
+ 408/500/502: retry after a pause. 503: honor Retry-After, else back off.
16
+ 429: wait Retry-After (own limit) or back off (provider limit). 402 is retried only when
17
+ `retry_402` is set, because Polza gives no machine-readable way to tell a parallel-load
18
+ reserve conflict from an empty balance. 400/401/403/404/409/413 are never retried.
19
+ """
20
+
21
+ max_retries: int = 3
22
+ base_delay: float = 1.0
23
+ max_delay: float = 30.0
24
+ max_wait: float = 60.0
25
+ jitter: float = 0.25
26
+ retry_402: bool = False
27
+ retry_402_attempts: int = 2
28
+
29
+ def delay_for(
30
+ self, error: PolzaError, attempt: int, rng: random.Random | None = None
31
+ ) -> float | None:
32
+ """Seconds to sleep before retry number `attempt` (0-based), or None to give up."""
33
+ if attempt >= self.max_retries:
34
+ return None
35
+ status = error.status
36
+ if status == 402:
37
+ if not self.retry_402 or attempt >= self.retry_402_attempts:
38
+ return None
39
+ return self._jittered(self.base_delay, rng)
40
+ if isinstance(error, RateLimitError) and error.retry_after is not None:
41
+ return error.retry_after if error.retry_after <= self.max_wait else None
42
+ if status == 503 and error.retry_after is not None:
43
+ return error.retry_after
44
+ if status in _BACKOFF_STATUSES or status in (429, 503):
45
+ return self._jittered(min(self.base_delay * 2**attempt, self.max_delay), rng)
46
+ return None
47
+
48
+ def _jittered(self, delay: float, rng: random.Random | None) -> float:
49
+ if not self.jitter:
50
+ return delay
51
+ return delay * (1 + (rng or random).uniform(-self.jitter, self.jitter))
@@ -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,12 @@
1
+ polza_extras/__init__.py,sha256=8aBkrSncshcSeRtukXNK1XZ3nY8CSdImMPgIrUQ14gk,1039
2
+ polza_extras/_json.py,sha256=3eQ4IKBxqWPvo1bqUSpaRLa6tVJ_BzgGCtPf9jivx4k,388
3
+ polza_extras/_models.py,sha256=YRYyZqsoM6blXHZcSN7BP8C03JI8IGictorRhPatY_U,5180
4
+ polza_extras/chat.py,sha256=zZU0l_14veij84D9m9Q95JAiy_3anJIafGv35Lf5doc,4213
5
+ polza_extras/client.py,sha256=TBZNAs24xwB4c6FiLfWd_SXMHb05cfi0oIW832DKqhs,15412
6
+ polza_extras/errors.py,sha256=v34B1DTDOFzHhzpMxOTYlpoxQCEbZqvB5oOsf38__No,4868
7
+ polza_extras/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ polza_extras/retry.py,sha256=wUQ20oYWsNUtjWOUF5-eZJoiYYqFyvuXyTDVxIJaH4I,1957
9
+ polza_extras-0.2.0.dist-info/METADATA,sha256=dlXHA2cN_hTOm4D23cgh_vHk8f70VD71mbP7qHcMBQg,8325
10
+ polza_extras-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ polza_extras-0.2.0.dist-info/licenses/LICENSE,sha256=DsRhA3-G0ezNENtBc0WESVItyrPiPfi4RDMfldHRsGw,1082
12
+ polza_extras-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.