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.
- polza_extras/__init__.py +57 -0
- polza_extras/_json.py +13 -0
- polza_extras/_models.py +166 -0
- polza_extras/chat.py +120 -0
- polza_extras/client.py +452 -0
- polza_extras/errors.py +161 -0
- polza_extras/py.typed +0 -0
- polza_extras/retry.py +51 -0
- polza_extras-0.2.0.dist-info/METADATA +189 -0
- polza_extras-0.2.0.dist-info/RECORD +12 -0
- polza_extras-0.2.0.dist-info/WHEEL +4 -0
- polza_extras-0.2.0.dist-info/licenses/LICENSE +21 -0
polza_extras/__init__.py
ADDED
|
@@ -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 []
|
polza_extras/_models.py
ADDED
|
@@ -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,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.
|