Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pydantic_ai_slim/pydantic_ai/models/openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ class _OpenRouterChoice(chat_completion.Choice):
class _OpenRouterCostDetails:
"""OpenRouter specific cost details."""

upstream_inference_cost: int | None = None
upstream_inference_cost: float | None = None

# TODO rework fields, tests/models/cassettes/test_openrouter/test_openrouter_google_nested_schema.yaml
# shows an `upstream_inference_completions_cost` field as well


class _OpenRouterPromptTokenDetails(completion_usage.PromptTokensDetails):
Expand Down
62 changes: 61 additions & 1 deletion pydantic_ai_slim/pydantic_ai/providers/openrouter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations as _annotations

import os
from dataclasses import replace
from typing import overload

import httpx
from openai import AsyncOpenAI

from pydantic_ai import ModelProfile
from pydantic_ai._json_schema import JsonSchema, JsonSchemaTransformer
from pydantic_ai.exceptions import UserError
from pydantic_ai.models import cached_async_http_client
from pydantic_ai.profiles.amazon import amazon_model_profile
Expand All @@ -31,6 +33,64 @@
) from _import_error


class _OpenRouterGoogleJsonSchemaTransformer(JsonSchemaTransformer):
"""Legacy Google JSON schema transformer for OpenRouter compatibility.

OpenRouter's compatibility layer doesn't fully support modern JSON Schema features
like $defs/$ref and anyOf for nullable types. This transformer restores v1.19.0
behavior by inlining definitions and simplifying nullable unions.

See: https://github.com/pydantic/pydantic-ai/issues/3617
"""

def __init__(self, schema: JsonSchema, *, strict: bool | None = None):
super().__init__(schema, strict=strict, prefer_inlined_defs=True, simplify_nullable_unions=True)

def transform(self, schema: JsonSchema) -> JsonSchema:
# Remove properties not supported by Gemini
schema.pop('$schema', None)
schema.pop('title', None)
schema.pop('discriminator', None)
schema.pop('examples', None)
schema.pop('exclusiveMaximum', None)
schema.pop('exclusiveMinimum', None)

if (const := schema.pop('const', None)) is not None:
schema['enum'] = [const]

# Convert enums to string type (legacy Gemini requirement)
if enum := schema.get('enum'):
schema['type'] = 'string'
schema['enum'] = [str(val) for val in enum]

# Convert oneOf to anyOf for discriminated unions
if 'oneOf' in schema and 'type' not in schema:
schema['anyOf'] = schema.pop('oneOf')

# Handle string format -> description
type_ = schema.get('type')
if type_ == 'string' and (fmt := schema.pop('format', None)):
description = schema.get('description')
if description:
schema['description'] = f'{description} (format: {fmt})'
else:
schema['description'] = f'Format: {fmt}'

return schema


def _openrouter_google_model_profile(model_name: str) -> ModelProfile | None:
"""Get the model profile for a Google model accessed via OpenRouter.

Uses the legacy transformer to maintain compatibility with OpenRouter's
translation layer, which doesn't fully support modern JSON Schema features.
"""
profile = google_model_profile(model_name)
if profile is None: # pragma: no cover
return None
return replace(profile, json_schema_transformer=_OpenRouterGoogleJsonSchemaTransformer)


class OpenRouterProvider(Provider[AsyncOpenAI]):
"""Provider for OpenRouter API."""

Expand All @@ -48,7 +108,7 @@ def client(self) -> AsyncOpenAI:

def model_profile(self, model_name: str) -> ModelProfile | None:
provider_to_profile = {
'google': google_model_profile,
'google': _openrouter_google_model_profile,
'openai': openai_model_profile,
'anthropic': anthropic_model_profile,
'mistralai': mistral_model_profile,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
interactions:
- request:
headers:
accept:
- application/json
accept-encoding:
- gzip, deflate
connection:
- keep-alive
content-length:
- '914'
content-type:
- application/json
host:
- openrouter.ai
method: POST
parsed_body:
messages:
- content: It's a house with a ground floor that has an entryway, a living room and a garage.
role: user
model: google/gemini-2.5-flash
stream: false
tool_choice: required
tools:
- function:
description: Insert a level with its spaces.
name: insert_level_with_spaces
parameters:
description: Insert a level with its spaces.
properties:
level:
default: null
nullable: true
properties:
level_name:
type: string
level_type:
enum:
- ground
- basement
- floor
- attic
type: string
required:
- level_name
- level_type
type: object
spaces:
items:
properties:
space_name:
type: string
space_type:
enum:
- entryway
- living-room
- kitchen
- bedroom
- bathroom
- garage
type: string
required:
- space_name
- space_type
type: object
type: array
required:
- spaces
type: object
strict: true
type: function
uri: https://openrouter.ai/api/v1/chat/completions
response:
headers:
access-control-allow-origin:
- '*'
connection:
- keep-alive
content-length:
- '1163'
content-type:
- application/json
permissions-policy:
- payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com"
"https://hooks.stripe.com")
referrer-policy:
- no-referrer, strict-origin-when-cross-origin
transfer-encoding:
- chunked
vary:
- Accept-Encoding
parsed_body:
choices:
- finish_reason: tool_calls
index: 0
logprobs: null
message:
content: ''
reasoning: null
refusal: null
role: assistant
tool_calls:
- function:
arguments: '{"level":{"level_name":"ground floor","level_type":"ground"},"spaces":[{"space_name":"entryway","space_type":"entryway"},{"space_type":"living-room","space_name":"living
room"},{"space_type":"garage","space_name":"garage"}]}'
name: insert_level_with_spaces
id: tool_insert_level_with_spaces_Dp2OGkGjAwsmqUUwHwmj
index: 0
type: function
native_finish_reason: STOP
created: 1764781178
id: gen-1764781178-SLI9BlpnJoXSOwuii48G
model: google/gemini-2.5-flash
object: chat.completion
provider: Google AI Studio
usage:
completion_tokens: 89
completion_tokens_details:
image_tokens: 0
reasoning_tokens: 0
cost: 0
cost_details:
upstream_inference_completions_cost: 0.0002225
upstream_inference_cost: 0.000296
upstream_inference_prompt_cost: 7.35e-05
is_byok: true
prompt_tokens: 245
prompt_tokens_details:
audio_tokens: 0
cached_tokens: 0
video_tokens: 0
total_tokens: 334
status:
code: 200
message: OK
version: 1
69 changes: 69 additions & 0 deletions tests/models/test_openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,72 @@ class FindEducationContentFilters(BaseModel):
}
]
)


async def test_openrouter_google_nested_schema(allow_model_requests: None, openrouter_api_key: str) -> None:
"""Test that nested schemas with $defs/$ref work correctly with OpenRouter + Gemini.

This verifies the fix for https://github.com/pydantic/pydantic-ai/issues/3617
where OpenRouter's translation layer didn't support modern JSON Schema features.
"""
from enum import Enum

provider = OpenRouterProvider(api_key=openrouter_api_key)

class LevelType(str, Enum):
ground = 'ground'
basement = 'basement'
floor = 'floor'
attic = 'attic'

class SpaceType(str, Enum):
entryway = 'entryway'
living_room = 'living-room'
kitchen = 'kitchen'
bedroom = 'bedroom'
bathroom = 'bathroom'
garage = 'garage'

class InsertLevelArg(BaseModel):
level_name: str
level_type: LevelType

class SpaceArg(BaseModel):
space_name: str
space_type: SpaceType

class InsertLevelWithSpaces(BaseModel):
"""Insert a level with its spaces."""

level: InsertLevelArg | None = None
spaces: list[SpaceArg]

model = OpenRouterModel('google/gemini-2.5-flash', provider=provider)
response = await model_request(
model,
[
ModelRequest.user_text_prompt(
"It's a house with a ground floor that has an entryway, a living room and a garage."
)
],
model_request_parameters=ModelRequestParameters(
function_tools=[
ToolDefinition(
name='insert_level_with_spaces',
description=InsertLevelWithSpaces.__doc__ or '',
parameters_json_schema=InsertLevelWithSpaces.model_json_schema(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use an Agent with a @agent.tool so we are sure that it goes through the exact same processing that users would see

)
],
allow_text_output=False,
),
)

assert response.parts == snapshot(
[
ToolCallPart(
tool_name='insert_level_with_spaces',
args='{"level":{"level_name":"ground floor","level_type":"ground"},"spaces":[{"space_name":"entryway","space_type":"entryway"},{"space_type":"living-room","space_name":"living room"},{"space_type":"garage","space_name":"garage"}]}',
tool_call_id='tool_insert_level_with_spaces_Dp2OGkGjAwsmqUUwHwmj',
)
]
)
48 changes: 44 additions & 4 deletions tests/providers/test_openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pydantic_ai.profiles.anthropic import AnthropicJsonSchemaTransformer, anthropic_model_profile
from pydantic_ai.profiles.cohere import cohere_model_profile
from pydantic_ai.profiles.deepseek import deepseek_model_profile
from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile
from pydantic_ai.profiles.google import google_model_profile
from pydantic_ai.profiles.grok import grok_model_profile
from pydantic_ai.profiles.meta import meta_model_profile
from pydantic_ai.profiles.mistral import mistral_model_profile
Expand All @@ -26,7 +26,10 @@
import openai

from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider
from pydantic_ai.providers.openrouter import (
OpenRouterProvider,
_OpenRouterGoogleJsonSchemaTransformer, # pyright: ignore[reportPrivateUsage]
)


pytestmark = [
Expand Down Expand Up @@ -109,12 +112,12 @@ def test_openrouter_provider_model_profile(mocker: MockerFixture):
google_profile = provider.model_profile('google/gemini-2.5-pro-preview')
google_model_profile_mock.assert_called_with('gemini-2.5-pro-preview')
assert google_profile is not None
assert google_profile.json_schema_transformer == GoogleJsonSchemaTransformer
assert google_profile.json_schema_transformer == _OpenRouterGoogleJsonSchemaTransformer

google_profile = provider.model_profile('google/gemma-3n-e4b-it:free')
google_model_profile_mock.assert_called_with('gemma-3n-e4b-it')
assert google_profile is not None
assert google_profile.json_schema_transformer == GoogleJsonSchemaTransformer
assert google_profile.json_schema_transformer == _OpenRouterGoogleJsonSchemaTransformer

openai_profile = provider.model_profile('openai/o1-mini')
openai_model_profile_mock.assert_called_with('o1-mini')
Expand Down Expand Up @@ -170,3 +173,40 @@ def test_openrouter_provider_model_profile(mocker: MockerFixture):
unknown_profile = provider.model_profile('unknown/model')
assert unknown_profile is not None
assert unknown_profile.json_schema_transformer == OpenAIJsonSchemaTransformer


def test_openrouter_google_json_schema_transformer():
"""Test _OpenRouterGoogleJsonSchemaTransformer covers all transformation cases."""
schema = {
'$schema': 'http://json-schema.org/draft-07/schema#',
'title': 'TestSchema',
'type': 'object',
'properties': {
'status': {'const': 'active'},
'category': {'oneOf': [{'type': 'string'}, {'type': 'integer'}]},
'email': {'type': 'string', 'format': 'email', 'description': 'User email'},
'date': {'type': 'string', 'format': 'date'},
},
}

transformer = _OpenRouterGoogleJsonSchemaTransformer(schema)
result = transformer.walk()

# const -> enum conversion (line 59)
assert result['properties']['status'] == {'enum': ['active'], 'type': 'string'}

# oneOf -> anyOf conversion (line 68)
assert 'anyOf' in result['properties']['category']
assert 'oneOf' not in result['properties']['category']

# format -> description with existing description (lines 73-75)
assert result['properties']['email']['description'] == 'User email (format: email)'
assert 'format' not in result['properties']['email']

# format -> description without existing description (lines 76-77)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These line numbers will get outdated; please don't include them

assert result['properties']['date']['description'] == 'Format: date'
assert 'format' not in result['properties']['date']

# Removed fields
assert '$schema' not in result
assert 'title' not in result