Skip to content

Commit 3b6dc5e

Browse files
authored
Fix structured output with nested definitions with Gemini via OpenRouter (#3618)
1 parent 48e3707 commit 3b6dc5e

File tree

5 files changed

+498
-6
lines changed

5 files changed

+498
-6
lines changed

pydantic_ai_slim/pydantic_ai/models/openrouter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,10 @@ class _OpenRouterChoice(chat_completion.Choice):
399399
class _OpenRouterCostDetails:
400400
"""OpenRouter specific cost details."""
401401

402-
upstream_inference_cost: int | None = None
402+
upstream_inference_cost: float | None = None
403+
404+
# TODO rework fields, tests/models/cassettes/test_openrouter/test_openrouter_google_nested_schema.yaml
405+
# shows an `upstream_inference_completions_cost` field as well
403406

404407

405408
class _OpenRouterPromptTokenDetails(completion_usage.PromptTokensDetails):

pydantic_ai_slim/pydantic_ai/providers/openrouter.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations as _annotations
22

33
import os
4+
from dataclasses import replace
45
from typing import overload
56

67
import httpx
78
from openai import AsyncOpenAI
89

910
from pydantic_ai import ModelProfile
11+
from pydantic_ai._json_schema import JsonSchema, JsonSchemaTransformer
1012
from pydantic_ai.exceptions import UserError
1113
from pydantic_ai.models import cached_async_http_client
1214
from pydantic_ai.profiles.amazon import amazon_model_profile
@@ -31,6 +33,64 @@
3133
) from _import_error
3234

3335

36+
class _OpenRouterGoogleJsonSchemaTransformer(JsonSchemaTransformer):
37+
"""Legacy Google JSON schema transformer for OpenRouter compatibility.
38+
39+
OpenRouter's compatibility layer doesn't fully support modern JSON Schema features
40+
like $defs/$ref and anyOf for nullable types. This transformer restores v1.19.0
41+
behavior by inlining definitions and simplifying nullable unions.
42+
43+
See: https://github.com/pydantic/pydantic-ai/issues/3617
44+
"""
45+
46+
def __init__(self, schema: JsonSchema, *, strict: bool | None = None):
47+
super().__init__(schema, strict=strict, prefer_inlined_defs=True, simplify_nullable_unions=True)
48+
49+
def transform(self, schema: JsonSchema) -> JsonSchema:
50+
# Remove properties not supported by Gemini
51+
schema.pop('$schema', None)
52+
schema.pop('title', None)
53+
schema.pop('discriminator', None)
54+
schema.pop('examples', None)
55+
schema.pop('exclusiveMaximum', None)
56+
schema.pop('exclusiveMinimum', None)
57+
58+
if (const := schema.pop('const', None)) is not None:
59+
schema['enum'] = [const]
60+
61+
# Convert enums to string type (legacy Gemini requirement)
62+
if enum := schema.get('enum'):
63+
schema['type'] = 'string'
64+
schema['enum'] = [str(val) for val in enum]
65+
66+
# Convert oneOf to anyOf for discriminated unions
67+
if 'oneOf' in schema and 'type' not in schema:
68+
schema['anyOf'] = schema.pop('oneOf')
69+
70+
# Handle string format -> description
71+
type_ = schema.get('type')
72+
if type_ == 'string' and (fmt := schema.pop('format', None)):
73+
description = schema.get('description')
74+
if description:
75+
schema['description'] = f'{description} (format: {fmt})'
76+
else:
77+
schema['description'] = f'Format: {fmt}'
78+
79+
return schema
80+
81+
82+
def _openrouter_google_model_profile(model_name: str) -> ModelProfile | None:
83+
"""Get the model profile for a Google model accessed via OpenRouter.
84+
85+
Uses the legacy transformer to maintain compatibility with OpenRouter's
86+
translation layer, which doesn't fully support modern JSON Schema features.
87+
"""
88+
profile = google_model_profile(model_name)
89+
if profile is None: # pragma: no cover
90+
return None
91+
return replace(profile, json_schema_transformer=_OpenRouterGoogleJsonSchemaTransformer)
92+
93+
3494
class OpenRouterProvider(Provider[AsyncOpenAI]):
3595
"""Provider for OpenRouter API."""
3696

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

49109
def model_profile(self, model_name: str) -> ModelProfile | None:
50110
provider_to_profile = {
51-
'google': google_model_profile,
111+
'google': _openrouter_google_model_profile,
52112
'openai': openai_model_profile,
53113
'anthropic': anthropic_model_profile,
54114
'mistralai': mistral_model_profile,

0 commit comments

Comments
 (0)