mirascope 2.1.0__py3-none-any.whl → 2.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.
Files changed (40) hide show
  1. mirascope/api/_generated/functions/client.py +10 -0
  2. mirascope/api/_generated/functions/raw_client.py +8 -0
  3. mirascope/api/_generated/functions/types/functions_create_response.py +25 -8
  4. mirascope/api/_generated/functions/types/functions_find_by_hash_response.py +25 -10
  5. mirascope/api/_generated/functions/types/functions_get_by_env_response.py +1 -0
  6. mirascope/api/_generated/functions/types/functions_get_response.py +25 -8
  7. mirascope/api/_generated/functions/types/functions_list_by_env_response_functions_item.py +1 -0
  8. mirascope/api/_generated/functions/types/functions_list_response_functions_item.py +22 -7
  9. mirascope/api/_generated/reference.md +9 -0
  10. mirascope/llm/__init__.py +42 -0
  11. mirascope/llm/calls/calls.py +38 -11
  12. mirascope/llm/exceptions.py +69 -0
  13. mirascope/llm/prompts/prompts.py +47 -9
  14. mirascope/llm/providers/__init__.py +3 -0
  15. mirascope/llm/providers/openai/completions/_utils/__init__.py +3 -0
  16. mirascope/llm/providers/openai/completions/_utils/encode.py +27 -32
  17. mirascope/llm/providers/openai/completions/_utils/feature_info.py +50 -0
  18. mirascope/llm/providers/openai/completions/base_provider.py +21 -0
  19. mirascope/llm/providers/openai/completions/provider.py +8 -2
  20. mirascope/llm/providers/openrouter/__init__.py +5 -0
  21. mirascope/llm/providers/openrouter/provider.py +67 -0
  22. mirascope/llm/providers/provider_id.py +2 -0
  23. mirascope/llm/providers/provider_registry.py +6 -0
  24. mirascope/llm/responses/response.py +217 -0
  25. mirascope/llm/responses/stream_response.py +234 -0
  26. mirascope/llm/retries/__init__.py +51 -0
  27. mirascope/llm/retries/retry_calls.py +159 -0
  28. mirascope/llm/retries/retry_config.py +168 -0
  29. mirascope/llm/retries/retry_decorator.py +258 -0
  30. mirascope/llm/retries/retry_models.py +1313 -0
  31. mirascope/llm/retries/retry_prompts.py +227 -0
  32. mirascope/llm/retries/retry_responses.py +340 -0
  33. mirascope/llm/retries/retry_stream_responses.py +571 -0
  34. mirascope/llm/retries/utils.py +159 -0
  35. mirascope/ops/_internal/versioned_calls.py +249 -9
  36. mirascope/ops/_internal/versioned_functions.py +2 -0
  37. {mirascope-2.1.0.dist-info → mirascope-2.2.0.dist-info}/METADATA +1 -1
  38. {mirascope-2.1.0.dist-info → mirascope-2.2.0.dist-info}/RECORD +40 -28
  39. {mirascope-2.1.0.dist-info → mirascope-2.2.0.dist-info}/WHEEL +0 -0
  40. {mirascope-2.1.0.dist-info → mirascope-2.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,6 @@
1
1
  """Concrete Prompt classes for generating messages with tools and formatting."""
2
2
 
3
3
  from collections.abc import Callable, Sequence
4
- from dataclasses import dataclass, field
5
4
  from typing import Any, Generic, TypeVar, overload
6
5
 
7
6
  from ..._utils import copy_function_metadata
@@ -32,22 +31,20 @@ from .protocols import (
32
31
  FunctionT = TypeVar("FunctionT", bound=Callable[..., Any])
33
32
 
34
33
 
35
- @dataclass(kw_only=True)
36
34
  class BasePrompt(Generic[FunctionT]):
37
35
  """Base class for all Prompt types with shared metadata functionality."""
38
36
 
39
37
  fn: FunctionT
40
38
  """The underlying prompt function that generates message content."""
41
39
 
42
- __name__: str = field(init=False, repr=False, default="")
40
+ __name__: str = ""
43
41
  """The name of the underlying function (preserved for decorator stacking)."""
44
42
 
45
- def __post_init__(self) -> None:
46
- """Preserve standard function attributes for decorator stacking."""
43
+ def __init__(self, *, fn: FunctionT) -> None:
44
+ self.fn = fn
47
45
  copy_function_metadata(self, self.fn)
48
46
 
49
47
 
50
- @dataclass
51
48
  class Prompt(BasePrompt[MessageTemplate[P]], Generic[P, FormattableT]):
52
49
  """A prompt that can be called with a model to generate a response.
53
50
 
@@ -64,6 +61,17 @@ class Prompt(BasePrompt[MessageTemplate[P]], Generic[P, FormattableT]):
64
61
  format: FormatSpec[FormattableT] | None
65
62
  """The response format for the generated response."""
66
63
 
64
+ def __init__(
65
+ self,
66
+ *,
67
+ fn: MessageTemplate[P],
68
+ toolkit: Toolkit,
69
+ format: FormatSpec[FormattableT] | None,
70
+ ) -> None:
71
+ self.toolkit = toolkit
72
+ self.format = format
73
+ super().__init__(fn=fn)
74
+
67
75
  def messages(self, *args: P.args, **kwargs: P.kwargs) -> Sequence[Message]:
68
76
  """Return the `Messages` from invoking this prompt."""
69
77
  return promote_to_messages(self.fn(*args, **kwargs))
@@ -141,7 +149,6 @@ class Prompt(BasePrompt[MessageTemplate[P]], Generic[P, FormattableT]):
141
149
  return model.stream(messages, tools=self.toolkit, format=self.format)
142
150
 
143
151
 
144
- @dataclass
145
152
  class AsyncPrompt(BasePrompt[AsyncMessageTemplate[P]], Generic[P, FormattableT]):
146
153
  """An async prompt that can be called with a model to generate a response.
147
154
 
@@ -158,6 +165,17 @@ class AsyncPrompt(BasePrompt[AsyncMessageTemplate[P]], Generic[P, FormattableT])
158
165
  format: FormatSpec[FormattableT] | None
159
166
  """The response format for the generated response."""
160
167
 
168
+ def __init__(
169
+ self,
170
+ *,
171
+ fn: AsyncMessageTemplate[P],
172
+ toolkit: AsyncToolkit,
173
+ format: FormatSpec[FormattableT] | None,
174
+ ) -> None:
175
+ self.toolkit = toolkit
176
+ self.format = format
177
+ super().__init__(fn=fn)
178
+
161
179
  async def messages(self, *args: P.args, **kwargs: P.kwargs) -> Sequence[Message]:
162
180
  """Return the `Messages` from invoking this prompt."""
163
181
  return promote_to_messages(await self.fn(*args, **kwargs))
@@ -237,7 +255,6 @@ class AsyncPrompt(BasePrompt[AsyncMessageTemplate[P]], Generic[P, FormattableT])
237
255
  )
238
256
 
239
257
 
240
- @dataclass
241
258
  class ContextPrompt(
242
259
  BasePrompt[ContextMessageTemplate[P, DepsT]], Generic[P, DepsT, FormattableT]
243
260
  ):
@@ -257,6 +274,17 @@ class ContextPrompt(
257
274
  format: FormatSpec[FormattableT] | None
258
275
  """The response format for the generated response."""
259
276
 
277
+ def __init__(
278
+ self,
279
+ *,
280
+ fn: ContextMessageTemplate[P, DepsT],
281
+ toolkit: ContextToolkit[DepsT],
282
+ format: FormatSpec[FormattableT] | None,
283
+ ) -> None:
284
+ self.toolkit = toolkit
285
+ self.format = format
286
+ super().__init__(fn=fn)
287
+
260
288
  def messages(
261
289
  self, ctx: Context[DepsT], *args: P.args, **kwargs: P.kwargs
262
290
  ) -> Sequence[Message]:
@@ -360,7 +388,6 @@ class ContextPrompt(
360
388
  )
361
389
 
362
390
 
363
- @dataclass
364
391
  class AsyncContextPrompt(
365
392
  BasePrompt[AsyncContextMessageTemplate[P, DepsT]], Generic[P, DepsT, FormattableT]
366
393
  ):
@@ -380,6 +407,17 @@ class AsyncContextPrompt(
380
407
  format: FormatSpec[FormattableT] | None
381
408
  """The response format for the generated response."""
382
409
 
410
+ def __init__(
411
+ self,
412
+ *,
413
+ fn: AsyncContextMessageTemplate[P, DepsT],
414
+ toolkit: AsyncContextToolkit[DepsT],
415
+ format: FormatSpec[FormattableT] | None,
416
+ ) -> None:
417
+ self.toolkit = toolkit
418
+ self.format = format
419
+ super().__init__(fn=fn)
420
+
383
421
  async def messages(
384
422
  self, ctx: Context[DepsT], *args: P.args, **kwargs: P.kwargs
385
423
  ) -> Sequence[Message]:
@@ -10,6 +10,7 @@ stub_module_if_missing("mirascope.llm.providers.anthropic", "anthropic")
10
10
  stub_module_if_missing("mirascope.llm.providers.google", "google")
11
11
  stub_module_if_missing("mirascope.llm.providers.mlx", "mlx")
12
12
  stub_module_if_missing("mirascope.llm.providers.openai", "openai")
13
+ stub_module_if_missing("mirascope.llm.providers.openrouter", "openai")
13
14
  stub_module_if_missing("mirascope.llm.providers.together", "openai")
14
15
  stub_module_if_missing("mirascope.llm.providers.ollama", "openai")
15
16
 
@@ -30,6 +31,7 @@ from .openai import (
30
31
  OpenAIProvider,
31
32
  )
32
33
  from .openai.completions import BaseOpenAICompletionsProvider
34
+ from .openrouter import OpenRouterProvider
33
35
  from .provider_id import KNOWN_PROVIDER_IDS, ProviderId
34
36
  from .provider_registry import (
35
37
  get_provider_for_model,
@@ -53,6 +55,7 @@ __all__ = [
53
55
  "OllamaProvider",
54
56
  "OpenAIModelId",
55
57
  "OpenAIProvider",
58
+ "OpenRouterProvider",
56
59
  "Provider",
57
60
  "ProviderId",
58
61
  "TogetherProvider",
@@ -5,11 +5,14 @@ from .decode import (
5
5
  model_name,
6
6
  )
7
7
  from .encode import encode_request
8
+ from .feature_info import CompletionsModelFeatureInfo, feature_info_for_openai_model
8
9
 
9
10
  __all__ = [
11
+ "CompletionsModelFeatureInfo",
10
12
  "decode_async_stream",
11
13
  "decode_response",
12
14
  "decode_stream",
13
15
  "encode_request",
16
+ "feature_info_for_openai_model",
14
17
  "model_name",
15
18
  ]
@@ -22,12 +22,7 @@ from .....tools import (
22
22
  )
23
23
  from ....base import _utils as _base_utils
24
24
  from ...model_id import OpenAIModelId, model_name
25
- from ...model_info import (
26
- MODELS_WITHOUT_AUDIO_SUPPORT,
27
- MODELS_WITHOUT_JSON_OBJECT_SUPPORT,
28
- MODELS_WITHOUT_JSON_SCHEMA_SUPPORT,
29
- NON_REASONING_MODELS,
30
- )
25
+ from .feature_info import CompletionsModelFeatureInfo
31
26
 
32
27
  if TYPE_CHECKING:
33
28
  from .....models import Params
@@ -58,6 +53,8 @@ class ChatCompletionCreateKwargs(TypedDict, total=False):
58
53
  def _encode_user_message(
59
54
  message: UserMessage,
60
55
  model_id: OpenAIModelId,
56
+ feature_info: CompletionsModelFeatureInfo,
57
+ provider_id: str,
61
58
  ) -> list[openai_types.ChatCompletionMessageParam]:
62
59
  """Convert Mirascope `UserMessage` to a list of OpenAI `ChatCompletionMessageParam`.
63
60
 
@@ -106,11 +103,10 @@ def _encode_user_message(
106
103
  )
107
104
  current_content.append(content)
108
105
  elif part.type == "audio":
109
- base_model_name = model_name(model_id, None)
110
- if base_model_name in MODELS_WITHOUT_AUDIO_SUPPORT:
106
+ if feature_info.audio_support is False:
111
107
  raise FeatureNotSupportedError(
112
108
  feature="Audio inputs",
113
- provider_id="openai",
109
+ provider_id=provider_id,
114
110
  message=f"Model '{model_id}' does not support audio inputs.",
115
111
  )
116
112
 
@@ -208,18 +204,13 @@ def _encode_assistant_message(
208
204
 
209
205
 
210
206
  def _encode_message(
211
- message: Message, model_id: OpenAIModelId, encode_thoughts_as_text: bool
207
+ message: Message,
208
+ model_id: OpenAIModelId,
209
+ encode_thoughts_as_text: bool,
210
+ feature_info: CompletionsModelFeatureInfo,
211
+ provider_id: str,
212
212
  ) -> list[openai_types.ChatCompletionMessageParam]:
213
- """Convert a Mirascope `Message` to OpenAI `ChatCompletionMessageParam` format.
214
-
215
- Args:
216
- message: A Mirascope message (system, user, or assistant)
217
- model_id: The model ID being used
218
- encode_thoughts: Whether to encode thoughts as text
219
-
220
- Returns:
221
- A list of OpenAI `ChatCompletionMessageParam` (may be multiple for tool outputs)
222
- """
213
+ """Convert a Mirascope `Message` to OpenAI `ChatCompletionMessageParam` format."""
223
214
  if message.role == "system":
224
215
  return [
225
216
  openai_types.ChatCompletionSystemMessageParam(
@@ -227,7 +218,7 @@ def _encode_message(
227
218
  )
228
219
  ]
229
220
  elif message.role == "user":
230
- return _encode_user_message(message, model_id)
221
+ return _encode_user_message(message, model_id, feature_info, provider_id)
231
222
  elif message.role == "assistant":
232
223
  return [_encode_assistant_message(message, model_id, encode_thoughts_as_text)]
233
224
  else:
@@ -302,6 +293,8 @@ def encode_request(
302
293
  tools: BaseToolkit[AnyToolSchema],
303
294
  format: FormatSpec[FormattableT] | None,
304
295
  params: Params,
296
+ feature_info: CompletionsModelFeatureInfo,
297
+ provider_id: str,
305
298
  ) -> tuple[Sequence[Message], Format[FormattableT] | None, ChatCompletionCreateKwargs]:
306
299
  """Prepares a request for the `OpenAI.chat.completions.create` method."""
307
300
  if model_id.endswith(":responses"):
@@ -313,7 +306,11 @@ def encode_request(
313
306
  )
314
307
  base_model_name = model_name(model_id, None)
315
308
 
316
- is_reasoning_model = base_model_name not in NON_REASONING_MODELS
309
+ is_reasoning_model = feature_info.is_reasoning_model is True
310
+ strict_supported = feature_info.strict_support is True
311
+ strict_allowed = feature_info.strict_support is not False
312
+ supports_json_object = feature_info.json_object_support is True
313
+
317
314
  kwargs: ChatCompletionCreateKwargs = ChatCompletionCreateKwargs(
318
315
  {"model": base_model_name}
319
316
  )
@@ -321,7 +318,7 @@ def encode_request(
321
318
 
322
319
  with _base_utils.ensure_all_params_accessed(
323
320
  params=params,
324
- provider_id="openai",
321
+ provider_id=provider_id,
325
322
  unsupported_params=[
326
323
  "top_k",
327
324
  *(["temperature", "top_p", "stop_sequences"] if is_reasoning_model else []),
@@ -348,15 +345,14 @@ def encode_request(
348
345
 
349
346
  openai_tools = [_convert_tool_to_tool_param(tool) for tool in tools.tools]
350
347
 
351
- model_supports_strict = base_model_name not in MODELS_WITHOUT_JSON_SCHEMA_SUPPORT
352
- default_mode = "strict" if model_supports_strict else "tool"
348
+ default_mode = "strict" if strict_supported else "tool"
353
349
  format = resolve_format(format, default_mode=default_mode)
354
350
  if format is not None:
355
351
  if format.mode == "strict":
356
- if not model_supports_strict:
352
+ if not strict_allowed:
357
353
  raise FeatureNotSupportedError(
358
354
  feature="formatting_mode:strict",
359
- provider_id="openai",
355
+ provider_id=provider_id,
360
356
  model_id=model_id,
361
357
  )
362
358
  kwargs["response_format"] = _create_strict_response_format(format)
@@ -371,10 +367,7 @@ def encode_request(
371
367
  kwargs["parallel_tool_calls"] = False
372
368
  format_tool_schema = format.create_tool_schema()
373
369
  openai_tools.append(_convert_tool_to_tool_param(format_tool_schema))
374
- elif (
375
- format.mode == "json"
376
- and base_model_name not in MODELS_WITHOUT_JSON_OBJECT_SUPPORT
377
- ):
370
+ elif format.mode == "json" and supports_json_object:
378
371
  kwargs["response_format"] = {"type": "json_object"}
379
372
 
380
373
  if format.formatting_instructions:
@@ -388,7 +381,9 @@ def encode_request(
388
381
  encoded_messages: list[openai_types.ChatCompletionMessageParam] = []
389
382
  for message in messages:
390
383
  encoded_messages.extend(
391
- _encode_message(message, model_id, encode_thoughts_as_text)
384
+ _encode_message(
385
+ message, model_id, encode_thoughts_as_text, feature_info, provider_id
386
+ )
392
387
  )
393
388
  kwargs["messages"] = encoded_messages
394
389
 
@@ -0,0 +1,50 @@
1
+ """Model feature information for OpenAI completions encoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ...model_info import (
8
+ MODELS_WITHOUT_AUDIO_SUPPORT,
9
+ MODELS_WITHOUT_JSON_OBJECT_SUPPORT,
10
+ MODELS_WITHOUT_JSON_SCHEMA_SUPPORT,
11
+ NON_REASONING_MODELS,
12
+ )
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CompletionsModelFeatureInfo:
17
+ """Model feature information for OpenAI completions encoding.
18
+
19
+ This dataclass encapsulates feature detection for OpenAI-compatible models,
20
+ allowing providers to pass pre-computed feature information rather than
21
+ relying on model name matching in encode_request.
22
+
23
+ None values mean "unknown":
24
+ - audio_support: None → allow audio (permissive)
25
+ - strict_support: None → default to tool mode, but allow explicit strict
26
+ - json_object_support: None → disable (use prompt instructions instead)
27
+ - is_reasoning_model: None → treat as False (allow temperature)
28
+ """
29
+
30
+ audio_support: bool | None = None
31
+ """Whether the model supports audio inputs. None means skip check (allow)."""
32
+
33
+ strict_support: bool | None = None
34
+ """Whether the model supports strict JSON schema. None allows explicit strict."""
35
+
36
+ json_object_support: bool | None = None
37
+ """Whether the model supports JSON object response format. None disables."""
38
+
39
+ is_reasoning_model: bool | None = None
40
+ """Whether the model is a reasoning model. None means False (allow temperature)."""
41
+
42
+
43
+ def feature_info_for_openai_model(model_name: str) -> CompletionsModelFeatureInfo:
44
+ """Get feature info for a base OpenAI model name."""
45
+ return CompletionsModelFeatureInfo(
46
+ audio_support=model_name not in MODELS_WITHOUT_AUDIO_SUPPORT,
47
+ strict_support=model_name not in MODELS_WITHOUT_JSON_SCHEMA_SUPPORT,
48
+ json_object_support=model_name not in MODELS_WITHOUT_JSON_OBJECT_SUPPORT,
49
+ is_reasoning_model=model_name not in NON_REASONING_MODELS,
50
+ )
@@ -27,6 +27,7 @@ from ...base import BaseProvider
27
27
  from .. import _utils as _shared_utils
28
28
  from ..model_id import model_name as openai_model_name
29
29
  from . import _utils
30
+ from ._utils import CompletionsModelFeatureInfo
30
31
 
31
32
  if TYPE_CHECKING:
32
33
  from ....models import Params
@@ -83,6 +84,10 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
83
84
  """Extract the model name to send to the API."""
84
85
  return openai_model_name(model_id, None)
85
86
 
87
+ def _model_feature_info(self, model_id: str) -> CompletionsModelFeatureInfo:
88
+ """Get feature info for the model. Override for provider-specific features."""
89
+ return CompletionsModelFeatureInfo()
90
+
86
91
  def _provider_model_name(self, model_id: str) -> str:
87
92
  """Get the model name for tracking in Response."""
88
93
  return self._model_name(model_id)
@@ -114,6 +119,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
114
119
  tools=toolkit,
115
120
  format=format,
116
121
  params=params,
122
+ feature_info=self._model_feature_info(model_id),
123
+ provider_id=self.id,
117
124
  )
118
125
  kwargs["model"] = self._model_name(model_id)
119
126
  openai_response = self.client.chat.completions.create(**kwargs)
@@ -168,6 +175,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
168
175
  tools=toolkit,
169
176
  format=format,
170
177
  params=params,
178
+ feature_info=self._model_feature_info(model_id),
179
+ provider_id=self.id,
171
180
  )
172
181
  kwargs["model"] = self._model_name(model_id)
173
182
  openai_response = self.client.chat.completions.create(**kwargs)
@@ -220,6 +229,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
220
229
  messages=messages,
221
230
  tools=toolkit,
222
231
  format=format,
232
+ feature_info=self._model_feature_info(model_id),
233
+ provider_id=self.id,
223
234
  )
224
235
  kwargs["model"] = self._model_name(model_id)
225
236
  openai_response = await self.async_client.chat.completions.create(**kwargs)
@@ -274,6 +285,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
274
285
  messages=messages,
275
286
  tools=toolkit,
276
287
  format=format,
288
+ feature_info=self._model_feature_info(model_id),
289
+ provider_id=self.id,
277
290
  )
278
291
  kwargs["model"] = self._model_name(model_id)
279
292
  openai_response = await self.async_client.chat.completions.create(**kwargs)
@@ -326,6 +339,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
326
339
  tools=toolkit,
327
340
  format=format,
328
341
  params=params,
342
+ feature_info=self._model_feature_info(model_id),
343
+ provider_id=self.id,
329
344
  )
330
345
  kwargs["model"] = self._model_name(model_id)
331
346
  openai_stream = self.client.chat.completions.create(
@@ -376,6 +391,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
376
391
  tools=toolkit,
377
392
  format=format,
378
393
  params=params,
394
+ feature_info=self._model_feature_info(model_id),
395
+ provider_id=self.id,
379
396
  )
380
397
  kwargs["model"] = self._model_name(model_id)
381
398
 
@@ -425,6 +442,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
425
442
  tools=toolkit,
426
443
  format=format,
427
444
  params=params,
445
+ feature_info=self._model_feature_info(model_id),
446
+ provider_id=self.id,
428
447
  )
429
448
  kwargs["model"] = self._model_name(model_id)
430
449
  openai_stream = await self.async_client.chat.completions.create(
@@ -478,6 +497,8 @@ class BaseOpenAICompletionsProvider(BaseProvider[OpenAI]):
478
497
  tools=toolkit,
479
498
  format=format,
480
499
  params=params,
500
+ feature_info=self._model_feature_info(model_id),
501
+ provider_id=self.id,
481
502
  )
482
503
  kwargs["model"] = self._model_name(model_id)
483
504
 
@@ -1,6 +1,7 @@
1
1
  """OpenAI Completions API provider implementation."""
2
2
 
3
- from ..model_id import model_name
3
+ from ..model_id import model_name as openai_model_name
4
+ from ._utils import CompletionsModelFeatureInfo, feature_info_for_openai_model
4
5
  from .base_provider import BaseOpenAICompletionsProvider
5
6
 
6
7
 
@@ -14,9 +15,14 @@ class OpenAICompletionsProvider(BaseOpenAICompletionsProvider):
14
15
  api_key_required = False
15
16
  provider_name = "OpenAI"
16
17
 
18
+ def _model_feature_info(self, model_id: str) -> CompletionsModelFeatureInfo:
19
+ """Get feature info for actual OpenAI models."""
20
+ base_name = openai_model_name(model_id, None)
21
+ return feature_info_for_openai_model(base_name)
22
+
17
23
  def _provider_model_name(self, model_id: str) -> str:
18
24
  """Get the model name for tracking in Response.
19
25
 
20
26
  Returns the model name with :completions suffix for tracking which API was used.
21
27
  """
22
- return model_name(model_id, "completions")
28
+ return openai_model_name(model_id, "completions")
@@ -0,0 +1,5 @@
1
+ """OpenRouter provider for accessing multiple LLM providers via OpenRouter's API."""
2
+
3
+ from .provider import OpenRouterProvider
4
+
5
+ __all__ = ["OpenRouterProvider"]
@@ -0,0 +1,67 @@
1
+ """OpenRouter provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from ..openai.completions._utils import (
8
+ CompletionsModelFeatureInfo,
9
+ feature_info_for_openai_model,
10
+ )
11
+ from ..openai.completions.base_provider import BaseOpenAICompletionsProvider
12
+ from ..openai.model_id import model_name as openai_model_name
13
+
14
+
15
+ class OpenRouterProvider(BaseOpenAICompletionsProvider):
16
+ """Provider for OpenRouter's OpenAI-compatible API.
17
+
18
+ Inherits from BaseOpenAICompletionsProvider with OpenRouter-specific configuration:
19
+ - Uses OpenRouter's API endpoint
20
+ - Requires OPENROUTER_API_KEY
21
+
22
+ Usage:
23
+ Option 1: Use "openrouter/" prefix for explicit OpenRouter models:
24
+
25
+ ```python
26
+ from mirascope import llm
27
+
28
+ llm.register_provider("openrouter", scope="openrouter/")
29
+
30
+ @llm.call("openrouter/openai/gpt-4o")
31
+ def my_prompt():
32
+ return [llm.messages.user("Hello!")]
33
+ ```
34
+
35
+ Option 2: Route existing model IDs through OpenRouter:
36
+
37
+ ```python
38
+ from mirascope import llm
39
+
40
+ # Register for openai and anthropic models via OpenRouter
41
+ llm.register_provider("openrouter", scope=["openai/", "anthropic/"])
42
+
43
+ # Now openai/ models go through OpenRouter
44
+ @llm.call("openai/gpt-4")
45
+ def my_prompt():
46
+ return [llm.messages.user("Hello!")]
47
+ ```
48
+ """
49
+
50
+ id: ClassVar[str] = "openrouter"
51
+ default_scope: ClassVar[str | list[str]] = []
52
+ default_base_url: ClassVar[str | None] = "https://openrouter.ai/api/v1"
53
+ api_key_env_var: ClassVar[str] = "OPENROUTER_API_KEY"
54
+ api_key_required: ClassVar[bool] = True
55
+ provider_name: ClassVar[str | None] = "OpenRouter"
56
+
57
+ def _model_name(self, model_id: str) -> str:
58
+ """Strip 'openrouter/' prefix from model ID for OpenRouter API."""
59
+ return model_id.removeprefix("openrouter/")
60
+
61
+ def _model_feature_info(self, model_id: str) -> CompletionsModelFeatureInfo:
62
+ """Return OpenAI feature info for openai/* models, empty info otherwise."""
63
+ base_model_id = model_id.removeprefix("openrouter/")
64
+ if base_model_id.startswith("openai/"):
65
+ openai_name = openai_model_name(base_model_id, None)
66
+ return feature_info_for_openai_model(openai_name)
67
+ return CompletionsModelFeatureInfo()
@@ -10,6 +10,7 @@ KnownProviderId: TypeAlias = Literal[
10
10
  "mlx", # Local inference powered by `mlx-lm`, via MLXProvider
11
11
  "ollama", # Ollama provider via OllamaProvider
12
12
  "openai", # OpenAI provider via OpenAIProvider (prefers Responses routing when available)
13
+ "openrouter", # OpenRouter provider via OpenRouterProvider
13
14
  "together", # Together AI provider via TogetherProvider
14
15
  ]
15
16
  KNOWN_PROVIDER_IDS = get_args(KnownProviderId)
@@ -20,5 +21,6 @@ OpenAICompletionsCompatibleProviderId: TypeAlias = Literal[
20
21
  "ollama", # Ollama (OpenAI-compatible)
21
22
  "openai", # OpenAI via OpenAIProvider (routes to completions)
22
23
  "openai:completions", # OpenAI Completions API directly
24
+ "openrouter", # OpenRouter (OpenAI-compatible)
23
25
  "together", # Together AI (OpenAI-compatible)
24
26
  ]
@@ -16,6 +16,7 @@ from .ollama import OllamaProvider
16
16
  from .openai import OpenAIProvider
17
17
  from .openai.completions.provider import OpenAICompletionsProvider
18
18
  from .openai.responses.provider import OpenAIResponsesProvider
19
+ from .openrouter import OpenRouterProvider
19
20
  from .provider_id import ProviderId
20
21
  from .together import TogetherProvider
21
22
 
@@ -71,6 +72,9 @@ DEFAULT_AUTO_REGISTER_SCOPES: dict[str, Sequence[ProviderDefault]] = {
71
72
  "mlx-community/": [
72
73
  ProviderDefault("mlx", None), # No API key required
73
74
  ],
75
+ "openrouter/": [
76
+ ProviderDefault("openrouter", "OPENROUTER_API_KEY"),
77
+ ],
74
78
  }
75
79
 
76
80
 
@@ -122,6 +126,8 @@ def provider_singleton(
122
126
  return OpenAICompletionsProvider(api_key=api_key, base_url=base_url)
123
127
  case "openai:responses":
124
128
  return OpenAIResponsesProvider(api_key=api_key, base_url=base_url)
129
+ case "openrouter":
130
+ return OpenRouterProvider(api_key=api_key, base_url=base_url)
125
131
  case "together":
126
132
  return TogetherProvider(api_key=api_key, base_url=base_url)
127
133
  case _: # pragma: no cover