python-open-router 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_open_router
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Asynchronous Python client for Open Router.
5
5
  License: MIT
6
6
  License-File: LICENSE.md
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python_open_router"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Asynchronous Python client for Open Router."
5
5
  authors = ["Joost Lekkerkerker <joostlek@outlook.com>"]
6
6
  maintainers = ["Joost Lekkerkerker <joostlek@outlook.com>"]
@@ -32,21 +32,21 @@ mashumaro = "^3.11"
32
32
  orjson = ">=3.9.0"
33
33
 
34
34
  [tool.poetry.group.dev.dependencies]
35
- codespell = "2.4.1"
35
+ codespell = "2.4.2"
36
36
  covdefaults = "2.3.0"
37
- coverage = {version = "7.11.0", extras = ["toml"]}
38
- mypy = "1.18.2"
39
- pre-commit = "4.3.0"
37
+ coverage = {version = "7.15.0", extras = ["toml"]}
38
+ mypy = "2.2.0"
39
+ pre-commit = "4.6.0"
40
40
  pre-commit-hooks = "6.0.0"
41
- pylint = "4.0.2"
42
- pytest = "8.4.2"
43
- pytest-asyncio = "1.2.0"
44
- pytest-cov = "7.0.0"
45
- ruff = "0.14.3"
46
- safety = "3.6.2"
47
- yamllint = "1.37.1"
48
- syrupy = "5.0.0"
49
- aioresponses = "0.7.8"
41
+ pylint = "4.0.6"
42
+ pytest = "9.1.1"
43
+ pytest-asyncio = "1.4.0"
44
+ pytest-cov = "7.1.0"
45
+ ruff = "0.15.21"
46
+ safety = "3.8.1"
47
+ yamllint = "1.38.0"
48
+ syrupy = "5.5.2"
49
+ aioresponses = "0.7.9"
50
50
 
51
51
  [tool.poetry.urls]
52
52
  "Bug Tracker" = "https://github.com/joostlek/python-open-router/issues"
@@ -141,6 +141,8 @@ ignore = [
141
141
  "D213", # Conflicts with other rules
142
142
  "D417", # False positives in some occasions
143
143
  "ISC001", # Conflicts with other rules
144
+ "PLR0912", # Too many branches - legitimate for API client methods
145
+ "PLR0913", # Too many arguments - legitimate for API client methods
144
146
  "PLR2004", # Just annoying, not really useful
145
147
  ]
146
148
  select = ["ALL"]
@@ -6,34 +6,48 @@ from python_open_router.exceptions import (
6
6
  OpenRouterError,
7
7
  )
8
8
  from python_open_router.models import (
9
- CreatedKey,
10
- CreateKeyDataWrapper,
9
+ DefaultParameters,
10
+ DeletedKeyResponse,
11
11
  Key,
12
+ KeyCreateResponse,
12
13
  KeyData,
13
14
  KeyDataWrapper,
15
+ KeyResponseWrapper,
14
16
  KeysDataWrapper,
15
17
  Modality,
16
18
  Model,
17
19
  ModelArchitecture,
20
+ ModelLinks,
21
+ ModelResponseWrapper,
18
22
  ModelsDataWrapper,
23
+ PerRequestLimits,
24
+ PublicPricing,
19
25
  SupportedParameter,
26
+ TopProviderInfo,
20
27
  )
21
28
  from python_open_router.open_router import OpenRouterClient
22
29
 
23
30
  __all__ = [
24
- "CreateKeyDataWrapper",
25
- "CreatedKey",
31
+ "DefaultParameters",
32
+ "DeletedKeyResponse",
26
33
  "Key",
34
+ "KeyCreateResponse",
27
35
  "KeyData",
28
36
  "KeyDataWrapper",
37
+ "KeyResponseWrapper",
29
38
  "KeysDataWrapper",
30
39
  "Modality",
31
40
  "Model",
32
41
  "ModelArchitecture",
42
+ "ModelLinks",
43
+ "ModelResponseWrapper",
33
44
  "ModelsDataWrapper",
34
45
  "OpenRouterAuthenticationError",
35
46
  "OpenRouterClient",
36
47
  "OpenRouterConnectionError",
37
48
  "OpenRouterError",
49
+ "PerRequestLimits",
50
+ "PublicPricing",
38
51
  "SupportedParameter",
52
+ "TopProviderInfo",
39
53
  ]
@@ -0,0 +1,271 @@
1
+ """Models for OpenRouter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ import logging
8
+ from typing import Any
9
+
10
+ from mashumaro.mixins.orjson import DataClassORJSONMixin
11
+
12
+ _LOGGER = logging.getLogger(__package__)
13
+
14
+
15
+ @dataclass
16
+ class KeyDataWrapper(DataClassORJSONMixin):
17
+ """Wrapper for OpenRouter key data."""
18
+
19
+ data: KeyData
20
+
21
+
22
+ @dataclass
23
+ class KeyData(DataClassORJSONMixin):
24
+ """The OpenRouter key data."""
25
+
26
+ label: str
27
+ usage: float
28
+ is_provisioning_key: bool
29
+ limit_remaining: float | None
30
+ is_free_tier: bool
31
+ limit: float | None = None
32
+ limit_reset: str | None = None
33
+ usage_daily: float = 0.0
34
+ usage_weekly: float = 0.0
35
+ usage_monthly: float = 0.0
36
+ byok_usage: float = 0.0
37
+ byok_usage_daily: float = 0.0
38
+ byok_usage_weekly: float = 0.0
39
+ byok_usage_monthly: float = 0.0
40
+ include_byok_in_limit: bool = False
41
+ is_management_key: bool = False
42
+ creator_user_id: str | None = None
43
+ expires_at: str | None = None
44
+
45
+
46
+ @dataclass
47
+ class KeysDataWrapper(DataClassORJSONMixin):
48
+ """Wrapper for OpenRouter key data."""
49
+
50
+ data: list[Key]
51
+
52
+
53
+ @dataclass
54
+ class KeyCreateResponse(DataClassORJSONMixin):
55
+ """Response from creating a key."""
56
+
57
+ data: Key
58
+ key: str
59
+
60
+
61
+ @dataclass
62
+ class Key(DataClassORJSONMixin):
63
+ """The OpenRouter key data."""
64
+
65
+ hash: str
66
+ name: str
67
+ label: str
68
+ disabled: bool
69
+ limit: float | None = None
70
+ usage: float = 0.0
71
+ created_at: str = ""
72
+ updated_at: str | None = None
73
+ limit_remaining: float | None = None
74
+ limit_reset: str | None = None
75
+ workspace_id: str = ""
76
+ expires_at: str | None = None
77
+ usage_daily: float = 0.0
78
+ usage_weekly: float = 0.0
79
+ usage_monthly: float = 0.0
80
+ byok_usage: float = 0.0
81
+ byok_usage_daily: float = 0.0
82
+ byok_usage_weekly: float = 0.0
83
+ byok_usage_monthly: float = 0.0
84
+ include_byok_in_limit: bool = False
85
+ creator_user_id: str | None = None
86
+
87
+
88
+ @dataclass
89
+ class KeyResponseWrapper(DataClassORJSONMixin):
90
+ """Wrapper for single key response."""
91
+
92
+ data: Key
93
+
94
+
95
+ @dataclass
96
+ class DeletedKeyResponse(DataClassORJSONMixin):
97
+ """Response after deleting a key."""
98
+
99
+ deleted: bool
100
+
101
+
102
+ @dataclass
103
+ class ModelResponseWrapper(DataClassORJSONMixin):
104
+ """Wrapper for single OpenRouter model response."""
105
+
106
+ data: Model
107
+
108
+
109
+ @dataclass
110
+ class ModelsDataWrapper(DataClassORJSONMixin):
111
+ """Wrapper for OpenRouter model data."""
112
+
113
+ data: list[Model]
114
+
115
+
116
+ class SupportedParameter(StrEnum):
117
+ """Supported parameters for models."""
118
+
119
+ TEMPERATURE = "temperature"
120
+ TOP_P = "top_p"
121
+ TOP_K = "top_k"
122
+ MAX_TOKENS = "max_tokens"
123
+ MAX_COMPLETION_TOKENS = "max_completion_tokens"
124
+ REASONING = "reasoning"
125
+ REASONING_EFFORT = "reasoning_effort"
126
+ INCLUDE_REASONING = "include_reasoning"
127
+ STOP = "stop"
128
+ SEED = "seed"
129
+ RESPONSE_FORMAT = "response_format"
130
+ STRUCTURED_OUTPUTS = "structured_outputs"
131
+ TOOLS = "tools"
132
+ TOOL_CHOICE = "tool_choice"
133
+ PARALLEL_TOOL_CALLS = "parallel_tool_calls"
134
+ FREQUENCY_PENALTY = "frequency_penalty"
135
+ PRESENCE_PENALTY = "presence_penalty"
136
+ REPETITION_PENALTY = "repetition_penalty"
137
+ MIN_P = "min_p"
138
+ TOP_A = "top_a"
139
+ LOGPROBS = "logprobs"
140
+ LOGIT_BIAS = "logit_bias"
141
+ TOP_LOGPROBS = "top_logprobs"
142
+ WEB_SEARCH_OPTIONS = "web_search_options"
143
+ VERBOSITY = "verbosity"
144
+
145
+
146
+ class Modality(StrEnum):
147
+ """Supported modalities for models."""
148
+
149
+ AUDIO = "audio"
150
+ TEXT = "text"
151
+ IMAGE = "image"
152
+ FILE = "file"
153
+ EMBEDDINGS = "embeddings"
154
+ VIDEO = "video"
155
+ RERANK = "rerank"
156
+ SPEECH = "speech"
157
+ TRANSCRIPTION = "transcription"
158
+
159
+
160
+ @dataclass(kw_only=True)
161
+ class ModelArchitecture(DataClassORJSONMixin):
162
+ """Model architecture data."""
163
+
164
+ input_modalities: list[Modality]
165
+ output_modalities: list[Modality]
166
+ modality: str | None = None
167
+ instruct_type: str | None = None
168
+ tokenizer: str | None = None
169
+
170
+
171
+ @dataclass(kw_only=True)
172
+ class PublicPricing(DataClassORJSONMixin):
173
+ """Model pricing data."""
174
+
175
+ completion: str
176
+ prompt: str
177
+ audio: str | None = None
178
+ audio_output: str | None = None
179
+ image: str | None = None
180
+ image_output: str | None = None
181
+ image_token: str | None = None
182
+ input_audio_cache: str | None = None
183
+ input_cache_read: str | None = None
184
+ input_cache_write: str | None = None
185
+ input_cache_write_1h: str | None = None
186
+ internal_reasoning: str | None = None
187
+ request: str | None = None
188
+ web_search: str | None = None
189
+ discount: float | None = None
190
+
191
+
192
+ @dataclass(kw_only=True)
193
+ class TopProviderInfo(DataClassORJSONMixin):
194
+ """Top provider information."""
195
+
196
+ is_moderated: bool
197
+ context_length: int | None = None
198
+ max_completion_tokens: int | None = None
199
+
200
+
201
+ @dataclass(kw_only=True)
202
+ class DefaultParameters(DataClassORJSONMixin):
203
+ """Default model parameters."""
204
+
205
+ temperature: float | None = None
206
+ top_p: float | None = None
207
+ top_k: int | None = None
208
+ frequency_penalty: float | None = None
209
+ presence_penalty: float | None = None
210
+ repetition_penalty: float | None = None
211
+
212
+
213
+ @dataclass(kw_only=True)
214
+ class PerRequestLimits(DataClassORJSONMixin):
215
+ """Per-request limits."""
216
+
217
+ completion_tokens: float
218
+ prompt_tokens: float
219
+
220
+
221
+ @dataclass(kw_only=True)
222
+ class ModelLinks(DataClassORJSONMixin):
223
+ """Model links."""
224
+
225
+ details: str
226
+
227
+
228
+ @dataclass(kw_only=True)
229
+ class Model(DataClassORJSONMixin):
230
+ """Model data."""
231
+
232
+ id: str
233
+ canonical_slug: str
234
+ name: str
235
+ context_length: int | None
236
+ architecture: ModelArchitecture
237
+ supported_parameters: list[SupportedParameter]
238
+ pricing: PublicPricing
239
+ top_provider: TopProviderInfo
240
+ hugging_face_id: str | None = None
241
+ description: str | None = None
242
+ created: int | None = None
243
+ default_parameters: DefaultParameters | None = None
244
+ links: ModelLinks | None = None
245
+ per_request_limits: PerRequestLimits | None = None
246
+ supported_voices: list[str] | None = None
247
+ expiration_date: str | None = None
248
+ knowledge_cutoff: str | None = None
249
+
250
+ @classmethod
251
+ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:
252
+ """Pre deserialize hook."""
253
+ parameters = d.get("supported_parameters", [])
254
+ supported = set(SupportedParameter)
255
+ d["supported_parameters"] = [p for p in parameters if p in supported]
256
+ for parameter in parameters:
257
+ if parameter not in supported:
258
+ _LOGGER.warning(
259
+ "Unsupported parameter: %s. Please report at https://github.com/joostlek/python-open-router/issues.",
260
+ parameter,
261
+ )
262
+ return d
263
+
264
+ @classmethod
265
+ def __post_deserialize__(cls, obj: Model) -> Model:
266
+ """Post deserialize hook."""
267
+ if obj.hugging_face_id == "":
268
+ obj.hugging_face_id = None
269
+ if obj.description == "":
270
+ obj.description = None
271
+ return obj
@@ -0,0 +1,290 @@
1
+ """Asynchronous Python client for Open Router."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from importlib import metadata
8
+ import socket
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from aiohttp import ClientError, ClientResponseError, ClientSession
12
+ from aiohttp.hdrs import METH_DELETE, METH_GET, METH_PATCH, METH_POST
13
+ import orjson
14
+ from yarl import URL
15
+
16
+ from python_open_router.exceptions import (
17
+ OpenRouterAuthenticationError,
18
+ OpenRouterConnectionError,
19
+ )
20
+ from python_open_router.models import (
21
+ DeletedKeyResponse,
22
+ Key,
23
+ KeyCreateResponse,
24
+ KeyData,
25
+ KeyDataWrapper,
26
+ KeyResponseWrapper,
27
+ KeysDataWrapper,
28
+ Model,
29
+ ModelResponseWrapper,
30
+ ModelsDataWrapper,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from typing_extensions import Self
35
+
36
+
37
+ VERSION = metadata.version(__package__)
38
+ HOST = "openrouter.ai"
39
+
40
+
41
+ @dataclass
42
+ class OpenRouterClient:
43
+ """Main class for handling connections with OpenRouter."""
44
+
45
+ api_key: str
46
+ session: ClientSession | None = None
47
+ request_timeout: int = 10
48
+ _close_session: bool = False
49
+
50
+ async def _request(
51
+ self,
52
+ method: str,
53
+ uri: str,
54
+ *,
55
+ params: dict[str, Any] | None = None,
56
+ data: dict[str, Any] | None = None,
57
+ ) -> str:
58
+ """Handle a request to OpenRouter."""
59
+ url = URL.build(host=HOST, scheme="https").joinpath(f"api/v1/{uri}")
60
+
61
+ headers = {
62
+ "User-Agent": f"PythonOpenRouter/{VERSION}",
63
+ "Accept": "application/json",
64
+ "Authorization": f"Bearer {self.api_key}",
65
+ }
66
+
67
+ if self.session is None:
68
+ self.session = ClientSession()
69
+ self._close_session = True
70
+
71
+ try:
72
+ async with asyncio.timeout(self.request_timeout):
73
+ response = await self.session.request(
74
+ method,
75
+ url,
76
+ params=params,
77
+ headers=headers,
78
+ json=data,
79
+ )
80
+ except asyncio.TimeoutError as exception:
81
+ msg = "Timeout occurred while connecting to the service"
82
+ raise OpenRouterConnectionError(msg) from exception
83
+ except (
84
+ ClientError,
85
+ ClientResponseError,
86
+ socket.gaierror,
87
+ ) as exception:
88
+ msg = "Error occurred while communicating with the service"
89
+ raise OpenRouterConnectionError(msg) from exception
90
+
91
+ if response.status == 401:
92
+ msg = "Authentication failed"
93
+ raise OpenRouterAuthenticationError(msg)
94
+
95
+ if response.status >= 400:
96
+ content_type = response.headers.get("Content-Type", "")
97
+ text = await response.text()
98
+ msg = "Unexpected response from OpenRouter"
99
+ raise OpenRouterConnectionError(
100
+ msg,
101
+ {"Content-Type": content_type, "response": text},
102
+ )
103
+
104
+ return await response.text()
105
+
106
+ async def get_key_data(self) -> KeyData:
107
+ """Get key data for API key."""
108
+ response = await self._request(METH_GET, "key")
109
+ return KeyDataWrapper.from_json(response).data
110
+
111
+ async def get_keys(
112
+ self,
113
+ *,
114
+ include_disabled: bool | None = None,
115
+ offset: int | None = None,
116
+ workspace_id: str | None = None,
117
+ ) -> list[Key]:
118
+ """Get all keys."""
119
+ params: dict[str, Any] = {}
120
+ if include_disabled is not None:
121
+ params["include_disabled"] = str(include_disabled).lower()
122
+ if offset is not None:
123
+ params["offset"] = str(offset)
124
+ if workspace_id is not None:
125
+ params["workspace_id"] = workspace_id
126
+ response = await self._request(METH_GET, "keys", params=params or None)
127
+ return KeysDataWrapper.from_json(response).data
128
+
129
+ async def create_key(
130
+ self,
131
+ name: str,
132
+ limit: float | None = None,
133
+ *,
134
+ limit_reset: str | None = None,
135
+ workspace_id: str | None = None,
136
+ expires_at: str | None = None,
137
+ include_byok_in_limit: bool | None = None,
138
+ ) -> Key:
139
+ """Create a new key."""
140
+ data: dict[str, Any] = {"name": name}
141
+ if limit is not None:
142
+ data["limit"] = limit
143
+ if limit_reset is not None:
144
+ data["limit_reset"] = limit_reset
145
+ if workspace_id is not None:
146
+ data["workspace_id"] = workspace_id
147
+ if expires_at is not None:
148
+ data["expires_at"] = expires_at
149
+ if include_byok_in_limit is not None:
150
+ data["include_byok_in_limit"] = include_byok_in_limit
151
+
152
+ response = await self._request(METH_POST, "keys", data=data)
153
+ return KeyCreateResponse.from_json(response).data
154
+
155
+ async def get_key(self, key_hash: str) -> Key:
156
+ """Get a single key by hash."""
157
+ response = await self._request(METH_GET, f"keys/{key_hash}")
158
+ return KeyResponseWrapper.from_json(response).data
159
+
160
+ async def update_key(
161
+ self,
162
+ key_hash: str,
163
+ *,
164
+ name: str | None = None,
165
+ disabled: bool | None = None,
166
+ limit: float | None = None,
167
+ limit_reset: str | None = None,
168
+ include_byok_in_limit: bool | None = None,
169
+ ) -> Key:
170
+ """Update an existing key."""
171
+ data: dict[str, Any] = {}
172
+ if name is not None:
173
+ data["name"] = name
174
+ if disabled is not None:
175
+ data["disabled"] = disabled
176
+ if limit is not None:
177
+ data["limit"] = limit
178
+ if limit_reset is not None:
179
+ data["limit_reset"] = limit_reset
180
+ if include_byok_in_limit is not None:
181
+ data["include_byok_in_limit"] = include_byok_in_limit
182
+
183
+ response = await self._request(METH_PATCH, f"keys/{key_hash}", data=data)
184
+ return KeyResponseWrapper.from_json(response).data
185
+
186
+ async def delete_key(self, key_hash: str) -> bool:
187
+ """Delete a key by hash."""
188
+ response = await self._request(METH_DELETE, f"keys/{key_hash}")
189
+ return DeletedKeyResponse.from_json(response).deleted
190
+
191
+ async def get_model(self, author: str, slug: str) -> Model:
192
+ """Get a single model by author and slug."""
193
+ response = await self._request(METH_GET, f"model/{author}/{slug}")
194
+ return ModelResponseWrapper.from_json(response).data
195
+
196
+ @staticmethod
197
+ def _filter_model_params(
198
+ kwargs: dict[str, Any],
199
+ *,
200
+ numeric_keys: frozenset[str] = frozenset(),
201
+ ) -> dict[str, Any]:
202
+ """Filter None values and convert numeric params to strings."""
203
+ params = {k: v for k, v in kwargs.items() if v is not None}
204
+ for key in numeric_keys:
205
+ if key in params:
206
+ params[key] = str(params[key])
207
+ return params
208
+
209
+ async def get_models( # pylint: disable=too-many-locals
210
+ self,
211
+ *,
212
+ category: str | None = None,
213
+ supported_parameters: str | None = None,
214
+ output_modalities: str | None = None,
215
+ sort: str | None = None,
216
+ q: str | None = None,
217
+ input_modalities: str | None = None,
218
+ context: int | None = None,
219
+ min_price: float | None = None,
220
+ max_price: float | None = None,
221
+ arch: str | None = None,
222
+ model_authors: str | None = None,
223
+ providers: str | None = None,
224
+ distillable: str | None = None,
225
+ zdr: str | None = None,
226
+ region: str | None = None,
227
+ ) -> list[Model]:
228
+ """Get all available models."""
229
+ numeric = frozenset({"context", "min_price", "max_price"})
230
+ params = self._filter_model_params(
231
+ {
232
+ "category": category,
233
+ "supported_parameters": supported_parameters,
234
+ "output_modalities": output_modalities,
235
+ "sort": sort,
236
+ "q": q,
237
+ "input_modalities": input_modalities,
238
+ "context": context,
239
+ "min_price": min_price,
240
+ "max_price": max_price,
241
+ "arch": arch,
242
+ "model_authors": model_authors,
243
+ "providers": providers,
244
+ "distillable": distillable,
245
+ "zdr": zdr,
246
+ "region": region,
247
+ },
248
+ numeric_keys=numeric,
249
+ )
250
+ response = await self._request(METH_GET, "models", params=params or None)
251
+ return ModelsDataWrapper.from_json(response).data
252
+
253
+ async def count_models(
254
+ self,
255
+ *,
256
+ output_modalities: str | None = None,
257
+ ) -> int:
258
+ """Get total count of available models."""
259
+ params: dict[str, Any] = {}
260
+ if output_modalities is not None:
261
+ params["output_modalities"] = output_modalities
262
+ response = await self._request(METH_GET, "models/count", params=params or None)
263
+ result: dict[str, Any] = orjson.loads(response) # pylint: disable=no-member
264
+ count: int = result.get("data", {}).get("count", 0)
265
+ return count
266
+
267
+ async def close(self) -> None:
268
+ """Close open client session."""
269
+ if self.session and self._close_session:
270
+ await self.session.close()
271
+
272
+ async def __aenter__(self) -> Self:
273
+ """Async enter.
274
+
275
+ Returns
276
+ -------
277
+ The OpenRouterClient object.
278
+
279
+ """
280
+ return self
281
+
282
+ async def __aexit__(self, *_exc_info: object) -> None:
283
+ """Async exit.
284
+
285
+ Args:
286
+ ----
287
+ _exc_info: Exec type.
288
+
289
+ """
290
+ await self.close()
@@ -1,148 +0,0 @@
1
- """Models for OpenRouter."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from enum import StrEnum
7
- import logging
8
- from typing import Any
9
-
10
- from mashumaro.mixins.orjson import DataClassORJSONMixin
11
-
12
- _LOGGER = logging.getLogger(__package__)
13
-
14
-
15
- @dataclass
16
- class KeyDataWrapper(DataClassORJSONMixin):
17
- """Wrapper for OpenRouter key data."""
18
-
19
- data: KeyData
20
-
21
-
22
- @dataclass
23
- class KeyData(DataClassORJSONMixin):
24
- """The OpenRouter key data."""
25
-
26
- label: str
27
- usage: int
28
- is_provisioning_key: bool
29
- limit_remaining: int | None
30
- is_free_tier: bool
31
-
32
-
33
- @dataclass
34
- class KeysDataWrapper(DataClassORJSONMixin):
35
- """Wrapper for OpenRouter key data."""
36
-
37
- data: list[Key]
38
-
39
-
40
- @dataclass
41
- class CreateKeyDataWrapper(DataClassORJSONMixin):
42
- """Wrapper for OpenRouter key data."""
43
-
44
- data: CreatedKey
45
-
46
-
47
- @dataclass
48
- class Key(DataClassORJSONMixin):
49
- """The OpenRouter key data."""
50
-
51
- hash: str
52
- name: str
53
- label: str
54
- disabled: bool
55
- limit: float
56
- usage: float = 0.0
57
-
58
-
59
- @dataclass(kw_only=True)
60
- class CreatedKey(Key):
61
- """Created key."""
62
-
63
- key: str
64
-
65
-
66
- @dataclass
67
- class ModelsDataWrapper(DataClassORJSONMixin):
68
- """Wrapper for OpenRouter model data."""
69
-
70
- data: list[Model]
71
-
72
-
73
- class SupportedParameter(StrEnum):
74
- """Supported parameters for models."""
75
-
76
- TEMPERATURE = "temperature"
77
- TOP_P = "top_p"
78
- MAX_TOKENS = "max_tokens"
79
- REASONING = "reasoning"
80
- INCLUDE_REASONING = "include_reasoning"
81
- STOP = "stop"
82
- TOK_K = "top_k"
83
- SEED = "seed"
84
- RESPONSE_FORMAT = "response_format"
85
- TOOLS = "tools"
86
- TOOL_CHOICE = "tool_choice"
87
- FREQUENCY_PENALTY = "frequency_penalty"
88
- PRESENCE_PENALTY = "presence_penalty"
89
- MIN_P = "min_p"
90
- REPETITION_PENALTY = "repetition_penalty"
91
- LOGPROBS = "logprobs"
92
- LOGIT_BIAS = "logit_bias"
93
- TOP_LOGPROBS = "top_logprobs"
94
- STRUCTURED_OUTPUTS = "structured_outputs"
95
- WEB_SEARCH_OPTIONS = "web_search_options"
96
- TOP_A = "top_a"
97
-
98
-
99
- class Modality(StrEnum):
100
- """Supported modalities for models."""
101
-
102
- AUDIO = "audio"
103
- TEXT = "text"
104
- IMAGE = "image"
105
- FILE = "file"
106
- EMBEDDINGS = "embeddings"
107
-
108
-
109
- @dataclass(kw_only=True)
110
- class ModelArchitecture(DataClassORJSONMixin):
111
- """Model data."""
112
-
113
- input_modalities: list[Modality]
114
- output_modalities: list[Modality]
115
-
116
-
117
- @dataclass(kw_only=True)
118
- class Model(DataClassORJSONMixin):
119
- """Model data."""
120
-
121
- id: str
122
- canonical_slug: str
123
- hugging_face_id: str | None
124
- name: str
125
- description: str
126
- context_length: int
127
- architecture: ModelArchitecture
128
- supported_parameters: list[SupportedParameter]
129
-
130
- @classmethod
131
- def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:
132
- """Pre deserialize hook."""
133
- parameters = d.get("supported_parameters", [])
134
- for parameter in parameters:
135
- if parameter not in SupportedParameter:
136
- _LOGGER.warning(
137
- "Unsupported parameter: %s. Please report at https://github.com/joostlek/python-open-router/issues.",
138
- parameter,
139
- )
140
- parameters.remove(parameter)
141
- return d
142
-
143
- @classmethod
144
- def __post_deserialize__(cls, obj: Model) -> Model:
145
- """Post deserialize hook."""
146
- if obj.hugging_face_id == "":
147
- obj.hugging_face_id = None
148
- return obj
@@ -1,142 +0,0 @@
1
- """Asynchronous Python client for Open Router."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- from dataclasses import dataclass
7
- from importlib import metadata
8
- import socket
9
- from typing import TYPE_CHECKING, Any
10
-
11
- from aiohttp import ClientError, ClientResponseError, ClientSession
12
- from aiohttp.hdrs import METH_GET, METH_POST
13
- from yarl import URL
14
-
15
- from python_open_router.exceptions import OpenRouterConnectionError
16
- from python_open_router.models import (
17
- CreateKeyDataWrapper,
18
- Key,
19
- KeyData,
20
- KeyDataWrapper,
21
- KeysDataWrapper,
22
- Model,
23
- ModelsDataWrapper,
24
- )
25
-
26
- if TYPE_CHECKING:
27
- from typing_extensions import Self
28
-
29
-
30
- VERSION = metadata.version(__package__)
31
- HOST = "openrouter.ai"
32
-
33
-
34
- @dataclass
35
- class OpenRouterClient:
36
- """Main class for handling connections with OpenRouter."""
37
-
38
- api_key: str
39
- session: ClientSession | None = None
40
- request_timeout: int = 10
41
- _close_session: bool = False
42
-
43
- async def _request(
44
- self,
45
- method: str,
46
- uri: str,
47
- *,
48
- params: dict[str, Any] | None = None,
49
- data: dict[str, Any] | None = None,
50
- ) -> str:
51
- """Handle a request to OpenRouter."""
52
- url = URL.build(host=HOST, scheme="https").joinpath(f"api/v1/{uri}")
53
-
54
- headers = {
55
- "User-Agent": f"PythonOpenRouter/{VERSION}",
56
- "Accept": "application/json",
57
- "Authorization": f"Bearer {self.api_key}",
58
- }
59
-
60
- if self.session is None:
61
- self.session = ClientSession()
62
- self._close_session = True
63
-
64
- try:
65
- async with asyncio.timeout(self.request_timeout):
66
- response = await self.session.request(
67
- method,
68
- url,
69
- params=params,
70
- headers=headers,
71
- json=data,
72
- )
73
- except asyncio.TimeoutError as exception:
74
- msg = "Timeout occurred while connecting to the service"
75
- raise OpenRouterConnectionError(msg) from exception
76
- except (
77
- ClientError,
78
- ClientResponseError,
79
- socket.gaierror,
80
- ) as exception:
81
- msg = "Error occurred while communicating with the service"
82
- raise OpenRouterConnectionError(msg) from exception
83
-
84
- if response.status >= 400:
85
- content_type = response.headers.get("Content-Type", "")
86
- text = await response.text()
87
- msg = "Unexpected response from OpenRouter"
88
- raise OpenRouterConnectionError(
89
- msg,
90
- {"Content-Type": content_type, "response": text},
91
- )
92
-
93
- return await response.text()
94
-
95
- async def get_key_data(self) -> KeyData:
96
- """Get key data for API key."""
97
- response = await self._request(METH_GET, "key")
98
- return KeyDataWrapper.from_json(response).data
99
-
100
- async def get_keys(self) -> list[Key]:
101
- """Get all keys."""
102
- response = await self._request(METH_GET, "keys")
103
- return KeysDataWrapper.from_json(response).data
104
-
105
- async def create_key(self, name: str, limit: float | None = None) -> Key:
106
- """Create a new key."""
107
- data: dict[str, Any] = {"name": name}
108
- if limit is not None:
109
- data["limit"] = limit
110
-
111
- response = await self._request(METH_POST, "keys", data=data)
112
- return CreateKeyDataWrapper.from_json(response).data
113
-
114
- async def get_models(self) -> list[Model]:
115
- """Get all available models."""
116
- response = await self._request(METH_GET, "models")
117
- return ModelsDataWrapper.from_json(response).data
118
-
119
- async def close(self) -> None:
120
- """Close open client session."""
121
- if self.session and self._close_session:
122
- await self.session.close()
123
-
124
- async def __aenter__(self) -> Self:
125
- """Async enter.
126
-
127
- Returns
128
- -------
129
- The OpenRouterClient object.
130
-
131
- """
132
- return self
133
-
134
- async def __aexit__(self, *_exc_info: object) -> None:
135
- """Async exit.
136
-
137
- Args:
138
- ----
139
- _exc_info: Exec type.
140
-
141
- """
142
- await self.close()