Skip to content

Commit cc66d80

Browse files
feat(models): add native OpenRouter support
1 parent 79ef2bf commit cc66d80

File tree

13 files changed

+2368
-109
lines changed

13 files changed

+2368
-109
lines changed

docs/api/models/openrouter.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# `pydantic_ai.models.openrouter`
2+
3+
## Setup
4+
5+
For details on how to set up authentication with this model, see [model configuration for OpenRouter](../../models/openrouter.md).
6+
7+
::: pydantic_ai.models.openrouter

docs/models/openai.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,10 @@ agent = Agent(model)
394394

395395
### OpenRouter
396396

397-
To use [OpenRouter](https://openrouter.ai), first create an API key at [openrouter.ai/keys](https://openrouter.ai/keys).
397+
[OpenRouter](https://openrouter.ai) now has dedicated support in PydanticAI with the [`OpenRouterModel`][pydantic_ai.models.openrouter.OpenRouterModel].
398+
For detailed documentation and examples, see the [OpenRouter documentation](openrouter.md).
398399

399-
Once you have the API key, you can use it with the [`OpenRouterProvider`][pydantic_ai.providers.openrouter.OpenRouterProvider]:
400+
You can also still use OpenRouter through the OpenAI-compatible interface:
400401

401402
```python
402403
from pydantic_ai import Agent

docs/models/openrouter.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# OpenRouter
2+
3+
## Install
4+
5+
To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openrouter` optional group:
6+
7+
```bash
8+
pip/uv-add "pydantic-ai-slim[openrouter]"
9+
```
10+
11+
## Configuration
12+
13+
To use [OpenRouter](https://openrouter.ai/) through their API, go to [openrouter.ai/keys](https://openrouter.ai/keys) and follow your nose until you find the place to generate an API key.
14+
15+
`OpenRouterModelName` contains a list of available OpenRouter models.
16+
17+
## Environment variable
18+
19+
Once you have the API key, you can set it as an environment variable:
20+
21+
```bash
22+
export OPENROUTER_API_KEY='your-api-key'
23+
```
24+
25+
You can then use `OpenRouterModel` by name:
26+
27+
```python
28+
from pydantic_ai import Agent
29+
30+
agent = Agent('openrouter:google/gemini-2.5-flash-lite')
31+
...
32+
```
33+
34+
Or initialise the model directly with just the model name:
35+
36+
```python
37+
from pydantic_ai import Agent
38+
from pydantic_ai.models.openrouter import OpenRouterModel
39+
40+
model = OpenRouterModel('google/gemini-2.5-flash-lite')
41+
agent = Agent(model)
42+
...
43+
```
44+
45+
## Custom API Key
46+
47+
You can provide a custom API key directly to the model:
48+
49+
```python
50+
from pydantic_ai import Agent
51+
from pydantic_ai.models.openrouter import OpenRouterModel
52+
53+
model = OpenRouterModel(
54+
'google/gemini-2.5-flash-lite', api_key='your-api-key'
55+
)
56+
agent = Agent(model)
57+
...
58+
```
59+
60+
## Custom HTTP Client
61+
62+
You can customize the OpenRouter model with a custom `httpx.AsyncClient`:
63+
64+
```python
65+
from httpx import AsyncClient
66+
67+
from pydantic_ai import Agent
68+
from pydantic_ai.models.openrouter import OpenRouterModel
69+
70+
custom_http_client = AsyncClient(timeout=30)
71+
model = OpenRouterModel(
72+
'google/gemini-2.5-flash-lite',
73+
api_key='your-api-key',
74+
http_client=custom_http_client,
75+
)
76+
agent = Agent(model)
77+
...
78+
```
79+
80+
## Structured Output
81+
82+
You can use OpenRouter models with structured output by providing a Pydantic model as the `output_type`:
83+
84+
```python
85+
from pydantic import BaseModel
86+
from pydantic_ai import Agent
87+
88+
class MathResult(BaseModel):
89+
answer: int
90+
explanation: str
91+
92+
agent = Agent('openrouter:google/gemini-2.5-flash-lite', output_type=MathResult)
93+
result = await agent.run("Calculate 5*3 and explain the multiplication process.")
94+
print(f"Answer: {result.output.answer}")
95+
print(f"Explanation: {result.output.explanation}")
96+
```
97+
98+
The model will validate and parse the response into your specified Pydantic model, allowing type-safe access to structured data fields via `result.output.field_name`.

docs/models/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Pydantic AI is model-agnostic and has built-in support for multiple model provid
1010
* [Cohere](cohere.md)
1111
* [Bedrock](bedrock.md)
1212
* [Hugging Face](huggingface.md)
13+
* [OpenRouter](openrouter.md)
1314

1415
## OpenAI-compatible Providers
1516

@@ -18,7 +19,6 @@ In addition, many providers are compatible with the OpenAI API, and can be used
1819
- [DeepSeek](openai.md#deepseek)
1920
- [Grok (xAI)](openai.md#grok-xai)
2021
- [Ollama](openai.md#ollama)
21-
- [OpenRouter](openai.md#openrouter)
2222
- [Vercel AI Gateway](openai.md#vercel-ai-gateway)
2323
- [Perplexity](openai.md#perplexity)
2424
- [Fireworks AI](openai.md#fireworks-ai)

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ nav:
3434
- models/groq.md
3535
- models/mistral.md
3636
- models/huggingface.md
37+
- models/openrouter.md
3738
- Tools & Toolsets:
3839
- tools.md
3940
- tools-advanced.md
@@ -123,6 +124,7 @@ nav:
123124
- api/models/huggingface.md
124125
- api/models/instrumented.md
125126
- api/models/mistral.md
127+
- api/models/openrouter.md
126128
- api/models/test.md
127129
- api/models/function.md
128130
- api/models/fallback.md

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,7 @@ async def _process_message_history(
11311131
sync_processor = cast(_HistoryProcessorSync, processor)
11321132
messages = await run_in_executor(sync_processor, messages)
11331133

1134+
messages = cast(list[_messages.ModelMessage], messages)
11341135
if len(messages) == 0:
11351136
raise exceptions.UserError('Processed history cannot be empty.')
11361137

pydantic_ai_slim/pydantic_ai/_utils.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import re
77
import time
8+
import types
89
import uuid
910
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterator
1011
from contextlib import asynccontextmanager, suppress
@@ -14,12 +15,21 @@
1415
from types import GenericAlias
1516
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeGuard, TypeVar, get_args, get_origin, overload
1617

18+
__all__ = [
19+
'guard_tool_call_id',
20+
'now_utc',
21+
'number_to_datetime',
22+
'PeekableAsyncStream',
23+
'Unset',
24+
]
25+
26+
from typing import Literal
27+
1728
from anyio.to_thread import run_sync
1829
from pydantic import BaseModel, TypeAdapter
1930
from pydantic.json_schema import JsonSchemaValue
2031
from typing_extensions import (
2132
ParamSpec,
22-
TypeIs,
2333
is_typeddict,
2434
)
2535
from typing_inspection import typing_objects
@@ -47,21 +57,24 @@ async def run_in_executor(func: Callable[_P, _R], *args: _P.args, **kwargs: _P.k
4757
return await run_sync(wrapped_func)
4858

4959

60+
def _is_class_type(obj: Any) -> TypeGuard[type[Any]]:
61+
return isinstance(obj, type) and not isinstance(obj, GenericAlias)
62+
63+
5064
def is_model_like(type_: Any) -> bool:
5165
"""Check if something is a pydantic model, dataclass or typedict.
5266
5367
These should all generate a JSON Schema with `{"type": "object"}` and therefore be usable directly as
5468
function parameters.
5569
"""
70+
if not _is_class_type(type_):
71+
return False
72+
5673
return (
57-
isinstance(type_, type)
58-
and not isinstance(type_, GenericAlias)
59-
and (
60-
issubclass(type_, BaseModel)
61-
or is_dataclass(type_) # pyright: ignore[reportUnknownArgumentType]
62-
or is_typeddict(type_) # pyright: ignore[reportUnknownArgumentType]
63-
or getattr(type_, '__is_model_like__', False) # pyright: ignore[reportUnknownArgumentType]
64-
)
74+
issubclass(type_, BaseModel)
75+
or is_dataclass(type_)
76+
or is_typeddict(type_)
77+
or getattr(type_, '__is_model_like__', False)
6578
)
6679

6780

@@ -311,7 +324,7 @@ async def __anext__(self) -> T:
311324

312325

313326
def get_traceparent(x: AgentRun | AgentRunResult | GraphRun | GraphRunResult) -> str:
314-
return x._traceparent(required=False) or '' # type: ignore[reportPrivateUsage]
327+
return x._traceparent(required=False) or '' # pyright: ignore[reportPrivateUsage]
315328

316329

317330
def dataclasses_no_defaults_repr(self: Any) -> str:
@@ -333,14 +346,14 @@ def number_to_datetime(x: int | float) -> datetime:
333346

334347

335348
@overload
336-
def is_async_callable(obj: AwaitableCallable[T]) -> TypeIs[AwaitableCallable[T]]: ...
349+
def is_async_callable(obj: AwaitableCallable[T]) -> Literal[True]: ...
337350

338351

339352
@overload
340-
def is_async_callable(obj: Any) -> TypeIs[AwaitableCallable[Any]]: ...
353+
def is_async_callable(obj: Any) -> bool: ...
341354

342355

343-
def is_async_callable(obj: Any) -> Any:
356+
def is_async_callable(obj: Any) -> bool:
344357
"""Correctly check if a callable is async.
345358
346359
This function was copied from Starlette:
@@ -349,7 +362,23 @@ def is_async_callable(obj: Any) -> Any:
349362
while isinstance(obj, functools.partial):
350363
obj = obj.func
351364

352-
return inspect.iscoroutinefunction(obj) or (callable(obj) and inspect.iscoroutinefunction(obj.__call__)) # type: ignore
365+
# Case 1: Direct check if obj itself is a coroutine function
366+
if inspect.iscoroutinefunction(obj):
367+
return True
368+
369+
# Case 2: If obj is callable, check its __call__ method
370+
if callable(obj):
371+
call_method = getattr(obj, '__call__', None)
372+
if call_method is None:
373+
return False
374+
375+
if isinstance(call_method, types.MethodType):
376+
call_method = call_method.__func__
377+
378+
if isinstance(call_method, types.FunctionType) and inspect.iscoroutinefunction(call_method):
379+
return True
380+
381+
return False
353382

354383

355384
def _update_mapped_json_schema_refs(s: dict[str, Any], name_mapping: dict[str, str]) -> None:

pydantic_ai_slim/pydantic_ai/ag_ui.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,8 @@ async def run_ag_ui(
379379

380380
if on_complete is not None and run.result is not None:
381381
if _utils.is_async_callable(on_complete):
382-
await on_complete(run.result)
382+
if on_complete is not None:
383+
await on_complete(run.result)
383384
else:
384385
await _utils.run_in_executor(on_complete, run.result)
385386
except _RunError as e:

0 commit comments

Comments
 (0)