klyuchora 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.
klyuchora/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Klyuchora SDK: one client for chat, responses, embeddings, images, audio, models, key limits and balance."""
2
+ from ._client import Klyuchora
3
+ from ._errors import (APIError, AuthenticationError, BadRequestError, ConnectionFailed,
4
+ InsufficientBalanceError, KlyuchoraError, NotFoundError, PermissionDeniedError,
5
+ RateLimitError, RequestTimeout, ServerError)
6
+ from ._obj import Obj
7
+ from ._stream import ChatStream, Stream
8
+ from ._version import __version__
9
+
10
+ __all__ = [
11
+ "Klyuchora", "AsyncKlyuchora", "Stream", "ChatStream", "Obj", "__version__",
12
+ "KlyuchoraError", "APIError", "AuthenticationError", "BadRequestError", "ConnectionFailed",
13
+ "InsufficientBalanceError", "NotFoundError", "PermissionDeniedError", "RateLimitError",
14
+ "RequestTimeout", "ServerError",
15
+ ]
16
+
17
+
18
+ def __getattr__(name): # lazy: asyncio is only imported when needed
19
+ if name == "AsyncKlyuchora":
20
+ from ._async import AsyncKlyuchora
21
+ return AsyncKlyuchora
22
+ raise AttributeError(name)
klyuchora/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
klyuchora/_async.py ADDED
@@ -0,0 +1,99 @@
1
+ """Async interface. Calls run in worker threads, so it works on any asyncio loop with no extra packages."""
2
+ import asyncio
3
+ import functools
4
+ from typing import Any, AsyncIterator, Optional
5
+
6
+ from ._client import Klyuchora
7
+ from ._stream import ChatStream, Stream
8
+
9
+
10
+ async def _run(fn, *args, **kwargs):
11
+ loop = asyncio.get_running_loop()
12
+ return await loop.run_in_executor(None, functools.partial(fn, *args, **kwargs))
13
+
14
+
15
+ class AsyncStream:
16
+ """``async for`` over a :class:`Stream`."""
17
+
18
+ def __init__(self, stream: Stream):
19
+ self._stream = stream
20
+ self.request_id = stream.request_id
21
+
22
+ def __aiter__(self) -> "AsyncStream":
23
+ return self
24
+
25
+ async def __anext__(self) -> Any:
26
+ def step():
27
+ try:
28
+ return next(self._stream), False
29
+ except StopIteration:
30
+ return None, True
31
+ item, done = await _run(step)
32
+ if done:
33
+ raise StopAsyncIteration
34
+ return item
35
+
36
+ async def text_deltas(self) -> AsyncIterator[str]:
37
+ async for chunk in self:
38
+ for choice in chunk.get("choices") or []:
39
+ piece = (choice.get("delta") or {}).get("content")
40
+ if piece:
41
+ yield piece
42
+
43
+ async def collect(self) -> Any:
44
+ if isinstance(self._stream, ChatStream):
45
+ return await _run(self._stream.collect)
46
+ return [event async for event in self]
47
+
48
+ async def aclose(self) -> None:
49
+ await _run(self._stream.close)
50
+
51
+ async def __aenter__(self) -> "AsyncStream":
52
+ return self
53
+
54
+ async def __aexit__(self, *exc: Any) -> None:
55
+ await self.aclose()
56
+
57
+
58
+ class _AsyncProxy:
59
+ def __init__(self, target: Any):
60
+ self._target = target
61
+
62
+ def __getattr__(self, name: str) -> Any:
63
+ attr = getattr(self._target, name)
64
+ if callable(attr):
65
+ @functools.wraps(attr)
66
+ async def call(*args: Any, **kwargs: Any) -> Any:
67
+ result = await _run(attr, *args, **kwargs)
68
+ return AsyncStream(result) if isinstance(result, Stream) else result
69
+ return call
70
+ if hasattr(attr, "__dict__") and not isinstance(attr, (str, int, float, bool, list, dict)):
71
+ return _AsyncProxy(attr)
72
+ return attr
73
+
74
+
75
+ class AsyncKlyuchora:
76
+ """Same API as :class:`Klyuchora`, every method is awaited:
77
+
78
+ client = AsyncKlyuchora()
79
+ reply = await client.chat.completions.create(model="...", messages=[...])
80
+ stream = await client.chat.completions.create(model="...", messages=[...], stream=True)
81
+ async for text in stream.text_deltas(): ...
82
+ """
83
+
84
+ def __init__(self, *args: Any, **kwargs: Any):
85
+ self._sync = Klyuchora(*args, **kwargs)
86
+ for name in ("chat", "completions", "responses", "messages", "embeddings", "images", "audio",
87
+ "rerank", "moderations", "models", "key", "account", "rates"):
88
+ setattr(self, name, _AsyncProxy(getattr(self._sync, name)))
89
+
90
+ @property
91
+ def base_url(self) -> str:
92
+ return self._sync.base_url
93
+
94
+ async def estimate_cost(self, *args: Any, **kwargs: Any) -> Any:
95
+ return await _run(self._sync.estimate_cost, *args, **kwargs)
96
+
97
+ async def request(self, *args: Any, **kwargs: Any) -> Any:
98
+ result = await _run(self._sync.request, *args, **kwargs)
99
+ return AsyncStream(result) if isinstance(result, Stream) else result
klyuchora/_client.py ADDED
@@ -0,0 +1,471 @@
1
+ """The Klyuchora client."""
2
+ import json
3
+ import mimetypes
4
+ import os
5
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
6
+
7
+ from ._errors import (APIError, AuthenticationError, KlyuchoraError)
8
+ from ._http import DEFAULT_BASE_URL, Transport, encode_multipart, env
9
+ from ._obj import Obj, wrap
10
+ from ._pricing import QUOTA_PER_USD, estimate, group_prices, quota_to_usd
11
+ from ._stream import ChatStream, Stream
12
+ from ._version import __version__
13
+
14
+ FileInput = Union[str, bytes, "os.PathLike[str]", Any]
15
+
16
+
17
+ _MAGIC = [
18
+ (b"RIFF", "wav", 8, b"WAVE"), (b"RIFF", "webp", 8, b"WEBP"), (b"\x89PNG", "png", 0, b""), (b"\xff\xd8\xff", "jpg", 0, b""),
19
+ (b"fLaC", "flac", 0, b""), (b"OggS", "ogg", 0, b""), (b"ID3", "mp3", 0, b""), (b"\x1a\x45\xdf\xa3", "webm", 0, b""),
20
+ (b"GIF8", "gif", 0, b""),
21
+ ]
22
+
23
+
24
+ def _sniff_name(content: bytes, default_name: str) -> str:
25
+ """Name for bytes without a name: the gateway picks the decoder by the file extension."""
26
+ for magic, ext, off, extra in _MAGIC:
27
+ if content.startswith(magic) and (not extra or content[off:off + len(extra)] == extra):
28
+ return "file." + ext
29
+ if len(content) > 2 and content[0] == 0xFF and content[1] & 0xE0 == 0xE0:
30
+ return "file.mp3"
31
+ if content[4:8] == b"ftyp":
32
+ return "file.m4a"
33
+ return default_name
34
+
35
+
36
+ def _file_part(value: FileInput, default_name: str = "file") -> tuple:
37
+ if hasattr(value, "read"):
38
+ content = value.read()
39
+ raw_name = getattr(value, "name", None)
40
+ name = os.path.basename(raw_name) if isinstance(raw_name, str) and raw_name else _sniff_name(content, default_name)
41
+ elif isinstance(value, (bytes, bytearray)):
42
+ content = bytes(value)
43
+ name = _sniff_name(content, default_name)
44
+ else:
45
+ with open(value, "rb") as fh:
46
+ content = fh.read()
47
+ name = os.path.basename(str(value))
48
+ ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
49
+ return name, content, ctype
50
+
51
+
52
+ def _clean_secret(value: Optional[str], name: str) -> Optional[str]:
53
+ if value is None:
54
+ return None
55
+ value = str(value).strip()
56
+ if any(ch in value for ch in "\r\n\x00") or any(ord(ch) > 126 for ch in value):
57
+ raise KlyuchoraError("%s contains characters that cannot be sent in a header" % name)
58
+ return value or None
59
+
60
+
61
+ class KeyList(list):
62
+ """List of keys; ``.total`` is the number of keys in the account (there may be more than one page)."""
63
+
64
+ total: int = 0
65
+
66
+
67
+ class _Resource:
68
+ def __init__(self, client: "Klyuchora"):
69
+ self._c = client
70
+
71
+
72
+ class _ChatCompletions(_Resource):
73
+ def create(self, *, model: str, messages: List[Dict[str, Any]], stream: bool = False,
74
+ timeout: Optional[float] = None, **params: Any) -> Union[Obj, ChatStream]:
75
+ """POST /v1/chat/completions. Extra keyword arguments go to the gateway unchanged."""
76
+ body = dict(params, model=model, messages=messages)
77
+ if stream:
78
+ body["stream"] = True
79
+ opts = dict(body.get("stream_options") or {})
80
+ opts.setdefault("include_usage", True)
81
+ body["stream_options"] = opts
82
+ resp = self._c._relay("POST", "/v1/chat/completions", json_body=body, stream=True, timeout=timeout)
83
+ return ChatStream(resp)
84
+ return self._c._relay_json("POST", "/v1/chat/completions", body, timeout=timeout)
85
+
86
+
87
+ class _Chat(_Resource):
88
+ def __init__(self, client: "Klyuchora"):
89
+ super().__init__(client)
90
+ self.completions = _ChatCompletions(client)
91
+
92
+
93
+ class _Completions(_Resource):
94
+ def create(self, *, model: str, prompt: Any, timeout: Optional[float] = None, **params: Any) -> Obj:
95
+ """POST /v1/completions (legacy text completion)."""
96
+ return self._c._relay_json("POST", "/v1/completions", dict(params, model=model, prompt=prompt), timeout=timeout)
97
+
98
+
99
+ class _Responses(_Resource):
100
+ def create(self, *, model: str, stream: bool = False, timeout: Optional[float] = None, **params: Any) -> Union[Obj, Stream]:
101
+ """POST /v1/responses. Pass ``input=...``."""
102
+ body = dict(params, model=model)
103
+ if stream:
104
+ body["stream"] = True
105
+ return Stream(self._c._relay("POST", "/v1/responses", json_body=body, stream=True, timeout=timeout))
106
+ return self._c._relay_json("POST", "/v1/responses", body, timeout=timeout)
107
+
108
+
109
+ class _Messages(_Resource):
110
+ def create(self, *, model: str, messages: List[Dict[str, Any]], max_tokens: int, stream: bool = False,
111
+ timeout: Optional[float] = None, api_version: str = "2023-06-01", **params: Any) -> Union[Obj, Stream]:
112
+ """POST /v1/messages (Claude message format)."""
113
+ body = dict(params, model=model, messages=messages, max_tokens=max_tokens)
114
+ headers = {"anthropic-version": api_version}
115
+ if stream:
116
+ body["stream"] = True
117
+ return Stream(self._c._relay("POST", "/v1/messages", json_body=body, stream=True, timeout=timeout, headers=headers))
118
+ return self._c._relay_json("POST", "/v1/messages", body, timeout=timeout, headers=headers)
119
+
120
+
121
+ class _Embeddings(_Resource):
122
+ def create(self, *, model: str, input: Any, timeout: Optional[float] = None, **params: Any) -> Obj:
123
+ """POST /v1/embeddings."""
124
+ return self._c._relay_json("POST", "/v1/embeddings", dict(params, model=model, input=input), timeout=timeout)
125
+
126
+
127
+ class _Images(_Resource):
128
+ def generate(self, *, model: str, prompt: str, timeout: Optional[float] = None, **params: Any) -> Obj:
129
+ """POST /v1/images/generations."""
130
+ return self._c._relay_json("POST", "/v1/images/generations", dict(params, model=model, prompt=prompt), timeout=timeout)
131
+
132
+ def edit(self, *, model: str, image: FileInput, prompt: str, mask: Optional[FileInput] = None,
133
+ timeout: Optional[float] = None, **params: Any) -> Obj:
134
+ """POST /v1/images/edits (multipart upload)."""
135
+ images = list(image) if isinstance(image, (list, tuple)) else [image]
136
+ files = [("image[]" if len(images) > 1 else "image", _file_part(img, "image.png")) for img in images]
137
+ if mask is not None:
138
+ files.append(("mask", _file_part(mask, "mask.png")))
139
+ fields = dict(params, model=model, prompt=prompt)
140
+ data, ctype = encode_multipart(fields, files)
141
+ resp = self._c._relay("POST", "/v1/images/edits", data=data, headers={"Content-Type": ctype}, timeout=timeout)
142
+ return wrap(resp.json())
143
+
144
+
145
+ class _Speech(_Resource):
146
+ def create(self, *, model: str, input: str, voice: str, timeout: Optional[float] = None, **params: Any) -> bytes:
147
+ """POST /v1/audio/speech. Returns the audio bytes."""
148
+ body = dict(params, model=model, input=input, voice=voice)
149
+ resp = self._c._relay("POST", "/v1/audio/speech", json_body=body, timeout=timeout, headers={"Accept": "*/*"})
150
+ return resp.read()
151
+
152
+
153
+ class _Transcriptions(_Resource):
154
+ def create(self, *, model: str, file: FileInput, timeout: Optional[float] = None, **params: Any) -> Obj:
155
+ """POST /v1/audio/transcriptions (multipart upload)."""
156
+ data, ctype = encode_multipart(dict(params, model=model), {"file": _file_part(file, "audio.mp3")})
157
+ resp = self._c._relay("POST", "/v1/audio/transcriptions", data=data, headers={"Content-Type": ctype}, timeout=timeout)
158
+ return _json_or_text(resp)
159
+
160
+
161
+ class _Translations(_Resource):
162
+ def create(self, *, model: str, file: FileInput, timeout: Optional[float] = None, **params: Any) -> Obj:
163
+ """POST /v1/audio/translations (multipart upload)."""
164
+ data, ctype = encode_multipart(dict(params, model=model), {"file": _file_part(file, "audio.mp3")})
165
+ resp = self._c._relay("POST", "/v1/audio/translations", data=data, headers={"Content-Type": ctype}, timeout=timeout)
166
+ return _json_or_text(resp)
167
+
168
+
169
+ class _Audio(_Resource):
170
+ def __init__(self, client: "Klyuchora"):
171
+ super().__init__(client)
172
+ self.speech = _Speech(client)
173
+ self.transcriptions = _Transcriptions(client)
174
+ self.translations = _Translations(client)
175
+
176
+
177
+ class _Rerank(_Resource):
178
+ def create(self, *, model: str, query: str, documents: List[Any], timeout: Optional[float] = None, **params: Any) -> Obj:
179
+ """POST /v1/rerank."""
180
+ return self._c._relay_json("POST", "/v1/rerank", dict(params, model=model, query=query, documents=documents), timeout=timeout)
181
+
182
+
183
+ class _Moderations(_Resource):
184
+ def create(self, *, input: Any, model: Optional[str] = None, timeout: Optional[float] = None, **params: Any) -> Obj:
185
+ """POST /v1/moderations."""
186
+ body = dict(params, input=input)
187
+ if model:
188
+ body["model"] = model
189
+ return self._c._relay_json("POST", "/v1/moderations", body, timeout=timeout)
190
+
191
+
192
+ def _json_or_text(resp: Any) -> Obj:
193
+ raw = resp.read()
194
+ try:
195
+ return wrap(json.loads(raw.decode("utf-8")))
196
+ except ValueError:
197
+ return wrap({"text": raw.decode("utf-8", "replace")})
198
+
199
+
200
+ class _Models(_Resource):
201
+ def list(self) -> List[Obj]:
202
+ """Models this key may call right now (GET /v1/models)."""
203
+ data = self._c._relay_json("GET", "/v1/models", None)
204
+ return data.get("data", []) if isinstance(data, dict) else []
205
+
206
+ def ids(self) -> List[str]:
207
+ """Just the model names, sorted."""
208
+ return sorted(m["id"] for m in self.list() if "id" in m)
209
+
210
+ def retrieve(self, model: str) -> Obj:
211
+ """GET /v1/models/{model}."""
212
+ from urllib.parse import quote
213
+ return self._c._relay_json("GET", "/v1/models/" + quote(model, safe="/:"), None)
214
+
215
+ def catalog(self, *, search: Optional[str] = None, endpoint: Optional[str] = None,
216
+ group: Optional[str] = None, refresh: bool = False) -> List[Obj]:
217
+ """Public price list (GET /api/pricing, no key needed).
218
+
219
+ Each item has ``name``, ``endpoint_types``, ``groups`` and ``prices``: one row per
220
+ group that can serve the model, USD per 1M tokens (``input_per_1m_usd``,
221
+ ``output_per_1m_usd``, ``cached_input_per_1m_usd``) or ``per_request_usd``.
222
+ The prices are final: nothing is added on top.
223
+ """
224
+ data = self._c._catalog(refresh)
225
+ gr = data.get("group_ratio") or {}
226
+ if isinstance(gr, dict):
227
+ ratios = {k: v for k, v in gr.items()}
228
+ else:
229
+ ratios = {r["key"]: r["value"] for r in gr if isinstance(r, dict)}
230
+ out = []
231
+ needle = search.lower() if search else None
232
+ for m in data.get("data", []):
233
+ name = m.get("model_name")
234
+ if not name:
235
+ continue
236
+ if needle and needle not in name.lower():
237
+ continue
238
+ if endpoint and endpoint not in (m.get("supported_endpoint_types") or []):
239
+ continue
240
+ if group and group not in (m.get("enable_groups") or []):
241
+ continue
242
+ out.append(wrap({
243
+ "name": name,
244
+ "endpoint_types": m.get("supported_endpoint_types") or [],
245
+ "groups": m.get("enable_groups") or [],
246
+ "billing": "per_request" if m.get("quota_type") == 1 else "per_token",
247
+ "prices": group_prices(m, ratios),
248
+ }))
249
+ out.sort(key=lambda x: x["name"])
250
+ return out
251
+
252
+ def price(self, model: str) -> Obj:
253
+ """Price rows of one model; raises ``KeyError`` for an unknown name."""
254
+ for item in self.catalog(search=model):
255
+ if item["name"] == model:
256
+ return item
257
+ raise KeyError("model %r is not in the catalog" % model)
258
+
259
+
260
+ class _Key(_Resource):
261
+ def usage(self) -> Obj:
262
+ """Limits and spend of the key in use (GET /api/usage/token/).
263
+
264
+ Returns ``name``, ``unlimited``, ``granted_usd``, ``used_usd``, ``remaining_usd``,
265
+ ``model_limits`` (list of allowed models, or empty), ``model_limits_enabled`` and
266
+ ``expires_at`` (unix seconds, ``0`` means never). When ``unlimited`` is true the
267
+ key has no own limit and spends from the account balance.
268
+ """
269
+ raw = self._c._relay_json("GET", "/api/usage/token/", None)
270
+ data = raw.get("data", raw) if isinstance(raw, dict) else {}
271
+ per = self._c._quota_per_unit()
272
+ limits = data.get("model_limits") or {}
273
+ if isinstance(limits, dict):
274
+ limits = sorted(limits.keys())
275
+ return wrap({
276
+ "name": data.get("name"),
277
+ "unlimited": bool(data.get("unlimited_quota")),
278
+ "granted_usd": quota_to_usd(data.get("total_granted"), per),
279
+ "used_usd": quota_to_usd(data.get("total_used"), per),
280
+ "remaining_usd": quota_to_usd(data.get("total_available"), per),
281
+ "model_limits_enabled": bool(data.get("model_limits_enabled")),
282
+ "model_limits": limits,
283
+ "expires_at": data.get("expires_at") or 0,
284
+ "raw": data,
285
+ })
286
+
287
+
288
+ class _Account(_Resource):
289
+ def _need_token(self) -> None:
290
+ if not self._c.access_token:
291
+ raise KlyuchoraError("this call needs an access token: create one in the cabinet (Profile, Security) "
292
+ "and pass access_token=... or set KLYUCHORA_ACCESS_TOKEN")
293
+
294
+ def profile(self) -> Obj:
295
+ """Account data (GET /api/user/self)."""
296
+ self._need_token()
297
+ raw = self._c._manage_json("GET", "/api/user/self")
298
+ return wrap(raw.get("data", {}))
299
+
300
+ def balance(self) -> Obj:
301
+ """Cabinet balance in USD and, if the rate is available, in rubles."""
302
+ self._need_token()
303
+ data = self._c._manage_json("GET", "/api/user/self").get("data", {})
304
+ per = self._c._quota_per_unit()
305
+ balance = quota_to_usd(data.get("quota"), per)
306
+ out = {
307
+ "balance_usd": balance,
308
+ "spent_usd": quota_to_usd(data.get("used_quota"), per),
309
+ "requests": data.get("request_count"),
310
+ "group": data.get("group"),
311
+ "username": data.get("username"),
312
+ }
313
+ try:
314
+ rate = self._c.rates.usd_rub()
315
+ out["rub_per_usd"] = rate["rub_per_usd"]
316
+ out["rate_date"] = rate["date"]
317
+ out["balance_rub_approx"] = round(balance * rate["rub_per_usd"], 2) if balance is not None else None
318
+ except KlyuchoraError:
319
+ pass
320
+ return wrap(out)
321
+
322
+ def keys(self, *, page: int = 1, page_size: int = 100) -> Obj:
323
+ """Your API keys with their limits (keys are masked). Returns ``items`` and ``total``."""
324
+ self._need_token()
325
+ data = self._c._manage_json("GET", "/api/token/", query={"p": page, "page_size": page_size}).get("data", {})
326
+ per = self._c._quota_per_unit()
327
+ items = []
328
+ for it in data.get("items") or []:
329
+ unlimited = bool(it.get("unlimited_quota"))
330
+ limits = it.get("model_limits") or ""
331
+ if isinstance(limits, str):
332
+ limits = [x for x in limits.split(",") if x]
333
+ allow_ips = it.get("allow_ips") or ""
334
+ ips = [x for x in str(allow_ips).replace("\n", ",").split(",") if x.strip()]
335
+ items.append({
336
+ "id": it.get("id"), "name": it.get("name"), "key": it.get("key"),
337
+ "status": {1: "enabled", 2: "disabled", 3: "expired", 4: "exhausted"}.get(it.get("status"), it.get("status")),
338
+ "unlimited": unlimited,
339
+ "remaining_usd": None if unlimited else quota_to_usd(it.get("remain_quota"), per),
340
+ "used_usd": quota_to_usd(it.get("used_quota"), per),
341
+ "model_limits_enabled": bool(it.get("model_limits_enabled")),
342
+ "model_limits": limits,
343
+ "allow_ips": ips,
344
+ "group": it.get("group"),
345
+ "expires_at": 0 if it.get("expired_time") in (None, -1) else it.get("expired_time"),
346
+ "created_at": it.get("created_time"),
347
+ "last_used_at": it.get("accessed_time"),
348
+ })
349
+ result = KeyList(wrap(items))
350
+ result.total = data.get("total", len(items))
351
+ return result
352
+
353
+
354
+ class _Rates(_Resource):
355
+ def usd_rub(self) -> Obj:
356
+ """Bank of Russia rate used for top-ups: ``rub_per_usd`` and ``date`` (GET /api/access/usd-rate)."""
357
+ raw = self._c._public_json("/api/access/usd-rate")
358
+ data = raw.get("data", {})
359
+ return wrap({"rub_per_usd": float(data["rub_per_usd"]), "date": data.get("date"), "source": data.get("source")})
360
+
361
+
362
+ class Klyuchora:
363
+ """Client for the Klyuchora gateway.
364
+
365
+ ``api_key`` (``sk-...``) is needed for model calls, ``/models`` and ``key.usage()``.
366
+ ``access_token`` is needed only for the ``account`` methods (balance, key list).
367
+ Both can come from ``KLYUCHORA_API_KEY`` and ``KLYUCHORA_ACCESS_TOKEN``.
368
+ """
369
+
370
+ def __init__(self, api_key: Optional[str] = None, *, access_token: Optional[str] = None,
371
+ base_url: Optional[str] = None, timeout: float = 600.0, max_retries: int = 2,
372
+ default_headers: Optional[Dict[str, str]] = None):
373
+ self.api_key = _clean_secret(api_key or env("KLYUCHORA_API_KEY"), "api_key")
374
+ self.access_token = _clean_secret(access_token or env("KLYUCHORA_ACCESS_TOKEN"), "access_token")
375
+ root = (base_url or env("KLYUCHORA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
376
+ if root.endswith("/v1"):
377
+ root = root[:-3]
378
+ self.base_url = root
379
+ self._http = Transport(base_url=root, timeout=timeout, max_retries=max_retries, default_headers=default_headers)
380
+ self._catalog_cache: Optional[Dict[str, Any]] = None
381
+ self._per_unit: Optional[float] = None
382
+ self.chat = _Chat(self)
383
+ self.completions = _Completions(self)
384
+ self.responses = _Responses(self)
385
+ self.messages = _Messages(self)
386
+ self.embeddings = _Embeddings(self)
387
+ self.images = _Images(self)
388
+ self.audio = _Audio(self)
389
+ self.rerank = _Rerank(self)
390
+ self.moderations = _Moderations(self)
391
+ self.models = _Models(self)
392
+ self.key = _Key(self)
393
+ self.account = _Account(self)
394
+ self.rates = _Rates(self)
395
+
396
+ def with_options(self, **kw: Any) -> "Klyuchora":
397
+ """Copy of this client with some settings changed (``timeout``, ``max_retries``, ...)."""
398
+ base = dict(api_key=self.api_key, access_token=self.access_token, base_url=self.base_url,
399
+ timeout=self._http.timeout, max_retries=self._http.max_retries,
400
+ default_headers=self._http.default_headers)
401
+ base.update(kw)
402
+ return Klyuchora(**base)
403
+
404
+ # --- plumbing -----------------------------------------------------------------
405
+ def _relay(self, method: str, path: str, *, json_body: Any = None, data: Optional[bytes] = None,
406
+ stream: bool = False, timeout: Optional[float] = None, headers: Optional[Dict[str, str]] = None):
407
+ if not self.api_key:
408
+ raise AuthenticationError("no API key: pass api_key=... or set KLYUCHORA_API_KEY", status=401, code="no_api_key")
409
+ hdrs = {"Authorization": "Bearer " + self.api_key}
410
+ hdrs.update(headers or {})
411
+ return self._http.request(method, path, headers=hdrs, json_body=json_body, data=data, stream=stream, timeout=timeout)
412
+
413
+ def _relay_json(self, method: str, path: str, body: Any, *, timeout: Optional[float] = None,
414
+ headers: Optional[Dict[str, str]] = None) -> Obj:
415
+ resp = self._relay(method, path, json_body=body, timeout=timeout, headers=headers)
416
+ return wrap(resp.json())
417
+
418
+ def _manage_json(self, method: str, path: str, *, query: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
419
+ if not self.access_token:
420
+ raise AuthenticationError("no access token: pass access_token=... or set KLYUCHORA_ACCESS_TOKEN",
421
+ status=401, code="no_access_token")
422
+ resp = self._http.request(method, path, headers={"Authorization": "Bearer " + self.access_token}, query=query)
423
+ raw = resp.json()
424
+ if isinstance(raw, dict) and raw.get("success") is False:
425
+ raise APIError(str(raw.get("message") or "request failed"), status=resp.status, code=raw.get("code"), body=wrap(raw))
426
+ return raw
427
+
428
+ def _public_json(self, path: str) -> Dict[str, Any]:
429
+ return self._http.request("GET", path).json()
430
+
431
+ def _catalog(self, refresh: bool = False) -> Dict[str, Any]:
432
+ if self._catalog_cache is None or refresh:
433
+ self._catalog_cache = self._public_json("/api/pricing")
434
+ return self._catalog_cache
435
+
436
+ def _quota_per_unit(self) -> float:
437
+ if self._per_unit is None:
438
+ try:
439
+ data = self._public_json("/api/status").get("data", {})
440
+ self._per_unit = float(data.get("quota_per_unit") or QUOTA_PER_USD)
441
+ except (KlyuchoraError, ValueError, TypeError):
442
+ self._per_unit = float(QUOTA_PER_USD)
443
+ return self._per_unit
444
+
445
+ # --- helpers --------------------------------------------------------------------
446
+ def estimate_cost(self, model: str, *, input_tokens: int = 0, output_tokens: int = 0,
447
+ cached_tokens: int = 0, requests: int = 1) -> Obj:
448
+ """Estimate the price of a call from the public catalog.
449
+
450
+ The gateway bills by the group that actually serves the request, so the answer is
451
+ a range: ``min_usd`` for the cheapest group and ``max_usd`` for the most expensive one.
452
+ With the rate available ``min_rub`` / ``max_rub`` are added (Bank of Russia rate).
453
+ """
454
+ item = self.models.price(model)
455
+ result = estimate(item["prices"], input_tokens, output_tokens, cached_tokens, requests)
456
+ try:
457
+ rate = self.rates.usd_rub()["rub_per_usd"]
458
+ result["min_rub"] = round(result["min_usd"] * rate, 4)
459
+ result["max_rub"] = round(result["max_usd"] * rate, 4)
460
+ result["rub_per_usd"] = rate
461
+ except KlyuchoraError:
462
+ pass
463
+ return wrap(result)
464
+
465
+ def request(self, method: str, path: str, *, json: Any = None, stream: bool = False,
466
+ timeout: Optional[float] = None) -> Any:
467
+ """Call any gateway route that has no helper yet. Returns the decoded JSON, or a :class:`Stream`."""
468
+ resp = self._relay(method.upper(), path, json_body=json, stream=stream, timeout=timeout)
469
+ if stream:
470
+ return Stream(resp)
471
+ return wrap(resp.json())