python-open-router 0.3.3__tar.gz → 0.4.1__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
  # MIT License
2
2
 
3
- Copyright (c) 2025 Joost Lekkerkerker
3
+ Copyright (c) 2025-2026 Joost Lekkerkerker
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_open_router
3
- Version: 0.3.3
3
+ Version: 0.4.1
4
4
  Summary: Asynchronous Python client for Open Router.
5
5
  License: MIT
6
6
  License-File: LICENSE.md
@@ -121,7 +121,7 @@ check [the contributor's page][contributors].
121
121
 
122
122
  MIT License
123
123
 
124
- Copyright (c) 2025 Joost Lekkerkerker
124
+ Copyright (c) 2025-2026 Joost Lekkerkerker
125
125
 
126
126
  Permission is hereby granted, free of charge, to any person obtaining a copy
127
127
  of this software and associated documentation files (the "Software"), to deal
@@ -151,7 +151,7 @@ SOFTWARE.
151
151
  [joostlek]: https://github.com/joostlek
152
152
  [keepchangelog]: http://keepachangelog.com/en/1.0.0/
153
153
  [license-shield]: https://img.shields.io/github/license/joostlek/python-open-router.svg
154
- [maintenance-shield]: https://img.shields.io/maintenance/yes/2025.svg
154
+ [maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
155
155
  [poetry-install]: https://python-poetry.org/docs/#installation
156
156
  [poetry]: https://python-poetry.org
157
157
  [pre-commit]: https://pre-commit.com/
@@ -88,7 +88,7 @@ check [the contributor's page][contributors].
88
88
 
89
89
  MIT License
90
90
 
91
- Copyright (c) 2025 Joost Lekkerkerker
91
+ Copyright (c) 2025-2026 Joost Lekkerkerker
92
92
 
93
93
  Permission is hereby granted, free of charge, to any person obtaining a copy
94
94
  of this software and associated documentation files (the "Software"), to deal
@@ -118,7 +118,7 @@ SOFTWARE.
118
118
  [joostlek]: https://github.com/joostlek
119
119
  [keepchangelog]: http://keepachangelog.com/en/1.0.0/
120
120
  [license-shield]: https://img.shields.io/github/license/joostlek/python-open-router.svg
121
- [maintenance-shield]: https://img.shields.io/maintenance/yes/2025.svg
121
+ [maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
122
122
  [poetry-install]: https://python-poetry.org/docs/#installation
123
123
  [poetry]: https://python-poetry.org
124
124
  [pre-commit]: https://pre-commit.com/
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python_open_router"
3
- version = "0.3.3"
3
+ version = "0.4.1"
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.3"
36
36
  covdefaults = "2.3.0"
37
- coverage = {version = "7.11.3", extras = ["toml"]}
38
- mypy = "1.18.2"
39
- pre-commit = "4.3.0"
37
+ coverage = {version = "7.16.1", extras = ["toml"]}
38
+ mypy = "2.3.1"
39
+ pre-commit = "4.6.2"
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.8"
42
+ pytest = "9.1.1"
43
+ pytest-asyncio = "1.4.0"
44
+ pytest-cov = "7.1.0"
45
+ ruff = "0.16.8"
46
+ safety = "3.8.1"
47
+ yamllint = "1.38.0"
48
+ syrupy = "6.1.1"
49
+ aiointercept = "0.1.10"
50
50
 
51
51
  [tool.poetry.urls]
52
52
  "Bug Tracker" = "https://github.com/joostlek/python-open-router/issues"
@@ -137,10 +137,14 @@ asyncio_mode = "auto"
137
137
  ignore = [
138
138
  "ANN401", # Opinioated warning on disallowing dynamically typed expressions
139
139
  "COM812", # Conflicts with other rules
140
+ "CPY001", # No copyright notices used in this project
140
141
  "D203", # Conflicts with other rules
141
142
  "D213", # Conflicts with other rules
142
143
  "D417", # False positives in some occasions
143
144
  "ISC001", # Conflicts with other rules
145
+ "PLR0912", # Too many branches - legitimate for API client methods
146
+ "PLR0913", # Too many arguments - legitimate for API client methods
147
+ "PLR0917", # Too many positional arguments - legitimate for API client methods
144
148
  "PLR2004", # Just annoying, not really useful
145
149
  ]
146
150
  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,272 @@
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
+ PREDICTION = "prediction"
132
+ TOOLS = "tools"
133
+ TOOL_CHOICE = "tool_choice"
134
+ PARALLEL_TOOL_CALLS = "parallel_tool_calls"
135
+ FREQUENCY_PENALTY = "frequency_penalty"
136
+ PRESENCE_PENALTY = "presence_penalty"
137
+ REPETITION_PENALTY = "repetition_penalty"
138
+ MIN_P = "min_p"
139
+ TOP_A = "top_a"
140
+ LOGPROBS = "logprobs"
141
+ LOGIT_BIAS = "logit_bias"
142
+ TOP_LOGPROBS = "top_logprobs"
143
+ WEB_SEARCH_OPTIONS = "web_search_options"
144
+ VERBOSITY = "verbosity"
145
+
146
+
147
+ class Modality(StrEnum):
148
+ """Supported modalities for models."""
149
+
150
+ AUDIO = "audio"
151
+ TEXT = "text"
152
+ IMAGE = "image"
153
+ FILE = "file"
154
+ EMBEDDINGS = "embeddings"
155
+ VIDEO = "video"
156
+ RERANK = "rerank"
157
+ SPEECH = "speech"
158
+ TRANSCRIPTION = "transcription"
159
+
160
+
161
+ @dataclass(kw_only=True)
162
+ class ModelArchitecture(DataClassORJSONMixin):
163
+ """Model architecture data."""
164
+
165
+ input_modalities: list[Modality]
166
+ output_modalities: list[Modality]
167
+ modality: str | None = None
168
+ instruct_type: str | None = None
169
+ tokenizer: str | None = None
170
+
171
+
172
+ @dataclass(kw_only=True)
173
+ class PublicPricing(DataClassORJSONMixin):
174
+ """Model pricing data."""
175
+
176
+ completion: str
177
+ prompt: str
178
+ audio: str | None = None
179
+ audio_output: str | None = None
180
+ image: str | None = None
181
+ image_output: str | None = None
182
+ image_token: str | None = None
183
+ input_audio_cache: str | None = None
184
+ input_cache_read: str | None = None
185
+ input_cache_write: str | None = None
186
+ input_cache_write_1h: str | None = None
187
+ internal_reasoning: str | None = None
188
+ request: str | None = None
189
+ web_search: str | None = None
190
+ discount: float | None = None
191
+
192
+
193
+ @dataclass(kw_only=True)
194
+ class TopProviderInfo(DataClassORJSONMixin):
195
+ """Top provider information."""
196
+
197
+ is_moderated: bool
198
+ context_length: int | None = None
199
+ max_completion_tokens: int | None = None
200
+
201
+
202
+ @dataclass(kw_only=True)
203
+ class DefaultParameters(DataClassORJSONMixin):
204
+ """Default model parameters."""
205
+
206
+ temperature: float | None = None
207
+ top_p: float | None = None
208
+ top_k: int | None = None
209
+ frequency_penalty: float | None = None
210
+ presence_penalty: float | None = None
211
+ repetition_penalty: float | None = None
212
+
213
+
214
+ @dataclass(kw_only=True)
215
+ class PerRequestLimits(DataClassORJSONMixin):
216
+ """Per-request limits."""
217
+
218
+ completion_tokens: float
219
+ prompt_tokens: float
220
+
221
+
222
+ @dataclass(kw_only=True)
223
+ class ModelLinks(DataClassORJSONMixin):
224
+ """Model links."""
225
+
226
+ details: str
227
+
228
+
229
+ @dataclass(kw_only=True)
230
+ class Model(DataClassORJSONMixin):
231
+ """Model data."""
232
+
233
+ id: str
234
+ canonical_slug: str
235
+ name: str
236
+ context_length: int | None
237
+ architecture: ModelArchitecture
238
+ supported_parameters: list[SupportedParameter]
239
+ pricing: PublicPricing
240
+ top_provider: TopProviderInfo
241
+ hugging_face_id: str | None = None
242
+ description: str | None = None
243
+ created: int | None = None
244
+ default_parameters: DefaultParameters | None = None
245
+ links: ModelLinks | None = None
246
+ per_request_limits: PerRequestLimits | None = None
247
+ supported_voices: list[str] | None = None
248
+ expiration_date: str | None = None
249
+ knowledge_cutoff: str | None = None
250
+
251
+ @classmethod
252
+ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:
253
+ """Pre deserialize hook."""
254
+ parameters = d.get("supported_parameters", [])
255
+ supported = set(SupportedParameter)
256
+ d["supported_parameters"] = [p for p in parameters if p in supported]
257
+ for parameter in parameters:
258
+ if parameter not in supported:
259
+ _LOGGER.warning(
260
+ "Unsupported parameter: %s. Please report at https://github.com/joostlek/python-open-router/issues.",
261
+ parameter,
262
+ )
263
+ return d
264
+
265
+ @classmethod
266
+ def __post_deserialize__(cls, obj: Model) -> Model:
267
+ """Post deserialize hook."""
268
+ if obj.hugging_face_id == "":
269
+ obj.hugging_face_id = None
270
+ if obj.description == "":
271
+ obj.description = None
272
+ 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,149 +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
- VIDEO = "video"
108
-
109
-
110
- @dataclass(kw_only=True)
111
- class ModelArchitecture(DataClassORJSONMixin):
112
- """Model data."""
113
-
114
- input_modalities: list[Modality]
115
- output_modalities: list[Modality]
116
-
117
-
118
- @dataclass(kw_only=True)
119
- class Model(DataClassORJSONMixin):
120
- """Model data."""
121
-
122
- id: str
123
- canonical_slug: str
124
- hugging_face_id: str | None
125
- name: str
126
- description: str
127
- context_length: int
128
- architecture: ModelArchitecture
129
- supported_parameters: list[SupportedParameter]
130
-
131
- @classmethod
132
- def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:
133
- """Pre deserialize hook."""
134
- parameters = d.get("supported_parameters", [])
135
- for parameter in parameters:
136
- if parameter not in SupportedParameter:
137
- _LOGGER.warning(
138
- "Unsupported parameter: %s. Please report at https://github.com/joostlek/python-open-router/issues.",
139
- parameter,
140
- )
141
- parameters.remove(parameter)
142
- return d
143
-
144
- @classmethod
145
- def __post_deserialize__(cls, obj: Model) -> Model:
146
- """Post deserialize hook."""
147
- if obj.hugging_face_id == "":
148
- obj.hugging_face_id = None
149
- 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()