Skip to content

Commit 9f64a41

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

File tree

13 files changed

+2427
-127
lines changed

13 files changed

+2427
-127
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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
from pydantic_ai.models.openrouter import OpenRouterModel
30+
31+
model = OpenRouterModel('google/gemini-2.5-flash-lite', api_key='your-api-key')
32+
agent = Agent(model)
33+
...
34+
```
35+
36+
Or initialise the model directly with just the model name:
37+
38+
```python
39+
from pydantic_ai import Agent
40+
from pydantic_ai.models.openrouter import OpenRouterModel
41+
42+
model = OpenRouterModel('google/gemini-2.5-flash-lite')
43+
agent = Agent(model)
44+
...
45+
```
46+
47+
## Custom API Key
48+
49+
You can provide a custom API key directly to the model:
50+
51+
```python
52+
from pydantic_ai import Agent
53+
from pydantic_ai.models.openrouter import OpenRouterModel
54+
55+
model = OpenRouterModel(
56+
'google/gemini-2.5-flash-lite', api_key='your-api-key'
57+
)
58+
agent = Agent(model)
59+
...
60+
```
61+
62+
## Custom HTTP Client
63+
64+
You can customize the OpenRouter model with a custom `httpx.AsyncClient`:
65+
66+
```python
67+
from httpx import AsyncClient
68+
69+
from pydantic_ai import Agent
70+
from pydantic_ai.models.openrouter import OpenRouterModel
71+
72+
custom_http_client = AsyncClient(timeout=30)
73+
model = OpenRouterModel(
74+
'google/gemini-2.5-flash-lite',
75+
api_key='your-api-key',
76+
http_client=custom_http_client,
77+
)
78+
agent = Agent(model)
79+
...
80+
```
81+
82+
## Structured Output
83+
84+
You can use OpenRouter models with structured output by providing a Pydantic model as the `output_type`:
85+
86+
```python {noqa="I001"}
87+
from pydantic import BaseModel
88+
89+
from pydantic_ai import Agent
90+
from pydantic_ai.models.openrouter import OpenRouterModel
91+
92+
class OlympicsLocation(BaseModel):
93+
city: str
94+
country: str
95+
96+
model = OpenRouterModel('google/gemini-2.5-flash-lite', api_key='your-api-key')
97+
agent = Agent(model, output_type=OlympicsLocation)
98+
99+
result = agent.run_sync('Where were the olympics held in 2012?')
100+
print(f'City: {result.output.city}')
101+
#> City: London
102+
print(f'Country: {result.output.country}')
103+
#> Country: United Kingdom
104+
```
105+
106+
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,7 +1119,8 @@ async def _process_message_history(
11191119

11201120
if is_async_callable(processor):
11211121
if takes_ctx:
1122-
messages = await processor(run_context, messages)
1122+
async_processor_with_ctx = cast(_HistoryProcessorAsyncWithCtx[DepsT], processor)
1123+
messages = await async_processor_with_ctx(run_context, messages)
11231124
else:
11241125
async_processor = cast(_HistoryProcessorAsync, processor)
11251126
messages = await async_processor(messages)

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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import asyncio
910
import json
1011
import uuid
1112
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
@@ -24,7 +25,6 @@
2425

2526
from pydantic import BaseModel, ValidationError
2627

27-
from . import _utils
2828
from ._agent_graph import CallToolsNode, ModelRequestNode
2929
from .agent import AbstractAgent, AgentRun, AgentRunResult
3030
from .exceptions import UserError
@@ -378,10 +378,9 @@ async def run_ag_ui(
378378
yield encoder.encode(event)
379379

380380
if on_complete is not None and run.result is not None:
381-
if _utils.is_async_callable(on_complete):
382-
await on_complete(run.result)
383-
else:
384-
await _utils.run_in_executor(on_complete, run.result)
381+
result = on_complete(run.result)
382+
if asyncio.iscoroutine(result):
383+
await result
385384
except _RunError as e:
386385
yield encoder.encode(
387386
RunErrorEvent(message=e.message, code=e.code),

0 commit comments

Comments
 (0)