From 227e873c786efd84d2d545ce58cd08f9dd9e7192 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 3 Oct 2025 18:40:42 -0600 Subject: [PATCH 01/42] Add OpenRouter support and test coverage --- .../pydantic_ai/models/openrouter.py | 287 ++++++++++++++++++ .../pydantic_ai/providers/openrouter.py | 22 +- .../test_openrouter_errors_raised.yaml | 161 ++++++++++ .../test_openrouter_infer_provider.yaml | 76 +++++ .../test_openrouter_with_native_options.yaml | 82 +++++ .../test_openrouter_with_preset.yaml | 75 +++++ tests/models/test_openrouter.py | 80 +++++ tests/providers/test_openrouter.py | 14 +- 8 files changed, 793 insertions(+), 4 deletions(-) create mode 100644 pydantic_ai_slim/pydantic_ai/models/openrouter.py create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_errors_raised.yaml create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_with_native_options.yaml create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_with_preset.yaml create mode 100644 tests/models/test_openrouter.py diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py new file mode 100644 index 0000000000..900f78d773 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -0,0 +1,287 @@ +from typing import Any, Literal, cast + +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletion +from pydantic import BaseModel +from typing_extensions import TypedDict + +from ..messages import ModelMessage, ModelResponse +from ..profiles import ModelProfileSpec +from ..providers import Provider, infer_provider +from ..settings import ModelSettings +from . import ModelRequestParameters, check_allow_model_requests +from .openai import OpenAIChatModel, OpenAIChatModelSettings + + +class OpenRouterMaxprice(TypedDict, total=False): + """The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion.""" + + prompt: int + completion: int + image: int + audio: int + request: int + + +LatestOpenRouterSlugs = Literal[ + 'z-ai', + 'cerebras', + 'venice', + 'moonshotai', + 'morph', + 'stealth', + 'wandb', + 'klusterai', + 'openai', + 'sambanova', + 'amazon-bedrock', + 'mistral', + 'nextbit', + 'atoma', + 'ai21', + 'minimax', + 'baseten', + 'anthropic', + 'featherless', + 'groq', + 'lambda', + 'azure', + 'ncompass', + 'deepseek', + 'hyperbolic', + 'crusoe', + 'cohere', + 'mancer', + 'avian', + 'perplexity', + 'novita', + 'siliconflow', + 'switchpoint', + 'xai', + 'inflection', + 'fireworks', + 'deepinfra', + 'inference-net', + 'inception', + 'atlas-cloud', + 'nvidia', + 'alibaba', + 'friendli', + 'infermatic', + 'targon', + 'ubicloud', + 'aion-labs', + 'liquid', + 'nineteen', + 'cloudflare', + 'nebius', + 'chutes', + 'enfer', + 'crofai', + 'open-inference', + 'phala', + 'gmicloud', + 'meta', + 'relace', + 'parasail', + 'together', + 'google-ai-studio', + 'google-vertex', +] +"""Known providers in the OpenRouter marketplace""" + +OpenRouterSlug = str | LatestOpenRouterSlugs +"""Possible OpenRouter provider slugs. + +Since OpenRouter is constantly updating their list of providers, we explicitly list some known providers but +allow any name in the type hints. +See [the OpenRouter API](https://openrouter.ai/docs/api-reference/list-available-providers) for a full list. +""" + +Transforms = Literal['middle-out'] +"""Available messages transforms for OpenRouter models with limited token windows. + +Currently only supports 'middle-out', but is expected to grow in the future. +""" + + +class OpenRouterPreferences(TypedDict, total=False): + """Represents the 'Provider' object from the OpenRouter API.""" + + order: list[OpenRouterSlug] + """List of provider slugs to try in order (e.g. ["anthropic", "openai"]). [See details](https://openrouter.ai/docs/features/provider-routing#ordering-specific-providers)""" + + allow_fallbacks: bool + """Whether to allow backup providers when the primary is unavailable. [See details](https://openrouter.ai/docs/features/provider-routing#disabling-fallbacks)""" + + require_parameters: bool + """Only use providers that support all parameters in your request.""" + + data_collection: Literal['allow', 'deny'] + """Control whether to use providers that may store data. [See details](https://openrouter.ai/docs/features/provider-routing#requiring-providers-to-comply-with-data-policies)""" + + zdr: bool + """Restrict routing to only ZDR (Zero Data Retention) endpoints. [See details](https://openrouter.ai/docs/features/provider-routing#zero-data-retention-enforcement)""" + + only: list[OpenRouterSlug] + """List of provider slugs to allow for this request. [See details](https://openrouter.ai/docs/features/provider-routing#allowing-only-specific-providers)""" + + ignore: list[str] + """List of provider slugs to skip for this request. [See details](https://openrouter.ai/docs/features/provider-routing#ignoring-providers)""" + + quantizations: list[Literal['int4', 'int8', 'fp4', 'fp6', 'fp8', 'fp16', 'bf16', 'fp32', 'unknown']] + """List of quantization levels to filter by (e.g. ["int4", "int8"]). [See details](https://openrouter.ai/docs/features/provider-routing#quantization)""" + + sort: Literal['price', 'throughput', 'latency'] + """Sort providers by price or throughput. (e.g. "price" or "throughput"). [See details](https://openrouter.ai/docs/features/provider-routing#provider-sorting)""" + + max_price: OpenRouterMaxprice + """The maximum pricing you want to pay for this request. [See details](https://openrouter.ai/docs/features/provider-routing#max-price)""" + + +class OpenRouterModelSettings(ModelSettings, total=False): + """Settings used for an OpenRouter model request.""" + + # ALL FIELDS MUST BE `openrouter_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS. + + openrouter_models: list[str] + """A list of fallback models. + + These models will be tried, in order, if the main model returns an error. [See details](https://openrouter.ai/docs/features/model-routing#the-models-parameter) + """ + + openrouter_preferences: OpenRouterPreferences + """OpenRouter routes requests to the best available providers for your model. By default, requests are load balanced across the top providers to maximize uptime. + + You can customize how your requests are routed using the provider object. [See more](https://openrouter.ai/docs/features/provider-routing)""" + + openrouter_preset: str + """Presets allow you to separate your LLM configuration from your code. + + Create and manage presets through the OpenRouter web application to control provider routing, model selection, system prompts, and other parameters, then reference them in OpenRouter API requests. [See more](https://openrouter.ai/docs/features/presets)""" + + openrouter_transforms: list[Transforms] + """To help with prompts that exceed the maximum context size of a model. + + Transforms work by removing or truncating messages from the middle of the prompt, until the prompt fits within the model’s context window. [See more](https://openrouter.ai/docs/features/message-transforms) + """ + + +class OpenRouterErrorResponse(BaseModel): + """Represents error responses from upstream LLM provider relayed by OpenRouter. + + Attributes: + code: The error code returned by LLM provider. + message: The error message returned by OpenRouter + metadata: Additional error context provided by OpenRouter. + + See: https://openrouter.ai/docs/api-reference/errors + """ + + code: int + message: str + metadata: dict[str, Any] | None + + +class OpenRouterChatCompletion(ChatCompletion): + """Extends ChatCompletion with OpenRouter-specific attributes. + + This class extends the base ChatCompletion model to include additional + fields returned specifically by the OpenRouter API. + + Attributes: + provider: The name of the upstream LLM provider (e.g., "Anthropic", + "OpenAI", etc.) that processed the request through OpenRouter. + """ + + provider: str + + +class OpenRouterModel(OpenAIChatModel): + """Extends OpenAIModel to capture extra metadata for Openrouter.""" + + def __init__( + self, + model_name: str, + *, + provider: Literal['openrouter'] | Provider[AsyncOpenAI] = 'openrouter', + profile: ModelProfileSpec | None = None, + settings: ModelSettings | None = None, + ): + """Initialize an OpenRouter model. + + Args: + model_name: The name of the model to use. + provider: The provider to use for authentication and API access. Currently, uses OpenAI as the internal client. Can be either the string + 'openai' or an instance of `Provider[AsyncOpenAI]`. If not provided, a new provider will be + created using the other parameters. + profile: The model profile to use. Defaults to a profile picked by the provider based on the model name. + json_mode_schema_prompt: The prompt to show when the model expects a JSON object as input. + settings: Model-specific settings that will be used as defaults for this model. + """ + self._model_name = model_name + + if isinstance(provider, str): + provider = infer_provider(provider) + self._provider = provider + self.client = provider.client + + super().__init__(model_name, provider=provider, profile=profile or provider.model_profile, settings=settings) + + async def request( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> ModelResponse: + check_allow_model_requests() + transformed_settings = OpenRouterModel._openrouter_settings_to_openai_settings( + cast(OpenRouterModelSettings, model_settings or {}) + ) + + response = await super()._completions_create( + messages=messages, + stream=False, + model_settings=transformed_settings, + model_request_parameters=model_request_parameters, + ) + + model_response = self._process_response(response) + return model_response + + def _process_response(self, response: ChatCompletion | str) -> ModelResponse: + model_response = super()._process_response(response=response) + response = cast(ChatCompletion, response) # If above did not raise an error, we can assume response != str + + if openrouter_provider := getattr(response, 'provider', None): + model_response.provider_name = openrouter_provider + + return model_response + + @staticmethod + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: + """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. + + Args: + model_settings: The 'OpenRouterModelSettings' object to transform. + + Returns: + An 'OpenAIChatModelSettings' object with equivalent settings. + """ + extra_body: dict[str, Any] = {} + + if models := model_settings.get('openrouter_models'): + extra_body['models'] = models + if provider := model_settings.get('openrouter_preferences'): + extra_body['provider'] = provider + if preset := model_settings.get('openrouter_preset'): + extra_body['preset'] = preset + if transforms := model_settings.get('openrouter_transforms'): + extra_body['transforms'] = transforms + + base_keys = ModelSettings.__annotations__.keys() + base_data: dict[str, Any] = {k: model_settings[k] for k in base_keys if k in model_settings} + + new_settings = OpenAIChatModelSettings(**base_data, extra_body=extra_body) + + return new_settings diff --git a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py index 33745ada29..d54ad6f343 100644 --- a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py @@ -81,6 +81,12 @@ def __init__(self, *, api_key: str) -> None: ... @overload def __init__(self, *, api_key: str, http_client: httpx.AsyncClient) -> None: ... + @overload + def __init__(self, *, api_key: str, http_referer: str, x_title: str) -> None: ... + + @overload + def __init__(self, *, api_key: str, http_referer: str, x_title: str, http_client: httpx.AsyncClient) -> None: ... + @overload def __init__(self, *, openai_client: AsyncOpenAI | None = None) -> None: ... @@ -88,6 +94,8 @@ def __init__( self, *, api_key: str | None = None, + http_referer: str | None = None, + x_title: str | None = None, openai_client: AsyncOpenAI | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: @@ -98,10 +106,20 @@ def __init__( 'to use the OpenRouter provider.' ) + attribution_headers: dict[str, str] = {} + if http_referer := http_referer or os.getenv('OPENROUTER_HTTP_REFERER'): + attribution_headers['HTTP-Referer'] = http_referer + if x_title := x_title or os.getenv('OPENROUTER_X_TITLE'): + attribution_headers['X-Title'] = x_title + if openai_client is not None: self._client = openai_client elif http_client is not None: - self._client = AsyncOpenAI(base_url=self.base_url, api_key=api_key, http_client=http_client) + self._client = AsyncOpenAI( + base_url=self.base_url, api_key=api_key, http_client=http_client, default_headers=attribution_headers + ) else: http_client = cached_async_http_client(provider='openrouter') - self._client = AsyncOpenAI(base_url=self.base_url, api_key=api_key, http_client=http_client) + self._client = AsyncOpenAI( + base_url=self.base_url, api_key=api_key, http_client=http_client, default_headers=attribution_headers + ) diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_errors_raised.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_errors_raised.yaml new file mode 100644 index 0000000000..dacb9f72c9 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_errors_raised.yaml @@ -0,0 +1,161 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '158' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me a joke. + role: user + model: google/gemini-2.0-flash-exp:free + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + 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: + error: + code: 429 + message: Provider returned error + metadata: + provider_name: Google + raw: 'google/gemini-2.0-flash-exp:free is temporarily rate-limited upstream. Please retry shortly, or add your own + key to accumulate your rate limits: https://openrouter.ai/settings/integrations' + user_id: user_2wT5ElBE4Es3R4QrNLpZiXICmQP + status: + code: 429 + message: Too Many Requests +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '158' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me a joke. + role: user + model: google/gemini-2.0-flash-exp:free + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + 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: + error: + code: 429 + message: Provider returned error + metadata: + provider_name: Google + raw: 'google/gemini-2.0-flash-exp:free is temporarily rate-limited upstream. Please retry shortly, or add your own + key to accumulate your rate limits: https://openrouter.ai/settings/integrations' + user_id: user_2wT5ElBE4Es3R4QrNLpZiXICmQP + status: + code: 429 + message: Too Many Requests +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '158' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me a joke. + role: user + model: google/gemini-2.0-flash-exp:free + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + 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: + error: + code: 429 + message: Provider returned error + metadata: + provider_name: Google + raw: 'google/gemini-2.0-flash-exp:free is temporarily rate-limited upstream. Please retry shortly, or add your own + key to accumulate your rate limits: https://openrouter.ai/settings/integrations' + user_id: user_2wT5ElBE4Es3R4QrNLpZiXICmQP + status: + code: 429 + message: Too Many Requests +version: 1 diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml new file mode 100644 index 0000000000..5e0361edad --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml @@ -0,0 +1,76 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '154' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me a joke. + role: user + model: google/gemini-2.5-flash-lite + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '591' + 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: stop + index: 0 + logprobs: null + message: + content: |- + Why did the scarecrow win an award? + + Because he was outstanding in his field! + reasoning: null + refusal: null + role: assistant + native_finish_reason: STOP + created: 1759503832 + id: gen-1759503832-O5IKtEwGGvVaTr3Thz3w + model: google/gemini-2.5-flash-lite + object: chat.completion + provider: Google + usage: + completion_tokens: 18 + completion_tokens_details: + image_tokens: 0 + reasoning_tokens: 0 + prompt_tokens: 8 + prompt_tokens_details: + cached_tokens: 0 + total_tokens: 26 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_with_native_options.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_with_native_options.yaml new file mode 100644 index 0000000000..b073b87179 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_with_native_options.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '193' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Who are you + role: user + model: google/gemini-2.0-flash-exp:free + models: + - x-ai/grok-4 + provider: + only: + - xai + stream: false + transforms: + - middle-out + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '1067' + 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: stop + index: 0 + logprobs: null + message: + content: |- + I'm Grok, a helpful and maximally truthful AI built by xAI. I'm not based on any other companies' models—instead, I'm inspired by the Hitchhiker's Guide to the Galaxy and JARVIS from Iron Man. My goal is to assist with questions, provide information, and maybe crack a joke or two along the way. + + What can I help you with today? + reasoning: null + refusal: null + role: assistant + native_finish_reason: stop + created: 1759509677 + id: gen-1759509677-MpJiZ3ZkiGU3lnbM8QKo + model: x-ai/grok-4 + object: chat.completion + provider: xAI + system_fingerprint: fp_19e21a36c0 + usage: + completion_tokens: 240 + completion_tokens_details: + reasoning_tokens: 165 + prompt_tokens: 687 + prompt_tokens_details: + audio_tokens: 0 + cached_tokens: 682 + total_tokens: 927 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_with_preset.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_with_preset.yaml new file mode 100644 index 0000000000..bd85de5b07 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_with_preset.yaml @@ -0,0 +1,75 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '131' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Trains + role: user + model: google/gemini-2.5-flash-lite + preset: '@preset/comedian' + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '617' + 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: stop + index: 0 + logprobs: null + message: + content: |- + Why did the train break up with the track? + + Because it felt like their relationship was going nowhere. + reasoning: null + refusal: null + role: assistant + native_finish_reason: STOP + created: 1759510642 + id: gen-1759510642-J9qupM2EtKoYTfG7ehDn + model: google/gemini-2.5-flash-lite + object: chat.completion + provider: Google + usage: + completion_tokens: 21 + completion_tokens_details: + image_tokens: 0 + reasoning_tokens: 0 + prompt_tokens: 31 + prompt_tokens_details: + cached_tokens: 0 + total_tokens: 52 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py new file mode 100644 index 0000000000..f66131f034 --- /dev/null +++ b/tests/models/test_openrouter.py @@ -0,0 +1,80 @@ +from typing import cast + +import pytest +from inline_snapshot import snapshot + +from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart +from pydantic_ai.direct import model_request +from pydantic_ai.models.openrouter import OpenRouterModelSettings + +from ..conftest import try_import + +with try_import() as imports_successful: + from pydantic_ai.models.openrouter import OpenRouterModel + from pydantic_ai.providers.openrouter import OpenRouterProvider + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='openai not installed'), + pytest.mark.vcr, + pytest.mark.anyio, +] + + +async def test_openrouter_infer_provider(allow_model_requests: None) -> None: + model = OpenRouterModel('google/gemini-2.5-flash-lite') + agent = Agent(model, instructions='Be helpful.', retries=1) + response = await agent.run('Tell me a joke.') + assert response.output == snapshot( + """\ +Why did the scarecrow win an award? + +Because he was outstanding in his field!\ +""" + ) + + +async def test_openrouter_with_preset(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.5-flash-lite', provider=provider) + settings = OpenRouterModelSettings(openrouter_preset='@preset/comedian') + response = await model_request(model, [ModelRequest.user_text_prompt('Trains')], model_settings=settings) + text_part = cast(TextPart, response.parts[0]) + assert text_part.content == snapshot( + """\ +Why did the train break up with the track? + +Because it felt like their relationship was going nowhere.\ +""" + ) + + +async def test_openrouter_with_native_options(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + # These specific settings will force OpenRouter to use the fallback model, since Gemini is not available via the xAI provider. + settings = OpenRouterModelSettings( + openrouter_models=['x-ai/grok-4'], + openrouter_transforms=['middle-out'], + openrouter_preferences={'only': ['xai']}, + ) + response = await model_request(model, [ModelRequest.user_text_prompt('Who are you')], model_settings=settings) + text_part = cast(TextPart, response.parts[0]) + assert text_part.content == snapshot( + """\ +I'm Grok, a helpful and maximally truthful AI built by xAI. I'm not based on any other companies' models—instead, I'm inspired by the Hitchhiker's Guide to the Galaxy and JARVIS from Iron Man. My goal is to assist with questions, provide information, and maybe crack a joke or two along the way. + +What can I help you with today?\ +""" + ) + assert response.provider_name == 'xAI' + + +async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + agent = Agent(model, instructions='Be helpful.', retries=1) + with pytest.raises(ModelHTTPError) as exc_info: + await agent.run('Tell me a joke.') + assert str(exc_info.value) == snapshot( + "status_code: 429, model_name: google/gemini-2.0-flash-exp:free, body: {'code': 429, 'message': 'Provider returned error', 'metadata': {'provider_name': 'Google', 'raw': 'google/gemini-2.0-flash-exp:free is temporarily rate-limited upstream. Please retry shortly, or add your own key to accumulate your rate limits: https://openrouter.ai/settings/integrations'}}" + ) diff --git a/tests/providers/test_openrouter.py b/tests/providers/test_openrouter.py index acdf166c50..a070b936b7 100644 --- a/tests/providers/test_openrouter.py +++ b/tests/providers/test_openrouter.py @@ -25,7 +25,7 @@ with try_import() as imports_successful: import openai - from pydantic_ai.models.openai import OpenAIChatModel + from pydantic_ai.models.openrouter import OpenRouterModel from pydantic_ai.providers.openrouter import OpenRouterProvider @@ -44,6 +44,16 @@ def test_openrouter_provider(): assert provider.client.api_key == 'api-key' +def test_openrouter_provider_with_app_attribution(): + provider = OpenRouterProvider(api_key='api-key', http_referer='test.com', x_title='test') + assert provider.name == 'openrouter' + assert provider.base_url == 'https://openrouter.ai/api/v1' + assert isinstance(provider.client, openai.AsyncOpenAI) + assert provider.client.api_key == 'api-key' + assert provider.client.default_headers['X-Title'] == 'test' + assert provider.client.default_headers['HTTP-Referer'] == 'test.com' + + def test_openrouter_provider_need_api_key(env: TestEnv) -> None: env.remove('OPENROUTER_API_KEY') with pytest.raises( @@ -70,7 +80,7 @@ def test_openrouter_pass_openai_client() -> None: async def test_openrouter_with_google_model(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) - model = OpenAIChatModel('google/gemini-2.0-flash-exp:free', provider=provider) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) agent = Agent(model, instructions='Be helpful.') response = await agent.run('Tell me a joke.') assert response.output == snapshot("""\ From c3c1546fec25b6242b7f2adcc09a351984a1d744 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 4 Oct 2025 23:35:43 -0600 Subject: [PATCH 02/42] Add OpenRouter reasoning config and refactor response details --- .../pydantic_ai/models/openrouter.py | 140 ++++++++---------- .../test_openrouter_infer_provider.yaml | 76 ---------- tests/models/test_openrouter.py | 16 +- 3 files changed, 62 insertions(+), 170 deletions(-) delete mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 900f78d773..93f5df0f15 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -2,14 +2,13 @@ from openai import AsyncOpenAI from openai.types.chat import ChatCompletion -from pydantic import BaseModel from typing_extensions import TypedDict -from ..messages import ModelMessage, ModelResponse +from ..messages import ModelResponse from ..profiles import ModelProfileSpec -from ..providers import Provider, infer_provider +from ..providers import Provider from ..settings import ModelSettings -from . import ModelRequestParameters, check_allow_model_requests +from . import ModelRequestParameters from .openai import OpenAIChatModel, OpenAIChatModelSettings @@ -139,6 +138,27 @@ class OpenRouterPreferences(TypedDict, total=False): """The maximum pricing you want to pay for this request. [See details](https://openrouter.ai/docs/features/provider-routing#max-price)""" +class OpenRouterReasoning(TypedDict, total=False): + """Configuration for reasoning tokens in OpenRouter requests. + + Reasoning tokens allow models to show their step-by-step thinking process. + You can configure this using either OpenAI-style effort levels or Anthropic-style + token limits, but not both simultaneously. + """ + + effort: Literal['high', 'medium', 'low'] + """OpenAI-style reasoning effort level. Cannot be used with max_tokens.""" + + max_tokens: int + """Anthropic-style specific token limit for reasoning. Cannot be used with effort.""" + + exclude: bool + """Whether to exclude reasoning tokens from the response. Default is False. All models support this.""" + + enabled: bool + """Whether to enable reasoning with default parameters. Default is inferred from effort or max_tokens.""" + + class OpenRouterModelSettings(ModelSettings, total=False): """Settings used for an OpenRouter model request.""" @@ -166,35 +186,39 @@ class OpenRouterModelSettings(ModelSettings, total=False): Transforms work by removing or truncating messages from the middle of the prompt, until the prompt fits within the model’s context window. [See more](https://openrouter.ai/docs/features/message-transforms) """ + openrouter_reasoning: OpenRouterReasoning + """To control the reasoning tokens in the request. -class OpenRouterErrorResponse(BaseModel): - """Represents error responses from upstream LLM provider relayed by OpenRouter. + The reasoning config object consolidates settings for controlling reasoning strength across different models. [See more](https://openrouter.ai/docs/use-cases/reasoning-tokens) + """ - Attributes: - code: The error code returned by LLM provider. - message: The error message returned by OpenRouter - metadata: Additional error context provided by OpenRouter. - See: https://openrouter.ai/docs/api-reference/errors - """ +def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: + """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. - code: int - message: str - metadata: dict[str, Any] | None + Args: + model_settings: The 'OpenRouterModelSettings' object to transform. + Returns: + An 'OpenAIChatModelSettings' object with equivalent settings. + """ + extra_body: dict[str, Any] = {} -class OpenRouterChatCompletion(ChatCompletion): - """Extends ChatCompletion with OpenRouter-specific attributes. + if models := model_settings.get('openrouter_models'): + extra_body['models'] = models + if provider := model_settings.get('openrouter_preferences'): + extra_body['provider'] = provider + if preset := model_settings.get('openrouter_preset'): + extra_body['preset'] = preset + if transforms := model_settings.get('openrouter_transforms'): + extra_body['transforms'] = transforms - This class extends the base ChatCompletion model to include additional - fields returned specifically by the OpenRouter API. + base_keys = ModelSettings.__annotations__.keys() + base_data: dict[str, Any] = {k: model_settings[k] for k in base_keys if k in model_settings} - Attributes: - provider: The name of the upstream LLM provider (e.g., "Anthropic", - "OpenAI", etc.) that processed the request through OpenRouter. - """ + new_settings = OpenAIChatModelSettings(**base_data, extra_body=extra_body) - provider: str + return new_settings class OpenRouterModel(OpenAIChatModel): @@ -213,75 +237,31 @@ def __init__( Args: model_name: The name of the model to use. provider: The provider to use for authentication and API access. Currently, uses OpenAI as the internal client. Can be either the string - 'openai' or an instance of `Provider[AsyncOpenAI]`. If not provided, a new provider will be + 'openrouter' or an instance of `Provider[AsyncOpenAI]`. If not provided, a new provider will be created using the other parameters. profile: The model profile to use. Defaults to a profile picked by the provider based on the model name. - json_mode_schema_prompt: The prompt to show when the model expects a JSON object as input. settings: Model-specific settings that will be used as defaults for this model. """ - self._model_name = model_name + super().__init__(model_name, provider=provider, profile=profile, settings=settings) - if isinstance(provider, str): - provider = infer_provider(provider) - self._provider = provider - self.client = provider.client - - super().__init__(model_name, provider=provider, profile=profile or provider.model_profile, settings=settings) - - async def request( + def prepare_request( self, - messages: list[ModelMessage], model_settings: ModelSettings | None, model_request_parameters: ModelRequestParameters, - ) -> ModelResponse: - check_allow_model_requests() - transformed_settings = OpenRouterModel._openrouter_settings_to_openai_settings( - cast(OpenRouterModelSettings, model_settings or {}) - ) - - response = await super()._completions_create( - messages=messages, - stream=False, - model_settings=transformed_settings, - model_request_parameters=model_request_parameters, - ) - - model_response = self._process_response(response) - return model_response + ) -> tuple[ModelSettings | None, ModelRequestParameters]: + merged_settings, customized_parameters = super().prepare_request(model_settings, model_request_parameters) + new_settings = _openrouter_settings_to_openai_settings(cast(OpenRouterModelSettings, merged_settings or {})) + return new_settings, customized_parameters def _process_response(self, response: ChatCompletion | str) -> ModelResponse: model_response = super()._process_response(response=response) response = cast(ChatCompletion, response) # If above did not raise an error, we can assume response != str - if openrouter_provider := getattr(response, 'provider', None): - model_response.provider_name = openrouter_provider - - return model_response - - @staticmethod - def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: - """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. + provider_details: dict[str, str] = {} - Args: - model_settings: The 'OpenRouterModelSettings' object to transform. - - Returns: - An 'OpenAIChatModelSettings' object with equivalent settings. - """ - extra_body: dict[str, Any] = {} + if openrouter_provider := getattr(response, 'provider', None): # pragma: lax no cover + provider_details['downstream_provider'] = openrouter_provider - if models := model_settings.get('openrouter_models'): - extra_body['models'] = models - if provider := model_settings.get('openrouter_preferences'): - extra_body['provider'] = provider - if preset := model_settings.get('openrouter_preset'): - extra_body['preset'] = preset - if transforms := model_settings.get('openrouter_transforms'): - extra_body['transforms'] = transforms + model_response.provider_details = provider_details - base_keys = ModelSettings.__annotations__.keys() - base_data: dict[str, Any] = {k: model_settings[k] for k in base_keys if k in model_settings} - - new_settings = OpenAIChatModelSettings(**base_data, extra_body=extra_body) - - return new_settings + return model_response diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml deleted file mode 100644 index 5e0361edad..0000000000 --- a/tests/models/cassettes/test_openrouter/test_openrouter_infer_provider.yaml +++ /dev/null @@ -1,76 +0,0 @@ -interactions: -- request: - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate - connection: - - keep-alive - content-length: - - '154' - content-type: - - application/json - host: - - openrouter.ai - method: POST - parsed_body: - messages: - - content: Be helpful. - role: system - - content: Tell me a joke. - role: user - model: google/gemini-2.5-flash-lite - stream: false - uri: https://openrouter.ai/api/v1/chat/completions - response: - headers: - access-control-allow-origin: - - '*' - connection: - - keep-alive - content-length: - - '591' - 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: stop - index: 0 - logprobs: null - message: - content: |- - Why did the scarecrow win an award? - - Because he was outstanding in his field! - reasoning: null - refusal: null - role: assistant - native_finish_reason: STOP - created: 1759503832 - id: gen-1759503832-O5IKtEwGGvVaTr3Thz3w - model: google/gemini-2.5-flash-lite - object: chat.completion - provider: Google - usage: - completion_tokens: 18 - completion_tokens_details: - image_tokens: 0 - reasoning_tokens: 0 - prompt_tokens: 8 - prompt_tokens_details: - cached_tokens: 0 - total_tokens: 26 - status: - code: 200 - message: OK -version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index f66131f034..c4650cb7d1 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -20,19 +20,6 @@ ] -async def test_openrouter_infer_provider(allow_model_requests: None) -> None: - model = OpenRouterModel('google/gemini-2.5-flash-lite') - agent = Agent(model, instructions='Be helpful.', retries=1) - response = await agent.run('Tell me a joke.') - assert response.output == snapshot( - """\ -Why did the scarecrow win an award? - -Because he was outstanding in his field!\ -""" - ) - - async def test_openrouter_with_preset(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('google/gemini-2.5-flash-lite', provider=provider) @@ -66,7 +53,8 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro What can I help you with today?\ """ ) - assert response.provider_name == 'xAI' + assert response.provider_details is not None + assert response.provider_details['downstream_provider'] == 'xAI' async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: From 10a1a17ff2a73f3ae48ee145a045e07b4fa62c91 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 4 Oct 2025 23:57:39 -0600 Subject: [PATCH 03/42] Move OpenRouterModelSettings import into try block --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- tests/models/test_openrouter.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 93f5df0f15..b2cf553eb0 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -183,7 +183,7 @@ class OpenRouterModelSettings(ModelSettings, total=False): openrouter_transforms: list[Transforms] """To help with prompts that exceed the maximum context size of a model. - Transforms work by removing or truncating messages from the middle of the prompt, until the prompt fits within the model’s context window. [See more](https://openrouter.ai/docs/features/message-transforms) + Transforms work by removing or truncating messages from the middle of the prompt, until the prompt fits within the model's context window. [See more](https://openrouter.ai/docs/features/message-transforms) """ openrouter_reasoning: OpenRouterReasoning diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index c4650cb7d1..2074bae30f 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -5,12 +5,11 @@ from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart from pydantic_ai.direct import model_request -from pydantic_ai.models.openrouter import OpenRouterModelSettings from ..conftest import try_import with try_import() as imports_successful: - from pydantic_ai.models.openrouter import OpenRouterModel + from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterModelSettings from pydantic_ai.providers.openrouter import OpenRouterProvider pytestmark = [ From 5e64a621e1d259b08a977de9d68e70b5737a9c1f Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Tue, 7 Oct 2025 13:36:12 -0600 Subject: [PATCH 04/42] Update pydantic_ai_slim/pydantic_ai/models/openrouter.py Co-authored-by: Douwe Maan --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index b2cf553eb0..1ec5698f09 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -12,7 +12,7 @@ from .openai import OpenAIChatModel, OpenAIChatModelSettings -class OpenRouterMaxprice(TypedDict, total=False): +class OpenRouterMaxPrice(TypedDict, total=False): """The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion.""" prompt: int From e219b8c908791bf2472486ff4b5cf6aa5e650d61 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 8 Oct 2025 13:49:28 -0600 Subject: [PATCH 05/42] Handle OpenRouter errors and extract response metadata --- .../pydantic_ai/models/openrouter.py | 62 ++++++++++- .../test_openrouter_with_reasoning.yaml | 103 ++++++++++++++++++ tests/models/test_openrouter.py | 101 ++++++++++++++++- 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 1ec5698f09..9647a87fe8 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -2,8 +2,10 @@ from openai import AsyncOpenAI from openai.types.chat import ChatCompletion +from pydantic import BaseModel from typing_extensions import TypedDict +from ..exceptions import ModelHTTPError, UnexpectedModelBehavior from ..messages import ModelResponse from ..profiles import ModelProfileSpec from ..providers import Provider @@ -104,7 +106,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): """ -class OpenRouterPreferences(TypedDict, total=False): +class OpenRouterProvider(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" order: list[OpenRouterSlug] @@ -134,7 +136,7 @@ class OpenRouterPreferences(TypedDict, total=False): sort: Literal['price', 'throughput', 'latency'] """Sort providers by price or throughput. (e.g. "price" or "throughput"). [See details](https://openrouter.ai/docs/features/provider-routing#provider-sorting)""" - max_price: OpenRouterMaxprice + max_price: OpenRouterMaxPrice """The maximum pricing you want to pay for this request. [See details](https://openrouter.ai/docs/features/provider-routing#max-price)""" @@ -170,7 +172,7 @@ class OpenRouterModelSettings(ModelSettings, total=False): These models will be tried, in order, if the main model returns an error. [See details](https://openrouter.ai/docs/features/model-routing#the-models-parameter) """ - openrouter_preferences: OpenRouterPreferences + openrouter_provider: OpenRouterProvider """OpenRouter routes requests to the best available providers for your model. By default, requests are load balanced across the top providers to maximize uptime. You can customize how your requests are routed using the provider object. [See more](https://openrouter.ai/docs/features/provider-routing)""" @@ -193,6 +195,13 @@ class OpenRouterModelSettings(ModelSettings, total=False): """ +class OpenRouterError(BaseModel): + """Utility class to validate error messages from OpenRouter.""" + + code: int + message: str + + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -206,7 +215,7 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti if models := model_settings.get('openrouter_models'): extra_body['models'] = models - if provider := model_settings.get('openrouter_preferences'): + if provider := model_settings.get('openrouter_provider'): extra_body['provider'] = provider if preset := model_settings.get('openrouter_preset'): extra_body['preset'] = preset @@ -221,6 +230,33 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti return new_settings +def _verify_response_is_not_error(response: ChatCompletion) -> ChatCompletion: + """Checks a pre-validation 'ChatCompletion' object for the error attribute. + + Args: + response: The 'ChatCompletion' object to validate. + + Returns: + The same 'ChatCompletion' object. + + Raises: + ModelHTTPError: If the response contains an error attribute. + UnexpectedModelBehavior: If the response does not contain an error attribute but contains an 'error' finish_reason. + """ + if openrouter_error := getattr(response, 'error', None): + error = OpenRouterError.model_validate(openrouter_error) + raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) + else: + choice = response.choices[0] + + if choice.finish_reason == 'error': + raise UnexpectedModelBehavior( + 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' + ) + + return response + + class OpenRouterModel(OpenAIChatModel): """Extends OpenAIModel to capture extra metadata for Openrouter.""" @@ -254,14 +290,28 @@ def prepare_request( return new_settings, customized_parameters def _process_response(self, response: ChatCompletion | str) -> ModelResponse: + if not isinstance(response, ChatCompletion): + raise UnexpectedModelBehavior( + 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' + ) + + response = _verify_response_is_not_error(response) + model_response = super()._process_response(response=response) - response = cast(ChatCompletion, response) # If above did not raise an error, we can assume response != str - provider_details: dict[str, str] = {} + provider_details: dict[str, Any] = {} if openrouter_provider := getattr(response, 'provider', None): # pragma: lax no cover provider_details['downstream_provider'] = openrouter_provider + choice = response.choices[0] + + if native_finish_reason := getattr(choice, 'native_finish_reason', None): # pragma: lax no cover + provider_details['native_finish_reason'] = native_finish_reason + + if reasoning_details := getattr(choice.message, 'reasoning_details', None): + provider_details['reasoning_details'] = reasoning_details + model_response.provider_details = provider_details return model_response diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml new file mode 100644 index 0000000000..6952863a80 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml @@ -0,0 +1,103 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '92' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Who are you + role: user + model: z-ai/glm-4.6 + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '3750' + 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: stop + index: 0 + logprobs: null + message: + content: |2- + + + I'm GLM, a large language model developed by Zhipu AI. I'm designed to have natural conversations, answer questions, and assist with various tasks through text-based interactions. I've been trained on a diverse range of data to help users with information and creative tasks. + + I continuously learn to improve my capabilities, though I don't store your personal data. Is there something specific you'd like to know about me or how I can help you today? + reasoning: |- + Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. + + I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. + + Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + + It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + + I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + + The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. + + Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction. + reasoning_details: + - format: unknown + index: 0 + text: |- + Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. + + I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. + + Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + + It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + + I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + + The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. + + Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction. + type: reasoning.text + refusal: null + role: assistant + native_finish_reason: stop + created: 1759944663 + id: gen-1759944663-AyClfEwG6WFB1puHZNXg + model: z-ai/glm-4.6 + object: chat.completion + provider: GMICloud + usage: + completion_tokens: 331 + prompt_tokens: 8 + prompt_tokens_details: null + total_tokens: 339 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 2074bae30f..ceae34a74c 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -2,8 +2,10 @@ import pytest from inline_snapshot import snapshot +from openai.types.chat import ChatCompletion +from openai.types.chat.chat_completion import Choice -from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart +from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart, ThinkingPart, UnexpectedModelBehavior from pydantic_ai.direct import model_request from ..conftest import try_import @@ -41,7 +43,7 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro settings = OpenRouterModelSettings( openrouter_models=['x-ai/grok-4'], openrouter_transforms=['middle-out'], - openrouter_preferences={'only': ['xai']}, + openrouter_provider={'only': ['xai']}, ) response = await model_request(model, [ModelRequest.user_text_prompt('Who are you')], model_settings=settings) text_part = cast(TextPart, response.parts[0]) @@ -54,6 +56,59 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro ) assert response.provider_details is not None assert response.provider_details['downstream_provider'] == 'xAI' + assert response.provider_details['native_finish_reason'] == 'stop' + + +async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('z-ai/glm-4.6', provider=provider) + response = await model_request(model, [ModelRequest.user_text_prompt('Who are you')]) + + assert len(response.parts) == 2 + assert isinstance(thinking_part := response.parts[0], ThinkingPart) + assert isinstance(response.parts[1], TextPart) + assert thinking_part.content == snapshot( + """\ +Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. + +I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. + +Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + +It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + +I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + +The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. + +Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ +""" + ) + assert response.provider_details is not None + assert response.provider_details['reasoning_details'] == snapshot( + [ + { + 'format': 'unknown', + 'index': 0, + 'text': """\ +Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. + +I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. + +Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + +It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + +I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + +The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. + +Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ +""", + 'type': 'reasoning.text', + } + ] + ) async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: @@ -65,3 +120,45 @@ async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_a assert str(exc_info.value) == snapshot( "status_code: 429, model_name: google/gemini-2.0-flash-exp:free, body: {'code': 429, 'message': 'Provider returned error', 'metadata': {'provider_name': 'Google', 'raw': 'google/gemini-2.0-flash-exp:free is temporarily rate-limited upstream. Please retry shortly, or add your own key to accumulate your rate limits: https://openrouter.ai/settings/integrations'}}" ) + + +async def test_openrouter_validate_non_json_response(openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + + with pytest.raises(UnexpectedModelBehavior) as exc_info: + model._process_response('This is not JSON!') + + assert str(exc_info.value) == snapshot( + 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' + ) + + +async def test_openrouter_validate_error_response(openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + + response = ChatCompletion.model_construct(model='test') + response.error = {'message': 'This response has an error attribute', 'code': 200} + + with pytest.raises(ModelHTTPError) as exc_info: + model._process_response(response) + + assert str(exc_info.value) == snapshot( + 'status_code: 200, model_name: test, body: This response has an error attribute' + ) + + +async def test_openrouter_validate_error_finish_reason(openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + + choice = Choice.model_construct(finish_reason='error') + response = ChatCompletion.model_construct(choices=[choice]) + + with pytest.raises(UnexpectedModelBehavior) as exc_info: + model._process_response(response) + + assert str(exc_info.value) == snapshot( + 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' + ) From 6f99fb2edda506e75df77cce56f2db2d116a0509 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 8 Oct 2025 14:04:27 -0600 Subject: [PATCH 06/42] Add type ignores to tests --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- tests/models/test_openrouter.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 9647a87fe8..6dff00393d 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -249,7 +249,7 @@ def _verify_response_is_not_error(response: ChatCompletion) -> ChatCompletion: else: choice = response.choices[0] - if choice.finish_reason == 'error': + if choice.finish_reason == 'error': # type: ignore[reportUnnecessaryComparison] raise UnexpectedModelBehavior( 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' ) diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index ceae34a74c..357e3a00a5 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -2,8 +2,6 @@ import pytest from inline_snapshot import snapshot -from openai.types.chat import ChatCompletion -from openai.types.chat.chat_completion import Choice from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart, ThinkingPart, UnexpectedModelBehavior from pydantic_ai.direct import model_request @@ -11,6 +9,9 @@ from ..conftest import try_import with try_import() as imports_successful: + from openai.types.chat import ChatCompletion + from openai.types.chat.chat_completion import Choice + from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterModelSettings from pydantic_ai.providers.openrouter import OpenRouterProvider @@ -127,7 +128,7 @@ async def test_openrouter_validate_non_json_response(openrouter_api_key: str) -> model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) with pytest.raises(UnexpectedModelBehavior) as exc_info: - model._process_response('This is not JSON!') + model._process_response('This is not JSON!') # type: ignore[reportPrivateUsage] assert str(exc_info.value) == snapshot( 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' @@ -139,10 +140,10 @@ async def test_openrouter_validate_error_response(openrouter_api_key: str) -> No model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) response = ChatCompletion.model_construct(model='test') - response.error = {'message': 'This response has an error attribute', 'code': 200} + response.error = {'message': 'This response has an error attribute', 'code': 200} # type: ignore[reportAttributeAccessIssue] with pytest.raises(ModelHTTPError) as exc_info: - model._process_response(response) + model._process_response(response) # type: ignore[reportPrivateUsage] assert str(exc_info.value) == snapshot( 'status_code: 200, model_name: test, body: This response has an error attribute' @@ -157,7 +158,7 @@ async def test_openrouter_validate_error_finish_reason(openrouter_api_key: str) response = ChatCompletion.model_construct(choices=[choice]) with pytest.raises(UnexpectedModelBehavior) as exc_info: - model._process_response(response) + model._process_response(response) # type: ignore[reportPrivateUsage] assert str(exc_info.value) == snapshot( 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' From ef3c6dd5eb42a4a2b69f811ff09ab2b0a89209d7 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 10 Oct 2025 10:43:25 -0600 Subject: [PATCH 07/42] Send back reasoning_details/signature --- .../pydantic_ai/models/openrouter.py | 24 ++++- ...est_openrouter_map_messages_reasoning.yaml | 96 +++++++++++++++++++ tests/models/test_openrouter.py | 42 +++++++- 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_map_messages_reasoning.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 6dff00393d..cb2d6fad1e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,12 +1,16 @@ from typing import Any, Literal, cast from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion +from openai.types.chat import ChatCompletion, ChatCompletionMessageParam from pydantic import BaseModel from typing_extensions import TypedDict from ..exceptions import ModelHTTPError, UnexpectedModelBehavior -from ..messages import ModelResponse +from ..messages import ( + ModelMessage, + ModelResponse, + ThinkingPart, +) from ..profiles import ModelProfileSpec from ..providers import Provider from ..settings import ModelSettings @@ -312,6 +316,22 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: if reasoning_details := getattr(choice.message, 'reasoning_details', None): provider_details['reasoning_details'] = reasoning_details + if signature := reasoning_details[0].get('signature', None): + thinking_part = cast(ThinkingPart, model_response.parts[0]) + thinking_part.signature = signature + model_response.provider_details = provider_details return model_response + + async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompletionMessageParam]: + """Maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam` and adds OpenRouter specific parameters.""" + openai_messages = await super()._map_messages(messages) + + for message, openai_message in zip(messages, openai_messages): + if isinstance(message, ModelResponse): + provider_details = cast(dict[str, Any], message.provider_details) + if reasoning_details := provider_details.get('reasoning_details', None): # pragma: lax no cover + openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssue] + + return openai_messages diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_map_messages_reasoning.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_map_messages_reasoning.yaml new file mode 100644 index 0000000000..aa634b6658 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_map_messages_reasoning.yaml @@ -0,0 +1,96 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '133' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Who are you. Think about it. + role: user + model: anthropic/claude-3.7-sonnet:thinking + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '4024' + 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: stop + index: 0 + logprobs: null + message: + content: "I am Claude, an AI assistant created by Anthropic. I'm a large language model designed to be helpful, + harmless, and honest.\n\nI don't have consciousness or sentience like humans do - I'm a sophisticated text prediction + system trained on a large dataset of human text. I don't have personal experiences, emotions, or a physical existence. + \n\nMy purpose is to assist you with information, tasks, and conversation in a helpful way, while acknowledging + my limitations. I have knowledge cutoffs, can occasionally make mistakes, and don't have the ability to access + the internet or take actions in the physical world.\n\nIs there something specific you'd like to know about me + or how I can assist you?" + reasoning: |- + This question is asking me about my identity. Let me think about how to respond clearly and accurately. + + I am Claude, an AI assistant created by Anthropic. I'm designed to be helpful, harmless, and honest in my interactions with humans. I don't have a physical form - I exist as a large language model running on computer hardware. I don't have consciousness, sentience, or feelings in the way humans do. I don't have personal experiences or a life outside of these conversations. + + My capabilities include understanding and generating natural language text, reasoning about various topics, and attempting to be helpful to users in a wide range of contexts. I have been trained on a large corpus of text data, but my training data has a cutoff date, so I don't have knowledge of events that occurred after my training. + + I have certain limitations - I don't have the ability to access the internet, run code, or interact with external systems unless given specific tools to do so. I don't have perfect knowledge and can make mistakes. + + I'm designed to be conversational and to engage with users in a way that's helpful and informative, while respecting important ethical boundaries. + reasoning_details: + - format: anthropic-claude-v1 + index: 0 + signature: ErcBCkgICBACGAIiQHtMxpqcMhnwgGUmSDWGoOL9ZHTbDKjWnhbFm0xKzFl0NmXFjQQxjFj5mieRYY718fINsJMGjycTVYeiu69npakSDDrsnKYAD/fdcpI57xoMHlQBxI93RMa5CSUZIjAFVCMQF5GfLLQCibyPbb7LhZ4kLIFxw/nqsTwDDt6bx3yipUcq7G7eGts8MZ6LxOYqHTlIDx0tfHRIlkkcNCdB2sUeMqP8e7kuQqIHoD52GAI= + text: |- + This question is asking me about my identity. Let me think about how to respond clearly and accurately. + + I am Claude, an AI assistant created by Anthropic. I'm designed to be helpful, harmless, and honest in my interactions with humans. I don't have a physical form - I exist as a large language model running on computer hardware. I don't have consciousness, sentience, or feelings in the way humans do. I don't have personal experiences or a life outside of these conversations. + + My capabilities include understanding and generating natural language text, reasoning about various topics, and attempting to be helpful to users in a wide range of contexts. I have been trained on a large corpus of text data, but my training data has a cutoff date, so I don't have knowledge of events that occurred after my training. + + I have certain limitations - I don't have the ability to access the internet, run code, or interact with external systems unless given specific tools to do so. I don't have perfect knowledge and can make mistakes. + + I'm designed to be conversational and to engage with users in a way that's helpful and informative, while respecting important ethical boundaries. + type: reasoning.text + refusal: null + role: assistant + native_finish_reason: stop + created: 1760051228 + id: gen-1760051228-zUtCCQbb0vkaM4UXZmcb + model: anthropic/claude-3.7-sonnet:thinking + object: chat.completion + provider: Google + usage: + completion_tokens: 402 + prompt_tokens: 43 + total_tokens: 445 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 357e3a00a5..8ba57544a5 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -3,7 +3,14 @@ import pytest from inline_snapshot import snapshot -from pydantic_ai import Agent, ModelHTTPError, ModelRequest, TextPart, ThinkingPart, UnexpectedModelBehavior +from pydantic_ai import ( + Agent, + ModelHTTPError, + ModelRequest, + TextPart, + ThinkingPart, + UnexpectedModelBehavior, +) from pydantic_ai.direct import model_request from ..conftest import try_import @@ -163,3 +170,36 @@ async def test_openrouter_validate_error_finish_reason(openrouter_api_key: str) assert str(exc_info.value) == snapshot( 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' ) + + +async def test_openrouter_map_messages_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('anthropic/claude-3.7-sonnet:thinking', provider=provider) + + user_message = ModelRequest.user_text_prompt('Who are you. Think about it.') + response = await model_request(model, [user_message]) + + mapped_messages = await model._map_messages([user_message, response]) # type: ignore[reportPrivateUsage] + + assert len(mapped_messages) == 2 + assert mapped_messages[1]['reasoning_details'] == snapshot( # type: ignore[reportGeneralTypeIssues] + [ + { + 'type': 'reasoning.text', + 'text': """\ +This question is asking me about my identity. Let me think about how to respond clearly and accurately. + +I am Claude, an AI assistant created by Anthropic. I'm designed to be helpful, harmless, and honest in my interactions with humans. I don't have a physical form - I exist as a large language model running on computer hardware. I don't have consciousness, sentience, or feelings in the way humans do. I don't have personal experiences or a life outside of these conversations. + +My capabilities include understanding and generating natural language text, reasoning about various topics, and attempting to be helpful to users in a wide range of contexts. I have been trained on a large corpus of text data, but my training data has a cutoff date, so I don't have knowledge of events that occurred after my training. + +I have certain limitations - I don't have the ability to access the internet, run code, or interact with external systems unless given specific tools to do so. I don't have perfect knowledge and can make mistakes. + +I'm designed to be conversational and to engage with users in a way that's helpful and informative, while respecting important ethical boundaries.\ +""", + 'signature': 'ErcBCkgICBACGAIiQHtMxpqcMhnwgGUmSDWGoOL9ZHTbDKjWnhbFm0xKzFl0NmXFjQQxjFj5mieRYY718fINsJMGjycTVYeiu69npakSDDrsnKYAD/fdcpI57xoMHlQBxI93RMa5CSUZIjAFVCMQF5GfLLQCibyPbb7LhZ4kLIFxw/nqsTwDDt6bx3yipUcq7G7eGts8MZ6LxOYqHTlIDx0tfHRIlkkcNCdB2sUeMqP8e7kuQqIHoD52GAI=', + 'format': 'anthropic-claude-v1', + 'index': 0, + } + ] + ) From ed9e7df089f2e9505fd8d0c214c78bf6f60d3737 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 16 Oct 2025 11:08:37 -0600 Subject: [PATCH 08/42] add OpenRouterChatCompletion model --- .../pydantic_ai/models/openrouter.py | 165 +++++++++++++----- tests/models/test_openrouter.py | 18 +- 2 files changed, 134 insertions(+), 49 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index cb2d6fad1e..22f4add144 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,7 +1,8 @@ from typing import Any, Literal, cast from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion, ChatCompletionMessageParam +from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam +from openai.types.chat.chat_completion import Choice from pydantic import BaseModel from typing_extensions import TypedDict @@ -110,7 +111,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): """ -class OpenRouterProvider(TypedDict, total=False): +class OpenRouterProviderConfig(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" order: list[OpenRouterSlug] @@ -176,7 +177,7 @@ class OpenRouterModelSettings(ModelSettings, total=False): These models will be tried, in order, if the main model returns an error. [See details](https://openrouter.ai/docs/features/model-routing#the-models-parameter) """ - openrouter_provider: OpenRouterProvider + openrouter_provider: OpenRouterProviderConfig """OpenRouter routes requests to the best available providers for your model. By default, requests are load balanced across the top providers to maximize uptime. You can customize how your requests are routed using the provider object. [See more](https://openrouter.ai/docs/features/provider-routing)""" @@ -206,6 +207,78 @@ class OpenRouterError(BaseModel): message: str +class BaseReasoningDetail(BaseModel): + """Common fields shared across all reasoning detail types.""" + + id: str | None = None + format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1'] + index: int | None + + +class ReasoningSummary(BaseReasoningDetail): + """Represents a high-level summary of the reasoning process.""" + + type: Literal['reasoning.summary'] + summary: str + + +class ReasoningEncrypted(BaseReasoningDetail): + """Represents encrypted reasoning data.""" + + type: Literal['reasoning.encrypted'] + data: str + + +class ReasoningText(BaseReasoningDetail): + """Represents raw text reasoning.""" + + type: Literal['reasoning.text'] + text: str + signature: str | None = None + + +OpenRouterReasoningDetail = ReasoningSummary | ReasoningEncrypted | ReasoningText + + +class OpenRouterCompletionMessage(ChatCompletionMessage): + """Wrapped chat completion message with OpenRouter specific attributes.""" + + reasoning: str | None = None + """The reasoning text associated with the message, if any.""" + + reasoning_details: list[OpenRouterReasoningDetail] | None = None + """The reasoning details associated with the message, if any.""" + + +class OpenRouterChoice(Choice): + """Wraps OpenAI chat completion choice with OpenRouter specific attribures.""" + + native_finish_reason: str + """The provided finish reason by the downstream provider from OpenRouter.""" + + finish_reason: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] # type: ignore[reportIncompatibleVariableOverride] + """OpenRouter specific finish reasons. + + Notably, removes 'function_call' and adds 'error' finish reasons. + """ + + message: OpenRouterCompletionMessage # type: ignore[reportIncompatibleVariableOverride] + """A wrapped chat completion message with OpenRouter specific attributes.""" + + +class OpenRouterChatCompletion(ChatCompletion): + """Wraps OpenAI chat completion with OpenRouter specific attribures.""" + + provider: str + """The downstream provider that was used by OpenRouter.""" + + choices: list[OpenRouterChoice] # type: ignore[reportIncompatibleVariableOverride] + """A list of chat completion choices modified with OpenRouter specific attributes.""" + + error: OpenRouterError | None = None + """OpenRouter specific error attribute.""" + + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -234,33 +307,6 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti return new_settings -def _verify_response_is_not_error(response: ChatCompletion) -> ChatCompletion: - """Checks a pre-validation 'ChatCompletion' object for the error attribute. - - Args: - response: The 'ChatCompletion' object to validate. - - Returns: - The same 'ChatCompletion' object. - - Raises: - ModelHTTPError: If the response contains an error attribute. - UnexpectedModelBehavior: If the response does not contain an error attribute but contains an 'error' finish_reason. - """ - if openrouter_error := getattr(response, 'error', None): - error = OpenRouterError.model_validate(openrouter_error) - raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) - else: - choice = response.choices[0] - - if choice.finish_reason == 'error': # type: ignore[reportUnnecessaryComparison] - raise UnexpectedModelBehavior( - 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' - ) - - return response - - class OpenRouterModel(OpenAIChatModel): """Extends OpenAIModel to capture extra metadata for Openrouter.""" @@ -299,26 +345,53 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' ) - response = _verify_response_is_not_error(response) - - model_response = super()._process_response(response=response) - - provider_details: dict[str, Any] = {} + native_response = OpenRouterChatCompletion.model_validate(response.model_dump()) + choice = native_response.choices[0] - if openrouter_provider := getattr(response, 'provider', None): # pragma: lax no cover - provider_details['downstream_provider'] = openrouter_provider + if error := native_response.error: + raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) + else: + if choice.finish_reason == 'error': + raise UnexpectedModelBehavior( + 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' + ) - choice = response.choices[0] + # This is done because 'super()._process_response' reads 'reasoning' to create a ThinkingPart. + # but this method will also create a ThinkingPart using 'reasoning_details'; Delete 'reasoning' to avoid duplication + if choice.message.reasoning is not None: + setattr(response.choices[0].message, 'reasoning', None) - if native_finish_reason := getattr(choice, 'native_finish_reason', None): # pragma: lax no cover - provider_details['native_finish_reason'] = native_finish_reason - - if reasoning_details := getattr(choice.message, 'reasoning_details', None): - provider_details['reasoning_details'] = reasoning_details + model_response = super()._process_response(response=response) - if signature := reasoning_details[0].get('signature', None): - thinking_part = cast(ThinkingPart, model_response.parts[0]) - thinking_part.signature = signature + provider_details: dict[str, Any] = {} + provider_details['downstream_provider'] = native_response.provider + provider_details['native_finish_reason'] = choice.native_finish_reason + + if reasoning_details := choice.message.reasoning_details: + provider_details['reasoning_details'] = [detail.model_dump() for detail in reasoning_details] + + reasoning = reasoning_details[0] + + assert isinstance(model_response.parts, list) + if isinstance(reasoning, ReasoningText): + model_response.parts.insert( + 0, + ThinkingPart( + id=reasoning.id, + content=reasoning.text, + signature=reasoning.signature, + provider_name=native_response.provider, + ), + ) + elif isinstance(reasoning, ReasoningSummary): + model_response.parts.insert( + 0, + ThinkingPart( + id=reasoning.id, + content=reasoning.summary, + provider_name=native_response.provider, + ), + ) model_response.provider_details = provider_details diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 8ba57544a5..9318e17e71 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -96,6 +96,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ assert response.provider_details['reasoning_details'] == snapshot( [ { + 'id': None, 'format': 'unknown', 'index': 0, 'text': """\ @@ -114,6 +115,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ """, 'type': 'reasoning.text', + 'signature': None, } ] ) @@ -146,7 +148,12 @@ async def test_openrouter_validate_error_response(openrouter_api_key: str) -> No provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) - response = ChatCompletion.model_construct(model='test') + choice = Choice.model_construct( + index=0, message={'role': 'assistant'}, finish_reason='error', native_finish_reason='stop' + ) + response = ChatCompletion.model_construct( + id='', choices=[choice], created=0, object='chat.completion', model='test', provider='test' + ) response.error = {'message': 'This response has an error attribute', 'code': 200} # type: ignore[reportAttributeAccessIssue] with pytest.raises(ModelHTTPError) as exc_info: @@ -161,8 +168,12 @@ async def test_openrouter_validate_error_finish_reason(openrouter_api_key: str) provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) - choice = Choice.model_construct(finish_reason='error') - response = ChatCompletion.model_construct(choices=[choice]) + choice = Choice.model_construct( + index=0, message={'role': 'assistant'}, finish_reason='error', native_finish_reason='stop' + ) + response = ChatCompletion.model_construct( + id='', choices=[choice], created=0, object='chat.completion', model='test', provider='test' + ) with pytest.raises(UnexpectedModelBehavior) as exc_info: model._process_response(response) # type: ignore[reportPrivateUsage] @@ -185,6 +196,7 @@ async def test_openrouter_map_messages_reasoning(allow_model_requests: None, ope assert mapped_messages[1]['reasoning_details'] == snapshot( # type: ignore[reportGeneralTypeIssues] [ { + 'id': None, 'type': 'reasoning.text', 'text': """\ This question is asking me about my identity. Let me think about how to respond clearly and accurately. From 75adbb4944992413d1fa2b64889ab75550dedded Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 24 Oct 2025 06:48:30 -0600 Subject: [PATCH 09/42] Update pydantic_ai_slim/pydantic_ai/models/openrouter.py Co-authored-by: Douwe Maan --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 22f4add144..43aeefa833 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -29,7 +29,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): request: int -LatestOpenRouterSlugs = Literal[ +KnownOpenRouterProviders = Literal[ 'z-ai', 'cerebras', 'venice', From ab9d690305680a350b90cc842240f9c604fd0065 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 24 Oct 2025 06:48:57 -0600 Subject: [PATCH 10/42] Update pydantic_ai_slim/pydantic_ai/models/openrouter.py Co-authored-by: Douwe Maan --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 43aeefa833..ad04dbe4fd 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -96,7 +96,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): ] """Known providers in the OpenRouter marketplace""" -OpenRouterSlug = str | LatestOpenRouterSlugs +OpenRouterProvider = str | KnownOpenRouterProviders """Possible OpenRouter provider slugs. Since OpenRouter is constantly updating their list of providers, we explicitly list some known providers but From 5700a1911c98d244afb510e4a224c0c23c95f7a7 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 16 Oct 2025 12:36:27 -0600 Subject: [PATCH 11/42] fix spelling mistake --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index ad04dbe4fd..7ce97df83e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -251,7 +251,7 @@ class OpenRouterCompletionMessage(ChatCompletionMessage): class OpenRouterChoice(Choice): - """Wraps OpenAI chat completion choice with OpenRouter specific attribures.""" + """Wraps OpenAI chat completion choice with OpenRouter specific attributes.""" native_finish_reason: str """The provided finish reason by the downstream provider from OpenRouter.""" @@ -267,7 +267,7 @@ class OpenRouterChoice(Choice): class OpenRouterChatCompletion(ChatCompletion): - """Wraps OpenAI chat completion with OpenRouter specific attribures.""" + """Wraps OpenAI chat completion with OpenRouter specific attributes.""" provider: str """The downstream provider that was used by OpenRouter.""" From ee93121fc47dac30148a631be2660f1cdf8b9855 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 24 Oct 2025 06:58:35 -0600 Subject: [PATCH 12/42] add openrouter web plugin --- pydantic_ai_slim/pydantic_ai/models/openai.py | 4 -- .../pydantic_ai/models/openrouter.py | 53 ++++++++++++++----- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 7b8bff1c55..580f89dfa8 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -567,10 +567,6 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons if reasoning := getattr(choice.message, 'reasoning', None): items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) - # NOTE: We don't currently handle OpenRouter `reasoning_details`: - # - https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks - # If you need this, please file an issue. - if choice.message.content: items.extend( (replace(part, id='content', provider_name=self.system) if isinstance(part, ThinkingPart) else part) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 7ce97df83e..32df1fba69 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -114,7 +114,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): class OpenRouterProviderConfig(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" - order: list[OpenRouterSlug] + order: list[OpenRouterProvider] """List of provider slugs to try in order (e.g. ["anthropic", "openai"]). [See details](https://openrouter.ai/docs/features/provider-routing#ordering-specific-providers)""" allow_fallbacks: bool @@ -129,7 +129,7 @@ class OpenRouterProviderConfig(TypedDict, total=False): zdr: bool """Restrict routing to only ZDR (Zero Data Retention) endpoints. [See details](https://openrouter.ai/docs/features/provider-routing#zero-data-retention-enforcement)""" - only: list[OpenRouterSlug] + only: list[OpenRouterProvider] """List of provider slugs to allow for this request. [See details](https://openrouter.ai/docs/features/provider-routing#allowing-only-specific-providers)""" ignore: list[str] @@ -166,6 +166,36 @@ class OpenRouterReasoning(TypedDict, total=False): """Whether to enable reasoning with default parameters. Default is inferred from effort or max_tokens.""" +class WebPlugin(TypedDict, total=False): + """You can incorporate relevant web search results for any model on OpenRouter by activating and customizing the web plugin. + + The web search plugin is powered by native search for Anthropic and OpenAI natively and by Exa for other models. For Exa, it uses their "auto" method (a combination of keyword search and embeddings-based web search) to find the most relevant results and augment/ground your prompt. + """ + + id: Literal['web'] + + engine: Literal['native', 'exa', 'undefined'] + """The web search plugin supports the following options for the engine parameter: + + `native`: Always uses the model provider's built-in web search capabilities + `exa`: Uses Exa's search API for web results + `undefined` (not specified): Uses native search if available for the provider, otherwise falls back to Exa + + Native search is used by default for OpenAI and Anthropic models that support it + Exa search is used for all other models or when native search is not supported. + + When you explicitly specify "engine": "native", it will always attempt to use the provider's native search, even if the model doesn't support it (which may result in an error).""" + + max_results: int + """The maximum results allowed by the web plugin.""" + + search_prompt: str + """The prompt used to attach results to your message.""" + + +OpenRouterPlugin = WebPlugin + + class OpenRouterModelSettings(ModelSettings, total=False): """Settings used for an OpenRouter model request.""" @@ -199,6 +229,8 @@ class OpenRouterModelSettings(ModelSettings, total=False): The reasoning config object consolidates settings for controlling reasoning strength across different models. [See more](https://openrouter.ai/docs/use-cases/reasoning-tokens) """ + openrouter_plugins: list[OpenRouterPlugin] + class OpenRouterError(BaseModel): """Utility class to validate error messages from OpenRouter.""" @@ -288,23 +320,18 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti Returns: An 'OpenAIChatModelSettings' object with equivalent settings. """ - extra_body: dict[str, Any] = {} + extra_body = model_settings['extra_body'] - if models := model_settings.get('openrouter_models'): + if models := model_settings.pop('openrouter_models', None): extra_body['models'] = models - if provider := model_settings.get('openrouter_provider'): + if provider := model_settings.pop('openrouter_provider', None): extra_body['provider'] = provider - if preset := model_settings.get('openrouter_preset'): + if preset := model_settings.pop('openrouter_preset', None): extra_body['preset'] = preset - if transforms := model_settings.get('openrouter_transforms'): + if transforms := model_settings.pop('openrouter_transforms', None): extra_body['transforms'] = transforms - base_keys = ModelSettings.__annotations__.keys() - base_data: dict[str, Any] = {k: model_settings[k] for k in base_keys if k in model_settings} - - new_settings = OpenAIChatModelSettings(**base_data, extra_body=extra_body) - - return new_settings + return OpenAIChatModelSettings(**model_settings, extra_body=extra_body) class OpenRouterModel(OpenAIChatModel): From ca45f8ae9f90c55bea2e2ea988441126dc6f9be9 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 24 Oct 2025 15:51:39 -0600 Subject: [PATCH 13/42] WIP build reasoning_details from ThinkingParts --- .../pydantic_ai/models/openrouter.py | 58 ++++++++++++++----- tests/models/test_openrouter.py | 29 +--------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 32df1fba69..282b423a01 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -243,7 +243,7 @@ class BaseReasoningDetail(BaseModel): """Common fields shared across all reasoning detail types.""" id: str | None = None - format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1'] + format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] index: int | None @@ -320,7 +320,7 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti Returns: An 'OpenAIChatModelSettings' object with equivalent settings. """ - extra_body = model_settings['extra_body'] + extra_body = model_settings.get('extra_body', {}) if models := model_settings.pop('openrouter_models', None): extra_body['models'] = models @@ -386,39 +386,51 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: # This is done because 'super()._process_response' reads 'reasoning' to create a ThinkingPart. # but this method will also create a ThinkingPart using 'reasoning_details'; Delete 'reasoning' to avoid duplication if choice.message.reasoning is not None: - setattr(response.choices[0].message, 'reasoning', None) + delattr(response.choices[0].message, 'reasoning') model_response = super()._process_response(response=response) - provider_details: dict[str, Any] = {} + provider_details = model_response.provider_details or {} provider_details['downstream_provider'] = native_response.provider provider_details['native_finish_reason'] = choice.native_finish_reason if reasoning_details := choice.message.reasoning_details: - provider_details['reasoning_details'] = [detail.model_dump() for detail in reasoning_details] - reasoning = reasoning_details[0] - assert isinstance(model_response.parts, list) + new_parts: list[ThinkingPart] = [] + if isinstance(reasoning, ReasoningText): - model_response.parts.insert( - 0, + new_parts.append( ThinkingPart( id=reasoning.id, content=reasoning.text, signature=reasoning.signature, provider_name=native_response.provider, - ), + ) ) elif isinstance(reasoning, ReasoningSummary): - model_response.parts.insert( - 0, + new_parts.append( ThinkingPart( id=reasoning.id, content=reasoning.summary, provider_name=native_response.provider, ), ) + else: + new_parts.append( + ThinkingPart( + id=reasoning.id, + content='', + signature=reasoning.data, + provider_name=native_response.provider, + ), + ) + + # TODO: Find a better way to store these attributes + new_parts[0].openrouter_type = reasoning.type + new_parts[0].openrouter_format = reasoning.format + + model_response.parts = [*new_parts, *model_response.parts] model_response.provider_details = provider_details @@ -430,8 +442,24 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti for message, openai_message in zip(messages, openai_messages): if isinstance(message, ModelResponse): - provider_details = cast(dict[str, Any], message.provider_details) - if reasoning_details := provider_details.get('reasoning_details', None): # pragma: lax no cover - openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssue] + for part in message.parts: + if isinstance(part, ThinkingPart): + reasoning_detail: dict[str, Any] = { + 'type': part.openrouter_type, + 'id': part.id, + 'format': part.openrouter_format, + 'index': 0, + } + + match part.openrouter_type: + case 'reasoning.summary': + reasoning_detail['summary'] = part.content + case 'reasoning.text': + reasoning_detail['text'] = part.content + reasoning_detail['signature'] = part.signature + case 'reasoning.encrypted': + reasoning_detail['data'] = part.signature + + openai_message['reasoning_details'] = [reasoning_detail] return openai_messages diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 9318e17e71..35119a5255 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -92,33 +92,8 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ """ ) - assert response.provider_details is not None - assert response.provider_details['reasoning_details'] == snapshot( - [ - { - 'id': None, - 'format': 'unknown', - 'index': 0, - 'text': """\ -Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. - -I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. - -Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. - -It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. - -I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. - -The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. - -Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ -""", - 'type': 'reasoning.text', - 'signature': None, - } - ] - ) + assert thinking_part.openrouter_type == snapshot('reasoning.text') + assert thinking_part.openrouter_format == snapshot('unknown') async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: From b32581604c626a916f688620f146412b90296565 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 27 Oct 2025 13:23:27 -0600 Subject: [PATCH 14/42] wip reasoning details conversion --- .../pydantic_ai/models/openrouter.py | 127 ++++---- .../test_openrouter_with_reasoning.yaml | 270 ++++++++++++++++-- tests/models/test_openrouter.py | 47 +-- 3 files changed, 343 insertions(+), 101 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 282b423a01..a0bcb7928c 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,4 +1,5 @@ -from typing import Any, Literal, cast +from dataclasses import dataclass +from typing import Literal, cast from openai import AsyncOpenAI from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam @@ -6,6 +7,7 @@ from pydantic import BaseModel from typing_extensions import TypedDict +from .. import _utils from ..exceptions import ModelHTTPError, UnexpectedModelBehavior from ..messages import ( ModelMessage, @@ -272,6 +274,67 @@ class ReasoningText(BaseReasoningDetail): OpenRouterReasoningDetail = ReasoningSummary | ReasoningEncrypted | ReasoningText +@dataclass(repr=False) +class OpenRouterThinkingPart(ThinkingPart): + """filler.""" + + type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text'] + index: int + format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] + + __repr__ = _utils.dataclasses_no_defaults_repr + + @classmethod + def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail, provider_name: str): + if isinstance(reasoning, ReasoningText): + return cls( + id=reasoning.id, + content=reasoning.text, + signature=reasoning.signature, + provider_name=provider_name, + format=reasoning.format, + type=reasoning.type, + index=reasoning.index, + ) + elif isinstance(reasoning, ReasoningSummary): + return cls( + id=reasoning.id, + content=reasoning.summary, + provider_name=provider_name, + format=reasoning.format, + type=reasoning.type, + index=reasoning.index, + ) + else: + return cls( + id=reasoning.id, + content='', + signature=reasoning.data, + provider_name=provider_name, + format=reasoning.format, + type=reasoning.type, + index=reasoning.index, + ) + + def into_reasoning_detail(self): + reasoning_detail = { + 'type': self.type, + 'id': self.id, + 'format': self.format, + 'index': self.index, + } + + if self.type == 'reasoning.summary': + reasoning_detail['summary'] = self.content + elif self.type == 'reasoning.text': + reasoning_detail['text'] = self.content + reasoning_detail['signature'] = self.signature + elif self.type == 'reasoning.encrypted': + reasoning_detail['data'] = self.signature + + return reasoning_detail + + class OpenRouterCompletionMessage(ChatCompletionMessage): """Wrapped chat completion message with OpenRouter specific attributes.""" @@ -395,40 +458,10 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: provider_details['native_finish_reason'] = choice.native_finish_reason if reasoning_details := choice.message.reasoning_details: - reasoning = reasoning_details[0] - - new_parts: list[ThinkingPart] = [] - - if isinstance(reasoning, ReasoningText): - new_parts.append( - ThinkingPart( - id=reasoning.id, - content=reasoning.text, - signature=reasoning.signature, - provider_name=native_response.provider, - ) - ) - elif isinstance(reasoning, ReasoningSummary): - new_parts.append( - ThinkingPart( - id=reasoning.id, - content=reasoning.summary, - provider_name=native_response.provider, - ), - ) - else: - new_parts.append( - ThinkingPart( - id=reasoning.id, - content='', - signature=reasoning.data, - provider_name=native_response.provider, - ), - ) - - # TODO: Find a better way to store these attributes - new_parts[0].openrouter_type = reasoning.type - new_parts[0].openrouter_format = reasoning.format + new_parts: list[ThinkingPart] = [ + OpenRouterThinkingPart.from_reasoning_detail(reasoning, native_response.provider) + for reasoning in reasoning_details + ] model_response.parts = [*new_parts, *model_response.parts] @@ -442,24 +475,12 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti for message, openai_message in zip(messages, openai_messages): if isinstance(message, ModelResponse): + reasoning_details = [] + for part in message.parts: - if isinstance(part, ThinkingPart): - reasoning_detail: dict[str, Any] = { - 'type': part.openrouter_type, - 'id': part.id, - 'format': part.openrouter_format, - 'index': 0, - } - - match part.openrouter_type: - case 'reasoning.summary': - reasoning_detail['summary'] = part.content - case 'reasoning.text': - reasoning_detail['text'] = part.content - reasoning_detail['signature'] = part.signature - case 'reasoning.encrypted': - reasoning_detail['data'] = part.signature - - openai_message['reasoning_details'] = [reasoning_detail] + if isinstance(part, OpenRouterThinkingPart): + reasoning_details.append(part.into_reasoning_detail()) + + openai_message['reasoning_details'] = reasoning_details return openai_messages diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml index 6952863a80..1a28651a07 100644 --- a/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml +++ b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml @@ -8,7 +8,7 @@ interactions: connection: - keep-alive content-length: - - '92' + - '174' content-type: - application/json host: @@ -16,7 +16,7 @@ interactions: method: POST parsed_body: messages: - - content: Who are you + - content: What was the impact of Voltaire's writings on modern french culture? Think about your answer. role: user model: z-ai/glm-4.6 stream: false @@ -28,7 +28,7 @@ interactions: connection: - keep-alive content-length: - - '3750' + - '19192' content-type: - application/json permissions-policy: @@ -49,54 +49,268 @@ interactions: content: |2- - I'm GLM, a large language model developed by Zhipu AI. I'm designed to have natural conversations, answer questions, and assist with various tasks through text-based interactions. I've been trained on a diverse range of data to help users with information and creative tasks. + This is an excellent question that requires moving beyond a simple historical summary. To understand Voltaire's impact on *modern* French culture, we need to see his ideas not as dusty relics, but as living, breathing principles that continue to shape French identity, politics, and daily life. - I continuously learn to improve my capabilities, though I don't store your personal data. Is there something specific you'd like to know about me or how I can help you today? - reasoning: |- - Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. + Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA. His impact is not monolithic but can be understood through several key, interconnected domains. + + ### 1. The Architect of *Laïcité*: The War Against "L'Infâme" + + Perhaps Voltaire's most profound and enduring impact is on France's unique form of secularism, ***laïcité***. + + * **The Core Idea:** Voltaire waged a lifelong intellectual war against what he called **"l'Infâme"** (the infamous thing)—a catch-all term for the power, intolerance, and superstition of the Catholic Church. He wasn't an atheist; he was a Deist who believed in a "clockmaker" God. His target was organized religion's interference in state, science, and individual conscience. + * **Modern Manifestation:** This crusade directly paved the way for the **1905 law on the separation of Church and State**. Today, when French politicians and citizens defend *laïcité*—whether in debates over religious symbols in schools or the Islamic veil—they are, consciously or not, echoing Voltaire's central tenet: that the public sphere must be free from religious dogma to ensure liberty for all. The French instinct to be suspicious of religious authority is a direct inheritance from Voltaire. + + ### 2. The Spirit of *L'Esprit Critique*: The Birth of a National Pastime + + Before Voltaire, challenging authority was dangerous. After him, it became a form of high art and a civic duty. + + * **The Core Idea:** Voltaire mastered the use of **wit, satire, and irony** as weapons against tyranny and absurdity. In works like *Candide* with its famous refrain "we must cultivate our garden," he lampooned philosophical optimism and the foolishness of the powerful. He taught that no institution, belief, or leader was above ridicule. + * **Modern Manifestation:** This is the origin of ***l'esprit critique***, the critical spirit, which is a hallmark of French culture. It manifests in: + * **Political Satire:** The fearless, often scathing, satire of publications like ***Le Canard Enchaîné*** and, most famously, ***Charlie Hebdo***, is a direct descendant of Voltaire's style. The very act of drawing a caricature of a prophet or president is a Voltairean assertion of free expression. + * **Everyday Debate:** The French love a good, heated argument (*une bonne discussion*). Questioning the teacher, challenging the boss, and debating politics over dinner are seen not as acts of disrespect, but as signs of an engaged and intelligent mind. This intellectual sparring is a cultural habit Voltaire helped popularize. + + ### 3. The Bedrock of the Republic: "Liberté" as a Sacred Value + + Voltaire was not a democrat; he favored an "enlightened monarch." However, his obsessive focus on individual liberties provided the ideological fuel for the French Revolution and the Republic that followed. + + * **The Core Idea:** Through his campaigns, most famously the **Calas affair** (where he fought to overturn the wrongful execution of a Protestant Huguenot), Voltaire championed the principles of **freedom of speech, freedom of religion, and the right to a fair trial**. + * **Modern Manifestation:** These ideas are not just abstract; they are enshrined in France's most sacred text: the ***Déclaration des Droits de l'Homme et du Citoyen*** of 1789. The first word of the Republican motto, **"Liberté,"** is Voltaire's legacy. When the French take to the streets to defend their rights—a common sight—they are invoking the Voltairean principle that individual liberty is paramount and must be protected against the encroachment of state power. - I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. + ### 4. The Archetype of the *Intellectuel Engagé* - Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + Voltaire created the model for the public intellectual in France. - It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + * **The Core Idea:** He was the first major writer to use his fame and literary talent not just for art's sake, but to actively intervene in social and political injustices. He used his pen as a weapon for the common good. + * **Modern Manifestation:** This created a powerful French tradition of the ***intellectuel engagé*** (the engaged intellectual). From **Émile Zola's** "J'accuse...!" in the Dreyfus Affair to **Jean-Paul Sartre's** political activism in the 20th century, French intellectuals have seen it as their responsibility to speak truth to power. Voltaire set the standard: to be a thinker in France is to have a social duty. - I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + ### Nuances and Contradictions: The Imperfect Prophet - The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. + A thoughtful analysis must also acknowledge that Voltaire was a man of his time, and his legacy is complex. - Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction. + * **Not a True Democrat:** He was an elitist who believed in rule by an educated, enlightened class, not by the masses. He would likely have been horrified by the full excesses of the French Revolution. + * **Personal Prejudices:** He held antisemitic and anti-African views that were common among the aristocracy of the 18th century. While he fought for religious tolerance for Christians, his vision of universalism had clear limits. + + ### Conclusion + + The impact of Voltaire's writings on modern French culture is not just historical; it is elemental. He is the ghost in the machine of French identity. When a French person defends *laïcité*, uses sharp wit to critique a politician, or asserts their right to disagree, they are speaking Voltaire's language. + + He provided France with its most cherished tools: skepticism as a virtue, liberty as a goal, and the pen as a mighty weapon. While his own prejudices and elitism complicate his legacy, the core principles he championed—reason, tolerance, and fearless critique—remain the very foundation upon which modern French culture is built, debated, and continuously redefined. + reasoning: |- + 1. **Deconstruct the User's Prompt:** + * **Core Subject:** Voltaire's writings. + * **Core Question:** What was their impact? + * **Specific Context:** *Modern French culture*. + * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's asking for depth, not just breadth. + + 2. **Initial Brainstorming & Keyword Association:** + * **Voltaire:** Enlightenment, *Philosophe*, reason, logic, tolerance, freedom of speech, freedom of religion, criticism of the Church (l'Infâme), criticism of absolute monarchy, satire, wit, *Candide*, *Dictionnaire philosophique*, *Lettres philosophiques*, Deism, Calas affair, Émilie du Châtelet. + * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), debate, protest, Gauloiserie (a bit cheeky, rebellious), universalism, human rights, cafés, education system, political discourse. + + 3. **Structure the Answer:** A good structure is key for a "think about your answer" type of question. + * **Introduction:** Start with a strong, concise thesis statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. Something like, "Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA." + * **Body Paragraphs (Thematic Approach):** Instead of just listing his works, I'll group the impacts into key themes. This shows analysis. Good themes would be: + * **The Birth of *Laïcité* and the Critique of Religious Power:** This is a huge one. Connect his fight against "l'Infâme" (the Church's intolerance) directly to the 1905 law on the separation of church and state and the modern concept of *laïcité*. Mention specific examples like the Calas affair. + * **The Spirit of *L'Esprit Critique* and Satire:** This is about the *style* of French discourse. Connect his wit and satire (e.g., *Candide*) to the modern French love of debate, political satire (like *Le Canard Enchaîné* or Charlie Hebdo), and a certain intellectual skepticism. This is a very "cultural" point. + * **The Foundations of the Republic: Liberty and Human Rights:** This is the political legacy. Link his ideas on freedom of speech, expression, and religious tolerance to the *Déclaration des Droits de l'Homme et du Citoyen* (1789) and the modern Republican motto "Liberté, Égalité, Fraternité." He directly inspired "Liberté." + * **The French Intellectual Tradition:** Position Voltaire as the archetype of the *intellectuel engagé* (the engaged intellectual). This connects him to later figures like Zola (Dreyfus Affair), Sartre, and Foucault. Explain that the idea of the intellectual using their platform for social and political justice starts with him. + * **Language and Education:** How did his work shape the French language itself? He championed clarity, precision, and wit. Mention his influence on the *Académie française* and the style of writing prized in the French education system (*la dissertation*). He made French the language of Enlightenment, not just the court. + * **Nuance and Counterarguments (The "Think About It" part):** A great answer doesn't just praise. It acknowledges complexity. + * Was he a true democrat? No, he was an enlightened monarchist, not a republican in the modern sense. He believed in a wise king, not popular rule. + * Was he free from the prejudices of his time? No. He had documented antisemitic and anti-African sentiments, common among the aristocracy of his era. This is crucial for a balanced view. It shows that his legacy is complex and has been reinterpreted over time. + * **Conclusion:** Summarize the main points. Reiterate the thesis in a new way. End with a powerful, concluding thought. Emphasize that his legacy is a living, breathing part of French culture, constantly debated and reinterpreted. The phrase "a ghost in the machine of French culture" or something similar could be effective. It captures his pervasive but sometimes contested presence. + + 4. **Drafting and Refining (Self-Correction):** + * As I write, I'll make sure to use specific French terms like *laïcité*, *l'esprit critique*, *l'Infâme*, and *philosophe*. This adds authenticity and precision. + * I'll check for flow between paragraphs. Do the transitions work? For example, moving from the political (Republic) to the cultural (Intellectual) needs a smooth link. + * I'll ensure the introduction and conclusion bookend the argument effectively. The conclusion shouldn't just repeat the intro but synthesize the body paragraphs. + * Is the tone right? It should be authoritative but accessible, analytical but not overly academic. + * I'll read it aloud to catch awkward phrasing. For instance, instead of "Voltaire's writings made the French culture," I'll use more sophisticated phrasing like "Voltaire's writings indelibly shaped the contours of modern French culture." reasoning_details: - format: unknown index: 0 text: |- - Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. - - I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. - - Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. + 1. **Deconstruct the User's Prompt:** + * **Core Subject:** Voltaire's writings. + * **Core Question:** What was their impact? + * **Specific Context:** *Modern French culture*. + * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's asking for depth, not just breadth. - It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. + 2. **Initial Brainstorming & Keyword Association:** + * **Voltaire:** Enlightenment, *Philosophe*, reason, logic, tolerance, freedom of speech, freedom of religion, criticism of the Church (l'Infâme), criticism of absolute monarchy, satire, wit, *Candide*, *Dictionnaire philosophique*, *Lettres philosophiques*, Deism, Calas affair, Émilie du Châtelet. + * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), debate, protest, Gauloiserie (a bit cheeky, rebellious), universalism, human rights, cafés, education system, political discourse. - I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. + 3. **Structure the Answer:** A good structure is key for a "think about your answer" type of question. + * **Introduction:** Start with a strong, concise thesis statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. Something like, "Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA." + * **Body Paragraphs (Thematic Approach):** Instead of just listing his works, I'll group the impacts into key themes. This shows analysis. Good themes would be: + * **The Birth of *Laïcité* and the Critique of Religious Power:** This is a huge one. Connect his fight against "l'Infâme" (the Church's intolerance) directly to the 1905 law on the separation of church and state and the modern concept of *laïcité*. Mention specific examples like the Calas affair. + * **The Spirit of *L'Esprit Critique* and Satire:** This is about the *style* of French discourse. Connect his wit and satire (e.g., *Candide*) to the modern French love of debate, political satire (like *Le Canard Enchaîné* or Charlie Hebdo), and a certain intellectual skepticism. This is a very "cultural" point. + * **The Foundations of the Republic: Liberty and Human Rights:** This is the political legacy. Link his ideas on freedom of speech, expression, and religious tolerance to the *Déclaration des Droits de l'Homme et du Citoyen* (1789) and the modern Republican motto "Liberté, Égalité, Fraternité." He directly inspired "Liberté." + * **The French Intellectual Tradition:** Position Voltaire as the archetype of the *intellectuel engagé* (the engaged intellectual). This connects him to later figures like Zola (Dreyfus Affair), Sartre, and Foucault. Explain that the idea of the intellectual using their platform for social and political justice starts with him. + * **Language and Education:** How did his work shape the French language itself? He championed clarity, precision, and wit. Mention his influence on the *Académie française* and the style of writing prized in the French education system (*la dissertation*). He made French the language of Enlightenment, not just the court. + * **Nuance and Counterarguments (The "Think About It" part):** A great answer doesn't just praise. It acknowledges complexity. + * Was he a true democrat? No, he was an enlightened monarchist, not a republican in the modern sense. He believed in a wise king, not popular rule. + * Was he free from the prejudices of his time? No. He had documented antisemitic and anti-African sentiments, common among the aristocracy of his era. This is crucial for a balanced view. It shows that his legacy is complex and has been reinterpreted over time. + * **Conclusion:** Summarize the main points. Reiterate the thesis in a new way. End with a powerful, concluding thought. Emphasize that his legacy is a living, breathing part of French culture, constantly debated and reinterpreted. The phrase "a ghost in the machine of French culture" or something similar could be effective. It captures his pervasive but sometimes contested presence. - The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. - - Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction. + 4. **Drafting and Refining (Self-Correction):** + * As I write, I'll make sure to use specific French terms like *laïcité*, *l'esprit critique*, *l'Infâme*, and *philosophe*. This adds authenticity and precision. + * I'll check for flow between paragraphs. Do the transitions work? For example, moving from the political (Republic) to the cultural (Intellectual) needs a smooth link. + * I'll ensure the introduction and conclusion bookend the argument effectively. The conclusion shouldn't just repeat the intro but synthesize the body paragraphs. + * Is the tone right? It should be authoritative but accessible, analytical but not overly academic. + * I'll read it aloud to catch awkward phrasing. For instance, instead of "Voltaire's writings made the French culture," I'll use more sophisticated phrasing like "Voltaire's writings indelibly shaped the contours of modern French culture." type: reasoning.text refusal: null role: assistant native_finish_reason: stop - created: 1759944663 - id: gen-1759944663-AyClfEwG6WFB1puHZNXg + created: 1761580402 + id: gen-1761580402-57zjmoISWVKHAKdbyUOs model: z-ai/glm-4.6 object: chat.completion provider: GMICloud usage: - completion_tokens: 331 - prompt_tokens: 8 + completion_tokens: 2545 + prompt_tokens: 24 prompt_tokens_details: null - total_tokens: 339 + total_tokens: 2569 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '171' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: What was the impact of Voltaire's writings on modern french culture? Think about your answer. + role: user + model: openai/o3 + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '11659' + 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: stop + index: 0 + logprobs: null + message: + content: "Voltaire (François-Marie Arouet, 1694-1778) wrote plays, poems, histories, pamphlets, tens of thousands + of letters and—above all—philosophical tales such as Candide. In the two-and-a-half centuries since his death + he has remained one of the most frequently quoted, taught and contested figures in France. His influence is felt + less in any single institution than in a collection of habits, reflexes and reference points that shape contemporary + French culture.\n\n1. Secularism (laïcité) and the place of religion \n • Voltaire’s most persistent target + was clerical power. By ridiculing dogma (“Écrasez l’infâme!”) and championing natural religion, he set the tone + for an anticlerical current that ran through the Revolution, the Third Republic and the 1905 separation law. \n + \ • The modern French consensus that religious belief is a private matter and that public authority must be + neutral—still fiercely debated in policies on head-scarves, schools and blasphemy—draws intellectual ancestry + from Voltaire’s writings and the style of reasoning they popularised.\n\n2. Free speech as a civic value \n + \ • The line “I disapprove of what you say, but I will defend to the death your right to say it” is apocryphal, + yet the principle it sums up is recognisably Voltairian. \n • Contemporary French jurisprudence on press freedom, + the satirical tradition of Le Canard enchaîné, Charlie Hebdo and the visibility of polemical essayists all cite + Voltaire as a legitimising ancestor.\n\n3. Critical rationalism and the “spirit of 1789” \n • Voltaire helped + make sceptical, analytic reason a civic virtue. His essays on Newtonian physics, his histories (Siècle de Louis + XIV) and his relentless fact-checking of rumours (e.g., the Calas case) taught readers to test authority against + evidence. \n • That epistemic stance informed the philosophes’ contribution to the Declaration of the Rights + of Man and ultimately France’s self-image as a “République de la raison.” French educational curricula still + present Voltaire as the archetype of the engaged, critical mind.\n\n4. Human rights and the defense of minorities + \ \n • Voltaire’s campaigns for Jean Calas, Sirven and other judicial victims made “l’affaire” a French civic + genre (later the Dreyfus affair). \n • His texts supplied language—“tolerance,” “the right to doubt,” “cruel + and unusual punishment”—that reappears in modern activist and legal discourse, from Amnesty International (founded + in France) to recent debates on police violence.\n\n5. Literary form and the French sense of wit \n • Candide + and the contes philosophiques created a template for mixing narrative, satire and philosophical provocation that + runs from Flaubert’s Bouvard et Pécuchet to modern writers like Houellebecq. \n • Voltaire’s aphoristic style + (“Le mieux est l’ennemi du bien,” “Il faut cultiver notre jardin”) supplies quotable commonplaces still used in + editorials, classrooms and everyday speech. \n • His terse, elegant prose helped standardise 18th-century + French; the Académie française still cites him as a model.\n\n6. The figure of the “intellectuel engagé” \n + \ • Voltaire turned the writer into a public actor who intervenes in courts, parliaments and the international + press. \n • Zola’s “J’accuse …!”, Sartre’s political essays, and contemporary “rock-star historians” or philosophers + on television follow a path he mapped: the intellectual who litigates ideas in the public square.\n\n7. Memory + culture and national mythology \n • Voltaire’s transfer to the Panthéon in July 1791 inaugurated the building + as a civic temple. His tomb, visited by school groups, helps embed Enlightenment ideals in the Republican liturgy. + \ \n • Town squares, streets, schools and metro stations named after him act as constant mnemonic prompts linking + present-day France to the Enlightenment project.\n\nAmbivalences and limitations \n • Catholic, royalist and + some post-colonial thinkers accuse Voltaire of fostering a reductive view of religion or ignoring non-European + voices. \n • Yet even the counter-currents define themselves against him, confirming his role as a reference + point.\n\nIn sum, Voltaire’s writings did not merely enrich French literature; they helped script a cultural operating + system. Whenever contemporary France prizes lucid argument over piety, lampoons the powerful, invokes laïcité, + or expects writers to weigh in on trials and treaties, it is living in the wake of Voltaire." + reasoning: "**Analyzing Voltaire's Impact**\n\nTo answer the question about Voltaire's influence on modern French + culture, I'll consider his impact on various aspects like society, literature, and attitudes. His writings shaped + Enlightenment values and ideals of secularism, human rights, and political critique.\n\nI should highlight how + he influenced the French Revolution by advocating for reason, civil liberties, and critique of the monarchy and + the Church. Also, his secularism affected the 1905 law and the French approach to religion. Finally, his advocacy + for freedom of speech and tolerance impacted modern human rights movements in France.**Drafting Voltaire's Influence**\n\nI + want to explore Voltaire's significant impact on modern French culture. First, I’ll discuss his literary style, + particularly his satire and philosophical tales, like *Candide*, which are part of school curricula and shape + humor and irony today. \n\nNext, I'll consider his influence on the French language, introducing wit and expressions + that enrich it. His role in defining the tradition of public intellectuals and his impact on legal frameworks, + like the Declaration of Rights of Man and the Napoleonic Code, are also important. Additionally, I need to address + controversies surrounding his views and presence at the Panthéon. I’ll structure the answer as a cohesive essay + or bullet points." + reasoning_details: + - format: openai-responses-v1 + index: 0 + summary: "**Analyzing Voltaire's Impact**\n\nTo answer the question about Voltaire's influence on modern French + culture, I'll consider his impact on various aspects like society, literature, and attitudes. His writings shaped + Enlightenment values and ideals of secularism, human rights, and political critique.\n\nI should highlight how + he influenced the French Revolution by advocating for reason, civil liberties, and critique of the monarchy + and the Church. Also, his secularism affected the 1905 law and the French approach to religion. Finally, his + advocacy for freedom of speech and tolerance impacted modern human rights movements in France.**Drafting Voltaire's + Influence**\n\nI want to explore Voltaire's significant impact on modern French culture. First, I’ll discuss + his literary style, particularly his satire and philosophical tales, like *Candide*, which are part of school + curricula and shape humor and irony today. \n\nNext, I'll consider his influence on the French language, introducing + wit and expressions that enrich it. His role in defining the tradition of public intellectuals and his impact + on legal frameworks, like the Declaration of Rights of Man and the Napoleonic Code, are also important. Additionally, + I need to address controversies surrounding his views and presence at the Panthéon. I’ll structure the answer + as a cohesive essay or bullet points." + type: reasoning.summary + - data: gAAAAABo_5XHkkwCuk-f0waWF42hzBg4R9rD9YUpiXWCgX81P6W2mXf-FLIDTmdvRxm3ctZjMD8Uw4Om_8HIu4TCVHd56avFGbdKVUHf7xNzSBJqYxlGLsp7OB3LKukXWjekw9i9dotrHHZQkXyRRt5esuDGujsquFbI8WYFhMCEhPZaAIJ-IKrnxiS2f2MJxVyGWv9eRWRzZFJmJUr8MHZpIbJS6tLumThbN1fGhn0hes-OWWxfNKfclSoZz86qego4k0Zo8PF2tYqX1uKvLOBr-SSPwplUU798j3DFxMQo6pdAZRT4pJGd-L19nMlrn8DQ5LyIFEV7hMIRD-ieJThuQ3OBei5xJaH1fmZwDFKJHQn_agcZDflY36HlCbIrt1ab-sLgsB4D0TCRq4j42cH0xc3qXC1wrMGuPUOO8CsvbDssJgdXSmTJKhrmsCMH4pPKh0PY983sFlGp_WeRT7RX--NA7JD7sUe7ZlVAWeaQdkXtNmLcvlMl8GdAUrErpUCLvvxYSgD5skISESjgY_gMKCi7NHPaOdgKvRgTc6S5aW2J_xzUyJWDGfPwzIWirVlesEjtUsloeieWRwa-C9YNDi9ZrDhSTqdoAHBW6J6sm1cDGOVN9GqtZ_SmOMGYrYVQZxkvV4nheM6lShDVUHqxh7P5IPazkWGjQBGTccje6RDFDLpBJ2x_gP9qR9aS0VWRieVN6swzpln--lDiKXNvK2RP_5sm0wiCiR14yjxsLibSudCnZWj3f3PWbqcT0xXoqJc0sERzwSrNldt9my7hN8dgWwG2q-atczccNNLSwut7dgiGSazaXHz0SsGvQRi2Gw5bAYfh2mJSyVbA0ZyRYv6nFpilNrQlpeXCoqbvBGbsDDZSfOqnO_OxfNr4yKSeP6JeJnY1-DMZ-zl6eN80ipjlTlJ60opdsJmSa3hGQxsGDVqIL2Ep3sHGT78MZXj2bPEtU5kEhfyD4f4ghfHZeKczJ5TvYKNFEfS9kUnoIbP0uiB1udDOz5mij_GwlvqeqnHSdOaaOoSxxDviPbcbPZgDTVPhADwpAGVkOK3TzysnQZjijAmOzcLp6-LpBG2LBrLatzHH4_wJUEWXFi-ORzvnxTaVGDR8Dn2UuKF4v81blN2-j14xp_2DaojIqIg2XhyDq6Q2a6s6a0rdqtnWC0QhiBC6-TCyDlBHTbENIOsGpmAykGD4_-hnx9Pp69CvfCmwjpgsRF319nNL_2awDWpQyIfxQ-smC-v_ljCF7JDwianEyKsM30tIqNsAcJwtf5_f98TUpbSHzDbw_7tGdzIZKIJQkqteMyKJXOtaZ_XxDcwCs9cswsDwutvTxdqtIXZN526l59zQ9uNYx9roLdN8n6WbhuZUSkQK7xrS-KgqdxZPlMg-ImqlwgWkDM8G8DLLcHuAzrb1r7F2tXjGjUDSDWS1zSCScg79WezPcL4bQ_zTAMDOQ0w4OMCxCJKwRye4KYY7c4QnCVdW3JiFrHByo2oVZOtknTiJOllsY6OkziVeiRrMwiRMhwgGKAisTosFcqzHILptzApWYIb8Jdx3glaJStoWbdgV80Rne_u333z3FfCajpwsfvkGM_yss5jAgcN_eNDXKBTZxx8NUu3d3Kz2u0tr_5MBOILwuItUNqWLhc1oMqkFnHrXwna874t9bgOvxR3ve552BjXL54XU7aGjTPKAGTAcWsxnvS2tx-wpTO8vZ9tsCnbDItpu9JDZnd-JqAfIr37qqeygxt0iIwPhRLz6Jlrjd4aqnpAhsFTb3CPkUX0hOeiCWYRkuTuqLXCUsycCjc_6Y5oznlPd6Pf2_IEpGBn819ob32Z8vbXsO7eTtBz3Yxc0CkuizkQQa_Efgz5kFFJoWwmzlMbl-SlArEgvNaiXv1WFWTR9jrUFZ1GRscczFbTjYWanmLawtR9NFyTj4G7XjB4Ikc4cyZIIsqEtRJH7IMd-3HVSnJX5jICyHagugShAPpwnyA_dn4_kiyXl821_nLCyWWMrQQNxQvoKA9EDKaXRLJ6RpnKWB5vaOm4el2v8rIgpE7OAhNW4wowVTdnn9lk3FcYa2arv_4415X07VY03njlmCV025HRxMNbc9ay4B6nndEBLHbP5TpfJHOzF6o57keP3LauKOzyVLN9YOsuc2Ht9vd4JZiyCuVzAaj4Z_G-ZvcSvRvXkTCS-bjyGH2FPw7MAQWDBw6ArBi-WTSYFwX4_k_bb0QjGmMjrLrbn4Vt_ClGKaapUajENwcnPBIpQ-p1yQBq0lSEQeVPwX76zIiktgiBFOD0MkJiWZSgybnwSdM3sN1kr7mP6Lph9DP7JiTHLakd-htJAyVmJbvRuT2_vpH9ywMddWpeHQiwBGhBjYxVWv0AdYUw7Gs7pVCC9ccPM1A727NGs-ecXFvxutO3lyR16zgw-e2dEt6eITYS4e1IsCe05r01WDUbR8B6IsMFl0sd7qG69X8nVLbK-m8sfURYLSrLsiXvsrmWNBaqraVfj1y6ALTKuJ657heQsF0BuNYVJldK_SgmmefTExc_t6ApkbkokWWMLZxk3J9wtCs4xrUzFkHX3AkqLpiAdi3yUyTjr-vPA5KHXLcBoDuj883w4yfwmKN_hGphWAcjv3N99_ao9UGYfNVIJmxcg7vMGlA1uEyawY3WjA5xlSrM6k--lph3PrT9Ukm8ojiZCMaMiJDVNjKORUJUyiSW8qJTcZEvKmfoju9KDuVfPbf0zT8vmQXWmAzWuH0QMi1KXjQEqtVoqgetV-YwzaZ-i7m8KPWxkRjV4t8aM1P6k71fA7DnOunbySPlEG-jNqxIrY5HNTbinDBDF_zp52JpL0saMKUfnY2EHL8gWXoXG4OguxzNFofp3tPk_uadv8rbmdno3RVPB7KrJqZFizoQ35F7MahgHCunKr9oK4uJ82sWQEa-tXgX8GI7a_rp-O5U6faibRjFSZODU-WXukzoSMhQrcJDpXT_1s5imdkJDV0wM20e-f18fjniMaSaCmgXOA3RdnPlZc26c6giZ7InttDaNRZCr-RCsDjQQVN4AKwnE5XM3yHL2usRx8ILmXZYWfTDNn-UDeueocDcuPhx9aMFf2rRMcw== + format: openai-responses-v1 + id: rs_068633cde4ea68920168ff95bdf3d881969382f905c626cbcd + index: 0 + type: reasoning.encrypted + refusal: null + role: assistant + native_finish_reason: completed + created: 1761580476 + id: gen-1761580476-UuSCBRFtiKPk6k5NiD84 + model: openai/o3 + object: chat.completion + provider: OpenAI + usage: + completion_tokens: 1351 + completion_tokens_details: + reasoning_tokens: 320 + prompt_tokens: 25 + total_tokens: 1376 status: code: 200 message: OK diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 35119a5255..b3bae1788f 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -69,31 +69,38 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) + request = ModelRequest.user_text_prompt( + "What was the impact of Voltaire's writings on modern french culture? Think about your answer." + ) + model = OpenRouterModel('z-ai/glm-4.6', provider=provider) - response = await model_request(model, [ModelRequest.user_text_prompt('Who are you')]) + response = await model_request(model, [request]) assert len(response.parts) == 2 - assert isinstance(thinking_part := response.parts[0], ThinkingPart) - assert isinstance(response.parts[1], TextPart) - assert thinking_part.content == snapshot( - """\ -Let me process this query about who I am. First, I should consider what the user really wants to know - they're likely seeking to understand my identity and capabilities as an AI assistant. - -I need to be clear and accurate about my nature. I'm a GLM large language model developed by Zhipu AI, not a human. This distinction is fundamental to our interaction. - -Looking at my core functions, I should highlight my ability to engage in natural conversations, answer questions, and assist with various tasks. My training involves processing vast amounts of text data, which enables me to understand and generate human-like responses. -It's important to mention my commitment to being helpful, harmless, and honest. These principles guide my interactions and ensure I provide appropriate assistance. - -I should also emphasize my continuous learning aspect. While I don't store personal data, I'm regularly updated to improve my capabilities and knowledge base. - -The response should be welcoming and encourage further questions about specific areas where I can help. This creates an open dialogue and shows my willingness to assist with various topics. - -Let me structure this information in a clear, friendly manner that addresses the user's question while inviting further interaction.\ -""" + thinking_part = response.parts[0] + assert isinstance(thinking_part, ThinkingPart) + assert thinking_part.id == snapshot(None) + assert thinking_part.content is not None + assert thinking_part.signature is None + + model = OpenRouterModel('openai/o3', provider=provider) + response = await model_request(model, [request]) + + assert len(response.parts) == 3 + + thinking_summary_part = response.parts[0] + thinking_redacted_part = response.parts[1] + assert isinstance(thinking_summary_part, ThinkingPart) + assert isinstance(thinking_redacted_part, ThinkingPart) + assert thinking_summary_part.id == snapshot(None) + assert thinking_summary_part.content is not None + assert thinking_summary_part.signature is None + assert thinking_redacted_part.id == snapshot('rs_068633cde4ea68920168ff95bdf3d881969382f905c626cbcd') + assert thinking_redacted_part.content == '' + assert thinking_redacted_part.signature == snapshot( + 'gAAAAABo_5XHkkwCuk-f0waWF42hzBg4R9rD9YUpiXWCgX81P6W2mXf-FLIDTmdvRxm3ctZjMD8Uw4Om_8HIu4TCVHd56avFGbdKVUHf7xNzSBJqYxlGLsp7OB3LKukXWjekw9i9dotrHHZQkXyRRt5esuDGujsquFbI8WYFhMCEhPZaAIJ-IKrnxiS2f2MJxVyGWv9eRWRzZFJmJUr8MHZpIbJS6tLumThbN1fGhn0hes-OWWxfNKfclSoZz86qego4k0Zo8PF2tYqX1uKvLOBr-SSPwplUU798j3DFxMQo6pdAZRT4pJGd-L19nMlrn8DQ5LyIFEV7hMIRD-ieJThuQ3OBei5xJaH1fmZwDFKJHQn_agcZDflY36HlCbIrt1ab-sLgsB4D0TCRq4j42cH0xc3qXC1wrMGuPUOO8CsvbDssJgdXSmTJKhrmsCMH4pPKh0PY983sFlGp_WeRT7RX--NA7JD7sUe7ZlVAWeaQdkXtNmLcvlMl8GdAUrErpUCLvvxYSgD5skISESjgY_gMKCi7NHPaOdgKvRgTc6S5aW2J_xzUyJWDGfPwzIWirVlesEjtUsloeieWRwa-C9YNDi9ZrDhSTqdoAHBW6J6sm1cDGOVN9GqtZ_SmOMGYrYVQZxkvV4nheM6lShDVUHqxh7P5IPazkWGjQBGTccje6RDFDLpBJ2x_gP9qR9aS0VWRieVN6swzpln--lDiKXNvK2RP_5sm0wiCiR14yjxsLibSudCnZWj3f3PWbqcT0xXoqJc0sERzwSrNldt9my7hN8dgWwG2q-atczccNNLSwut7dgiGSazaXHz0SsGvQRi2Gw5bAYfh2mJSyVbA0ZyRYv6nFpilNrQlpeXCoqbvBGbsDDZSfOqnO_OxfNr4yKSeP6JeJnY1-DMZ-zl6eN80ipjlTlJ60opdsJmSa3hGQxsGDVqIL2Ep3sHGT78MZXj2bPEtU5kEhfyD4f4ghfHZeKczJ5TvYKNFEfS9kUnoIbP0uiB1udDOz5mij_GwlvqeqnHSdOaaOoSxxDviPbcbPZgDTVPhADwpAGVkOK3TzysnQZjijAmOzcLp6-LpBG2LBrLatzHH4_wJUEWXFi-ORzvnxTaVGDR8Dn2UuKF4v81blN2-j14xp_2DaojIqIg2XhyDq6Q2a6s6a0rdqtnWC0QhiBC6-TCyDlBHTbENIOsGpmAykGD4_-hnx9Pp69CvfCmwjpgsRF319nNL_2awDWpQyIfxQ-smC-v_ljCF7JDwianEyKsM30tIqNsAcJwtf5_f98TUpbSHzDbw_7tGdzIZKIJQkqteMyKJXOtaZ_XxDcwCs9cswsDwutvTxdqtIXZN526l59zQ9uNYx9roLdN8n6WbhuZUSkQK7xrS-KgqdxZPlMg-ImqlwgWkDM8G8DLLcHuAzrb1r7F2tXjGjUDSDWS1zSCScg79WezPcL4bQ_zTAMDOQ0w4OMCxCJKwRye4KYY7c4QnCVdW3JiFrHByo2oVZOtknTiJOllsY6OkziVeiRrMwiRMhwgGKAisTosFcqzHILptzApWYIb8Jdx3glaJStoWbdgV80Rne_u333z3FfCajpwsfvkGM_yss5jAgcN_eNDXKBTZxx8NUu3d3Kz2u0tr_5MBOILwuItUNqWLhc1oMqkFnHrXwna874t9bgOvxR3ve552BjXL54XU7aGjTPKAGTAcWsxnvS2tx-wpTO8vZ9tsCnbDItpu9JDZnd-JqAfIr37qqeygxt0iIwPhRLz6Jlrjd4aqnpAhsFTb3CPkUX0hOeiCWYRkuTuqLXCUsycCjc_6Y5oznlPd6Pf2_IEpGBn819ob32Z8vbXsO7eTtBz3Yxc0CkuizkQQa_Efgz5kFFJoWwmzlMbl-SlArEgvNaiXv1WFWTR9jrUFZ1GRscczFbTjYWanmLawtR9NFyTj4G7XjB4Ikc4cyZIIsqEtRJH7IMd-3HVSnJX5jICyHagugShAPpwnyA_dn4_kiyXl821_nLCyWWMrQQNxQvoKA9EDKaXRLJ6RpnKWB5vaOm4el2v8rIgpE7OAhNW4wowVTdnn9lk3FcYa2arv_4415X07VY03njlmCV025HRxMNbc9ay4B6nndEBLHbP5TpfJHOzF6o57keP3LauKOzyVLN9YOsuc2Ht9vd4JZiyCuVzAaj4Z_G-ZvcSvRvXkTCS-bjyGH2FPw7MAQWDBw6ArBi-WTSYFwX4_k_bb0QjGmMjrLrbn4Vt_ClGKaapUajENwcnPBIpQ-p1yQBq0lSEQeVPwX76zIiktgiBFOD0MkJiWZSgybnwSdM3sN1kr7mP6Lph9DP7JiTHLakd-htJAyVmJbvRuT2_vpH9ywMddWpeHQiwBGhBjYxVWv0AdYUw7Gs7pVCC9ccPM1A727NGs-ecXFvxutO3lyR16zgw-e2dEt6eITYS4e1IsCe05r01WDUbR8B6IsMFl0sd7qG69X8nVLbK-m8sfURYLSrLsiXvsrmWNBaqraVfj1y6ALTKuJ657heQsF0BuNYVJldK_SgmmefTExc_t6ApkbkokWWMLZxk3J9wtCs4xrUzFkHX3AkqLpiAdi3yUyTjr-vPA5KHXLcBoDuj883w4yfwmKN_hGphWAcjv3N99_ao9UGYfNVIJmxcg7vMGlA1uEyawY3WjA5xlSrM6k--lph3PrT9Ukm8ojiZCMaMiJDVNjKORUJUyiSW8qJTcZEvKmfoju9KDuVfPbf0zT8vmQXWmAzWuH0QMi1KXjQEqtVoqgetV-YwzaZ-i7m8KPWxkRjV4t8aM1P6k71fA7DnOunbySPlEG-jNqxIrY5HNTbinDBDF_zp52JpL0saMKUfnY2EHL8gWXoXG4OguxzNFofp3tPk_uadv8rbmdno3RVPB7KrJqZFizoQ35F7MahgHCunKr9oK4uJ82sWQEa-tXgX8GI7a_rp-O5U6faibRjFSZODU-WXukzoSMhQrcJDpXT_1s5imdkJDV0wM20e-f18fjniMaSaCmgXOA3RdnPlZc26c6giZ7InttDaNRZCr-RCsDjQQVN4AKwnE5XM3yHL2usRx8ILmXZYWfTDNn-UDeueocDcuPhx9aMFf2rRMcw==' ) - assert thinking_part.openrouter_type == snapshot('reasoning.text') - assert thinking_part.openrouter_format == snapshot('unknown') async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: From 1db529f4be1a6ecc9b84e8969e3bfb79578d6da1 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 27 Oct 2025 14:19:54 -0600 Subject: [PATCH 15/42] finish openrouter thinking part --- .../pydantic_ai/models/openrouter.py | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index a0bcb7928c..de92d30063 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,10 +1,10 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Literal, cast from openai import AsyncOpenAI from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam from openai.types.chat.chat_completion import Choice -from pydantic import BaseModel +from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict from .. import _utils @@ -253,21 +253,21 @@ class ReasoningSummary(BaseReasoningDetail): """Represents a high-level summary of the reasoning process.""" type: Literal['reasoning.summary'] - summary: str + summary: str = Field(validation_alias=AliasChoices('summary', 'content')) class ReasoningEncrypted(BaseReasoningDetail): """Represents encrypted reasoning data.""" type: Literal['reasoning.encrypted'] - data: str + data: str = Field(validation_alias=AliasChoices('data', 'signature')) class ReasoningText(BaseReasoningDetail): """Represents raw text reasoning.""" type: Literal['reasoning.text'] - text: str + text: str = Field(validation_alias=AliasChoices('text', 'content')) signature: str | None = None @@ -276,7 +276,7 @@ class ReasoningText(BaseReasoningDetail): @dataclass(repr=False) class OpenRouterThinkingPart(ThinkingPart): - """filler.""" + """A special ThinkingPart that includes reasoning attributes specific to OpenRouter.""" type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text'] index: int @@ -317,22 +317,7 @@ def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail, provider_na ) def into_reasoning_detail(self): - reasoning_detail = { - 'type': self.type, - 'id': self.id, - 'format': self.format, - 'index': self.index, - } - - if self.type == 'reasoning.summary': - reasoning_detail['summary'] = self.content - elif self.type == 'reasoning.text': - reasoning_detail['text'] = self.content - reasoning_detail['signature'] = self.signature - elif self.type == 'reasoning.encrypted': - reasoning_detail['data'] = self.signature - - return reasoning_detail + return TypeAdapter(OpenRouterReasoningDetail).validate_python(asdict(self)).model_dump() class OpenRouterCompletionMessage(ChatCompletionMessage): @@ -475,12 +460,8 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti for message, openai_message in zip(messages, openai_messages): if isinstance(message, ModelResponse): - reasoning_details = [] - - for part in message.parts: - if isinstance(part, OpenRouterThinkingPart): - reasoning_details.append(part.into_reasoning_detail()) - - openai_message['reasoning_details'] = reasoning_details + openai_message['reasoning_details'] = [ + part.into_reasoning_detail() for part in message.parts if isinstance(part, OpenRouterThinkingPart) + ] return openai_messages From 3d7f1b40f5d1e86dea4f5b00a827f29596ff3664 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Tue, 28 Oct 2025 08:25:25 -0600 Subject: [PATCH 16/42] add preserve reasoning tokens test --- .../pydantic_ai/models/openrouter.py | 5 +- ...t_openrouter_preserve_reasoning_block.yaml | 132 +++++++ .../test_openrouter_with_reasoning.yaml | 326 ++++++------------ tests/models/test_openrouter.py | 52 ++- 4 files changed, 279 insertions(+), 236 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index de92d30063..5c70287e2c 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -460,8 +460,9 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti for message, openai_message in zip(messages, openai_messages): if isinstance(message, ModelResponse): - openai_message['reasoning_details'] = [ + if reasoning_details := [ part.into_reasoning_detail() for part in message.parts if isinstance(part, OpenRouterThinkingPart) - ] + ]: + openai_message['reasoning_details'] = reasoning_details return openai_messages diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml new file mode 100644 index 0000000000..74a253a510 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml @@ -0,0 +1,132 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '171' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: What was the impact of Voltaire's writings on modern french culture? Think about your answer. + role: user + model: openai/o3 + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '11648' + 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: stop + index: 0 + logprobs: null + message: + content: "Impact of Voltaire’s Writings on Modern French Culture\n\n1. A civic vocabulary of liberty and tolerance\n• + “Écrasez l’infâme” (Crush the infamous thing) and “Il faut cultiver notre jardin” (We must cultivate our garden) + are still quoted by politicians, journalists and schoolchildren. \n• His defense of minor-religion victims (Calas, + Sirven, La Barre) supplied iconic cases that taught the French the meaning of liberté de conscience. \n• Concepts + central to the Revolution (droits de l’homme, liberté d’expression, égalité civile) were first popularized not + by legal texts but by Voltaire’s pamphlets, letters and contes philosophiques. When the Déclaration des droits + de l’homme et du citoyen (1789) was drafted, deputies openly cited him.\n\n2. Laïcité as a cultural reflex\n• + Voltaire’s relentless criticism of clerical power helped dissociate “being French” from “being Catholic.” \n• + The 1905 law separating Church and State and the contemporary consensus that religion is a private matter (laïcité) + both rest on an attitude—skepticism toward organized religion—that Voltaire normalized. \n• His nickname l’Athée + de la Sorbonne is still invoked in current debates about headscarves, bio-ethics or blasphemy; op-ed writers speak + of a “Voltaire moment” whenever satire confronts religion (Charlie Hebdo, exhibitions, plays, etc.).\n\n3. Freedom + of speech as a near-sacred principle\n• Voltaire’s legendary—if apocryphal—phrase “I disapprove of what you say, + but I will defend to the death your right to say it,” regularly appears in parliamentary debates, media codes + of ethics and lycée textbooks. \n• Modern defamation and press-liberty laws (1881 and after) were drafted in + a climate steeped in Voltairian skepticism toward censorship.\n\n4. The French taste for “esprit” and satire\n• + Candide, Lettres philosophiques, and Dictionnaire philosophique established the short, witty, corrosive form as + a French ideal of prose. \n• Newspapers like Le Canard enchaîné, TV programs such as “Les Guignols,” and graphic + novels by Luz or Jul draw directly on the Voltairian strategy: humor plus moral indignation. \n• Even serious + political commentary in France prizes the mot d’esprit and the reductive punch line—an unwritten stylistic legacy + of Voltaire.\n\n5. Educational canon and cultural literacy\n• Voltaire is compulsory reading in collège and lycée; + exam questions on Candide are perennial. \n• His letters model the “dissertation française” structure (thèse, + antithèse, synthèse) taught nationwide. \n• The annual “Prix Voltaire” (CLEMI) rewards high-school press clubs + that fight censorship, rooting his ideals in adolescent civic training.\n\n6. Influence on French legal and political + institutions\n• The Council of State and the Constitutional Council frequently cite “liberté de penser” (Voltaire, + Traité sur la tolérance) when striking down laws that restrict expression. \n• The secular “Journée de la laïcité,” + celebrated each 9 December, uses excerpts from Traité sur la tolérance in official posters distributed to town + halls.\n\n7. Literary forms and genres\n• The conte philosophique (Candide, Zadig, Micromégas) paved the way for + the modern nouvelle, the hybrid “essay-novel” of Sartre, Camus, Yourcenar, and for the philosophical BD (Sfar’s + Le chat du rabbin). \n• Voltaire’s mixing of reportage, satire, philosophy and fiction prefigured the essayistic + style of today’s “livres de société” by writers such as Houellebecq or Mona Chollet.\n\n8. Language: a living + imprint\n• “Voltairien/Voltairienne” denotes caustic wit; “voltairisme” means sharp, secular critique. \n• His + aphorisms—“Le mieux est l’ennemi du bien,” “Les hommes naissent égaux” —crop up in talk-shows and business seminars + alike.\n\n9. National memory\n• Burial in the Panthéon (1791) created the template for the République’s secular + sanctuaries. \n• Libraries, streets, Lycée Voltaire (Paris, Orléans, Wingles) and the high-speed train “TGV 220 + Voltaire” embed him in daily geography. \n• Bicentenary celebrations in 1978 and the 2014 republication of Traité + sur la tolérance (after the Charlie Hebdo attacks) both caused nationwide spikes in sales, proving enduring resonance.\n\n10. + A benchmark for intellectual engagement\n• When French public intellectuals sign manifestos (from Zola’s “J’accuse” + to the recent petitions on climate or pensions), the very act echoes Voltaire’s pamphlet warfare: use the pen + to influence power. \n• The Académie française and PEN International invoke him as the patron saint of the “écrivain + engagé,” a figure central to modern French self-understanding.\n\nIn short, Voltaire’s writings did more than + enrich French literature; they installed reflexes—skepticism, satire, secularism, and the primacy of individual + rights—that continue to structure French laws, education, media tone, and collective identity. Modern France’s + attachment to laïcité, its bawdy political humor, its fierce defense of free expression, and even the way essays + are written in lycée classrooms all carry a Voltairian fingerprint." + reasoning: |- + **Exploring Voltaire's impact** + + The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + + I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects. + reasoning_details: + - format: openai-responses-v1 + index: 0 + summary: |- + **Exploring Voltaire's impact** + + The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + + I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects. + type: reasoning.summary + - data: gAAAAABpALH0SrlqG_JSYqQT07H5yKQO7GcmENK5_dmCbkx_o6J5Qg7kZHHNvKynIDieAzknNQwmLf4SB96VU59uasmIK5oOyInBjdpczoYyFEoN8zKWVOolEaCLXSMJfybQb6U_CKEYmPrh_B4CsGDuLzKq7ak6ERC0qFb0vh6ctchIyzWN7MztgnrNt85TReEN3yPmox0suv_kjEc4K5nB6L8C5NOK8ZG4Y3X88feoIvteZq0u2AapGPAYJ-tqWqbwYBBBocX7nYfOw3mVGgHb1Ku7pqf13IoWtgR-hz0lmgsLuzGucmjaBKE881oodwUGKUkDWuUbIiFIxGLH5V6cR53XttM91wAKoUgizg0HuFHS_TEYeP2rJhVBv-8OpUmdKFs-lIrCqVBlJDeIwQS_jGSaY4_z-6Prjh797X3_mSDtXBNqaiAgRQkMHHytW6mrVfZVA-cXikTLX5CRc266KNY6MkaJRAS7-tOKxjMwE-IyvmrIIMW4YTdnoaTfVcZhE5YpbrqilZllTW2RtU4lLFh4DFmBRplJsh2F4the_VXm1LITRYrZlrkLB3qTkA_oPslxFxGk_BApWRmbpCxs9mNgwzqqDCsYyvkGqUNAqCTdgPZMApWwJyRNURu_s8yHo-wcLS42zgPvC64E2GvNaO5G5xPFApbHyN950seaSiivqLc-TysXpk6RxNwKm2l1EJDPvMk0G6sZnLlQVPSXQQsCcSfZmJFSHUNSk7u99o5JsuHWsW5oco2cD111Ghd2EAujdimTRGbhjhTTt1SOGl0DL7EgYVWFiYXxgB7XsXy6PgzuIXBuJkJRn4qpk6VeRHpHujbntbVlxlt5ah-lcvRqka8JEew5NXv4qL5zuMQiSIhmHdw_zVucZv7TqknUPJSovsFte40pYwVIeQP23HMekTqsAwEjc4S28Kg300IchGuEi9ihEL9-5_kgrFTgGOOQhNYo28ftnTD7LtoS5m3Av9CraHdbK9Y4bs1u7-qFfCopcamLXJPQe1ZQ0xqR3_zGQJtK24N_oi2Et5g4o_flqzyVwrd83B5nrcbUuayJL3C9SQg4NR2VD8eS96c3qIl_FxCsD6SoTQu22VbrRngvkM_WP1EtvBSKwMtYHnHlQSufV1bkv4E3JXfHg2UJZdvJ0MtfNMTY9qx39YlI1A1Ds4ctMjCF4qAS2XPkUvvgIpwFq4JzH3v2d-f57itMmqamINLmxP2Pv1J69kj7M_shl_FWTJrWn_MtKLsS77Awxc3NdhXhvA2ketiLp_wOE8CED-o4j_Yh0NKy2AVNqeQcmZvJ3FK2vysB2oAjRqTemcad_B2fHkdceoMvSqAYk26gGm8Nvu8GK_atpKOfi1akGKQBRoERZmPT2wyDpXXS4GdVMyC8m5MUa7xJHwUsRDn4ucW792Pt_5skKrBK_So2pGhmoZa8nJYZ8x7O9ZNEXF6a4OIRgbGKnkVpP95YzlQAsVxR31YXkE1pcdM4nRqCpPjdoQjZ0Twr0ute5v4J6Lhb1F3FsNrg3Sm9YRkJ9h-yfUfvyt1bK1V4nFMtRFt120WjfIvlZZ-1qyenToySK8doSSUZ6VOQWG_ieBkf-IRAN3eONC0n6BfGogsVlPXhXHLznouLnzapC4pGWWBIDsGlTvZj_o7UpHMPr_20PDC5d2jSGGtXf6kJvtfsAnJjtQPHs41VfDLyT-yQIlnUd8QvdwUlQ22A79I-rg38C8BWJNqg3sbOtzMMpt6R8Cvyp4dmB1ksS24tpiEZZ42aH8JIgoqs1sRbFPsC1v3kDPd3XRbbKpliQxseR_xWMNZkGj2F8q9HH1lgLkkCod_97OYrYBROxn9K79wlkZBUFjrNXA3EuiBf-IDOvQeKtDRypAaTKnHybIEOIypTNOWjhGT6oQutKSFswfvSeJGA0fF26FAgxnVmzFS7eAyzSHDqygQfhB7Yp7N2yEbD0eFLUs8qgete-eDIn6eM5E5eMnT1JeP6LD8ku5iR30sDdU8O6BrsGvUypMSID-hoBDytF1_GS6yOhMsU4pXZHTJ4yYNUOFyMH3ReE3SeAuFFohR9aXTpUA5YeLy6-Xo0_ZA9FuFMDVK4Bp1F5f-2BJ3FXRc1aqtyROpMdBtY4ehEqm-FKqbYd4VlaIMb9adG1LnOgWpnWCr9ciOP-c75rxX885yZLXO8rHJ_wbg04JzobGFnKdZHPtCYiTgkpnFavesiy9iI_bRO0Mu7SaDwXne6u4NY4YIHGRRCKR7o98lvSCOw7PT3SgWPoHEML6Na0QnycJeiPayB7megnFGfQZn_lSzDDeAiKgOBJ42LJZf3ysH1Dueqb7icX6xn4HlrMJdDLhMCgvwry4QQkrgrsIhyrTFNt2j--0IO7hX4RbwU5v5yudb0QRQovbmjoPRk4qeZKOyH-YSW-J1lu2MJcrk9Z-Sc4d875cZ6B3HxUuJFYQWqMoJ6EkZjNRLpp3XBkFEu5ip4md7yu_FYK7SInsuVI0igMVRx5i9vURIiGnVf60yBfWpqJac0Jp_7V3ftXsdXk3pSE4_GF9QgKM4l9chXH-frUEExxXS13BRQJP1b29-0B7ciG-48c18uSktRItBmXv2_baSiyo7_nvnPWVUgpig9qOuiFFmVPPvFRGTQS6jh8dPZR8PCFnpxuMhVrrJDJUNu8wmVGdVDMZP6kO2PYhNpz35RrX25SSgzjbl6V4uFDb7KQdWQAv-78QkLibUeB7w47I_2G47TGxbUsmnTt_sss1LW0peGrmCMKncgSQKro8rSBQ== + format: openai-responses-v1 + id: rs_06569cab051e4c78016900b1eb409c81909668d636e3a424f9 + index: 0 + type: reasoning.encrypted + refusal: null + role: assistant + native_finish_reason: completed + created: 1761653226 + id: gen-1761653226-LJcHbeILDwsw5Tlqupp9 + model: openai/o3 + object: chat.completion + provider: OpenAI + usage: + completion_tokens: 1431 + completion_tokens_details: + reasoning_tokens: 256 + prompt_tokens: 25 + total_tokens: 1456 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml index 1a28651a07..abed014bc6 100644 --- a/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml +++ b/tests/models/cassettes/test_openrouter/test_openrouter_with_reasoning.yaml @@ -28,7 +28,7 @@ interactions: connection: - keep-alive content-length: - - '19192' + - '20169' content-type: - application/json permissions-policy: @@ -49,82 +49,93 @@ interactions: content: |2- - This is an excellent question that requires moving beyond a simple historical summary. To understand Voltaire's impact on *modern* French culture, we need to see his ideas not as dusty relics, but as living, breathing principles that continue to shape French identity, politics, and daily life. + Of course. This is an excellent question that requires looking beyond simple historical facts and into the very DNA of a nation's identity. The impact of Voltaire's writings on modern French culture is not merely a historical legacy; it is a living, breathing, and often contested foundation. - Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA. His impact is not monolithic but can be understood through several key, interconnected domains. + To think about it properly, we must break down his influence into several key areas that are still visible in France today. - ### 1. The Architect of *Laïcité*: The War Against "L'Infâme" + --- - Perhaps Voltaire's most profound and enduring impact is on France's unique form of secularism, ***laïcité***. + ### 1. The Foundation of *Laïcité*: The War on Religious Dogma - * **The Core Idea:** Voltaire waged a lifelong intellectual war against what he called **"l'Infâme"** (the infamous thing)—a catch-all term for the power, intolerance, and superstition of the Catholic Church. He wasn't an atheist; he was a Deist who believed in a "clockmaker" God. His target was organized religion's interference in state, science, and individual conscience. - * **Modern Manifestation:** This crusade directly paved the way for the **1905 law on the separation of Church and State**. Today, when French politicians and citizens defend *laïcité*—whether in debates over religious symbols in schools or the Islamic veil—they are, consciously or not, echoing Voltaire's central tenet: that the public sphere must be free from religious dogma to ensure liberty for all. The French instinct to be suspicious of religious authority is a direct inheritance from Voltaire. + This is arguably Voltaire's most profound and enduring impact. His famous cry, **"Écrasez l'infâme!"** ("Crush the infamous thing!"), was a direct and relentless assault on the power, intolerance, and superstition of the Catholic Church. - ### 2. The Spirit of *L'Esprit Critique*: The Birth of a National Pastime + * **Voltaire's Contribution:** Through works like the *Dictionnaire philosophique* and his treatise on tolerance, he didn't just critique religious belief; he attacked the institutional power of the Church over the state, education, and justice. His campaign to rehabilitate Jean Calas, a Protestant wrongly executed, was a masterclass in using public opinion to fight religious injustice. + * **Modern French Impact:** This spirit directly fueled the French Revolution's seizure of Church lands and the radical secularism of the Third Republic. It culminated in the **1905 law on the Separation of the Churches and the State**, which established *laïcité* as a core principle of the Republic. Today, debates over the wearing of hijabs in schools or the display of religious symbols in public spaces are a direct continuation of the Voltairean struggle to keep the public sphere strictly secular. For many French people, *laïcité* isn't just a law; it's a defense of reason and liberty against dogma, a battle Voltaire started. - Before Voltaire, challenging authority was dangerous. After him, it became a form of high art and a civic duty. + ### 2. The Sanctity of *Liberté*: Freedom of Expression - * **The Core Idea:** Voltaire mastered the use of **wit, satire, and irony** as weapons against tyranny and absurdity. In works like *Candide* with its famous refrain "we must cultivate our garden," he lampooned philosophical optimism and the foolishness of the powerful. He taught that no institution, belief, or leader was above ridicule. - * **Modern Manifestation:** This is the origin of ***l'esprit critique***, the critical spirit, which is a hallmark of French culture. It manifests in: - * **Political Satire:** The fearless, often scathing, satire of publications like ***Le Canard Enchaîné*** and, most famously, ***Charlie Hebdo***, is a direct descendant of Voltaire's style. The very act of drawing a caricature of a prophet or president is a Voltairean assertion of free expression. - * **Everyday Debate:** The French love a good, heated argument (*une bonne discussion*). Questioning the teacher, challenging the boss, and debating politics over dinner are seen not as acts of disrespect, but as signs of an engaged and intelligent mind. This intellectual sparring is a cultural habit Voltaire helped popularize. + Voltaire was a zealous advocate for freedom of speech, thought, and of the press. He was constantly censored, exiled, and forced to publish his most controversial works anonymously or abroad. - ### 3. The Bedrock of the Republic: "Liberté" as a Sacred Value + * **Voltaire's Contribution:** He used satire, irony, and wit as weapons to bypass censors and criticize the monarchy, the aristocracy, and the Church. *Candide* is a prime example—a seemingly simple story that savagely lampoons philosophical optimism, religious hypocrisy, and social injustice. + * **Modern French Impact:** This is enshrined in the first word of the Republic's motto: **"Liberté."** The French have a fierce, often absolutist, defense of free speech, even for speech they find offensive. The **"Charlie Hebdo"** affair is the ultimate modern example. The magazine's deliberate, provocative, and often blasphemous cartoons are a direct descendant of Voltaire's satirical style. The rallying cry *"Je suis Charlie"* was, in essence, a modern declaration of *"Je suis Voltaire."* The willingness to "offend" in the name of challenging power is a deeply Voltairean trait in modern French culture. - Voltaire was not a democrat; he favored an "enlightened monarch." However, his obsessive focus on individual liberties provided the ideological fuel for the French Revolution and the Republic that followed. + ### 3. The Rise of the Public Intellectual and *L'Esprit Critique* - * **The Core Idea:** Through his campaigns, most famously the **Calas affair** (where he fought to overturn the wrongful execution of a Protestant Huguenot), Voltaire championed the principles of **freedom of speech, freedom of religion, and the right to a fair trial**. - * **Modern Manifestation:** These ideas are not just abstract; they are enshrined in France's most sacred text: the ***Déclaration des Droits de l'Homme et du Citoyen*** of 1789. The first word of the Republican motto, **"Liberté,"** is Voltaire's legacy. When the French take to the streets to defend their rights—a common sight—they are invoking the Voltairean principle that individual liberty is paramount and must be protected against the encroachment of state power. + Before Voltaire, philosophers were often academics. Voltaire made being an "intellectual" a public role. He was a celebrity, a socialite, a correspondent with kings and emperors, who used his immense fame to influence public opinion. - ### 4. The Archetype of the *Intellectuel Engagé* + * **Voltaire's Contribution:** He demonstrated that ideas could be a form of political action. He engaged with the public, not just other scholars. His style was clear, witty, and accessible, designed for a growing literate bourgeoisie, not just for other philosophers. + * **Modern French Impact:** France has a unique and enduring respect for its **"intellectuels."** Figures like Jean-Paul Sartre, Simone de Beauvoir, Michel Foucault, and Pierre Bourdieu were national figures who weighed in on politics and society. This cultural expectation—that thinkers have a public responsibility—is a Voltairean invention. Furthermore, his work instilled a cultural value known as ***l'esprit critique***—the critical spirit. The French education system and social discourse prize the ability to deconstruct arguments, question authority, and engage in rigorous, often skeptical, debate. - Voltaire created the model for the public intellectual in France. + ### 4. The Seeds of Human Rights and Social Justice - * **The Core Idea:** He was the first major writer to use his fame and literary talent not just for art's sake, but to actively intervene in social and political injustices. He used his pen as a weapon for the common good. - * **Modern Manifestation:** This created a powerful French tradition of the ***intellectuel engagé*** (the engaged intellectual). From **Émile Zola's** "J'accuse...!" in the Dreyfus Affair to **Jean-Paul Sartre's** political activism in the 20th century, French intellectuals have seen it as their responsibility to speak truth to power. Voltaire set the standard: to be a thinker in France is to have a social duty. + While not a democrat—he favored an "enlightened despot"—Voltaire's writings laid the groundwork for the concept of universal human rights. - ### Nuances and Contradictions: The Imperfect Prophet + * **Voltaire's Contribution:** His focus on the Calas and Sirven cases was not about legal technicalities; it was about the inherent right of an individual to a fair trial, regardless of their religion. He argued for the rule of law to protect the individual from the arbitrary power of the state and the mob. + * **Modern French Impact:** This directly fed into the **Declaration of the Rights of Man and of the Citizen (1789)**, a foundational document of the Republic. The modern French commitment to *universalisme*—the idea that human rights are universal and not tied to any particular culture or identity—has its intellectual roots in Voltaire's appeals to reason and humanity over sectarian law. - A thoughtful analysis must also acknowledge that Voltaire was a man of his time, and his legacy is complex. + ### Nuance and Contradictions: The Complicated Legacy - * **Not a True Democrat:** He was an elitist who believed in rule by an educated, enlightened class, not by the masses. He would likely have been horrified by the full excesses of the French Revolution. - * **Personal Prejudices:** He held antisemitic and anti-African views that were common among the aristocracy of the 18th century. While he fought for religious tolerance for Christians, his vision of universalism had clear limits. + To truly "think" about the answer, we must acknowledge the complexities. Voltaire's legacy is not a simple story of progress. + + * **Not a Democrat:** Voltaire was an elitist who feared the "mob." He believed in rule by an educated, enlightened monarch, not by the people. The French Revolution took his ideas of liberty and reason and pushed them to a democratic extreme he would have found terrifying. + * **Personal Prejudices:** The great champion of tolerance held his own significant prejudices. His writings contain anti-Semitic passages and racist views about non-Europeans, particularly Africans. This complicates his image as a universalist and serves as a reminder that even the great architects of modernity were products of their time, with deep flaws. + + --- ### Conclusion - The impact of Voltaire's writings on modern French culture is not just historical; it is elemental. He is the ghost in the machine of French identity. When a French person defends *laïcité*, uses sharp wit to critique a politician, or asserts their right to disagree, they are speaking Voltaire's language. + The impact of Voltaire on modern French culture is not that of a dusty historical figure whose ideas are occasionally quoted. He is a **ghost in the machine**. When France debates secularism, it is channeling Voltaire. When a French satirist provokes outrage in the name of free speech, it is a Voltairean act. When a French student is taught to critically analyze a text rather than accept it at face value, they are learning in a tradition Voltaire helped create. - He provided France with its most cherished tools: skepticism as a virtue, liberty as a goal, and the pen as a mighty weapon. While his own prejudices and elitism complicate his legacy, the core principles he championed—reason, tolerance, and fearless critique—remain the very foundation upon which modern French culture is built, debated, and continuously redefined. + His spirit of *l'esprit critique*, his unwavering defense of *liberté*, and his profound suspicion of religious and political dogma are not just influences; they are the very pillars upon which the modern French identity of the Republic is built, for better and for worse. reasoning: |- 1. **Deconstruct the User's Prompt:** * **Core Subject:** Voltaire's writings. * **Core Question:** What was their impact? - * **Specific Context:** *Modern French culture*. - * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's asking for depth, not just breadth. + * **Specific Focus:** Modern French culture. + * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's a prompt for depth and quality. 2. **Initial Brainstorming & Keyword Association:** - * **Voltaire:** Enlightenment, *Philosophe*, reason, logic, tolerance, freedom of speech, freedom of religion, criticism of the Church (l'Infâme), criticism of absolute monarchy, satire, wit, *Candide*, *Dictionnaire philosophique*, *Lettres philosophiques*, Deism, Calas affair, Émilie du Châtelet. - * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), debate, protest, Gauloiserie (a bit cheeky, rebellious), universalism, human rights, cafés, education system, political discourse. - - 3. **Structure the Answer:** A good structure is key for a "think about your answer" type of question. - * **Introduction:** Start with a strong, concise thesis statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. Something like, "Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA." - * **Body Paragraphs (Thematic Approach):** Instead of just listing his works, I'll group the impacts into key themes. This shows analysis. Good themes would be: - * **The Birth of *Laïcité* and the Critique of Religious Power:** This is a huge one. Connect his fight against "l'Infâme" (the Church's intolerance) directly to the 1905 law on the separation of church and state and the modern concept of *laïcité*. Mention specific examples like the Calas affair. - * **The Spirit of *L'Esprit Critique* and Satire:** This is about the *style* of French discourse. Connect his wit and satire (e.g., *Candide*) to the modern French love of debate, political satire (like *Le Canard Enchaîné* or Charlie Hebdo), and a certain intellectual skepticism. This is a very "cultural" point. - * **The Foundations of the Republic: Liberty and Human Rights:** This is the political legacy. Link his ideas on freedom of speech, expression, and religious tolerance to the *Déclaration des Droits de l'Homme et du Citoyen* (1789) and the modern Republican motto "Liberté, Égalité, Fraternité." He directly inspired "Liberté." - * **The French Intellectual Tradition:** Position Voltaire as the archetype of the *intellectuel engagé* (the engaged intellectual). This connects him to later figures like Zola (Dreyfus Affair), Sartre, and Foucault. Explain that the idea of the intellectual using their platform for social and political justice starts with him. - * **Language and Education:** How did his work shape the French language itself? He championed clarity, precision, and wit. Mention his influence on the *Académie française* and the style of writing prized in the French education system (*la dissertation*). He made French the language of Enlightenment, not just the court. - * **Nuance and Counterarguments (The "Think About It" part):** A great answer doesn't just praise. It acknowledges complexity. - * Was he a true democrat? No, he was an enlightened monarchist, not a republican in the modern sense. He believed in a wise king, not popular rule. - * Was he free from the prejudices of his time? No. He had documented antisemitic and anti-African sentiments, common among the aristocracy of his era. This is crucial for a balanced view. It shows that his legacy is complex and has been reinterpreted over time. - * **Conclusion:** Summarize the main points. Reiterate the thesis in a new way. End with a powerful, concluding thought. Emphasize that his legacy is a living, breathing part of French culture, constantly debated and reinterpreted. The phrase "a ghost in the machine of French culture" or something similar could be effective. It captures his pervasive but sometimes contested presence. - - 4. **Drafting and Refining (Self-Correction):** - * As I write, I'll make sure to use specific French terms like *laïcité*, *l'esprit critique*, *l'Infâme*, and *philosophe*. This adds authenticity and precision. - * I'll check for flow between paragraphs. Do the transitions work? For example, moving from the political (Republic) to the cultural (Intellectual) needs a smooth link. - * I'll ensure the introduction and conclusion bookend the argument effectively. The conclusion shouldn't just repeat the intro but synthesize the body paragraphs. - * Is the tone right? It should be authoritative but accessible, analytical but not overly academic. - * I'll read it aloud to catch awkward phrasing. For instance, instead of "Voltaire's writings made the French culture," I'll use more sophisticated phrasing like "Voltaire's writings indelibly shaped the contours of modern French culture." + * **Voltaire:** Enlightenment, *Candide*, *Lettres philosophiques*, *Dictionnaire philosophique*, tolerance, freedom of speech, religion (critique of), deism, reason, satire, wit, irony, "Écrasez l'infâme" (Crush the infamous thing), Calas affair, social justice, monarchy, absolutism. + * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), satire (Charlie Hebdo), debate, protest, universalism, human rights, French language (academy, precision), gastronomy? (Maybe a stretch, but let's keep it in mind), education system. + * **Connecting the two:** + * Critique of Church -> *Laïcité* + * Freedom of Speech/Reason -> *Liberté*, *l'esprit critique*, satire + * Social Justice (Calas) -> Human rights, *Égalité*, protest culture + * Wit/Irony -> French intellectual tradition, debate culture + * Use of French -> Precision of the language, the role of the public intellectual + + 3. **Structure the Answer:** A good structure is key to a "think about your answer" response. + * **Introduction:** Start with a strong, summary statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. + * **Thematic Sections (Body Paragraphs):** This is where the real analysis happens. I'll group the impacts into clear, thematic categories. This is better than a chronological list or a random jumble. My brainstormed connections are perfect for this. + * **Theme 1: The Birth of *Laïcité* and the Critique of Religious Power.** This is arguably the most significant impact. I'll explain *Écrasez l'infâme*, connect it to the Calas affair, and show how this evolved into the strict secularism of the Third Republic and modern *laïcité*. I'll mention the 1905 law on the separation of Church and State. + * **Theme 2: The Spirit of *Liberté* and Freedom of Expression.** This is the "free speech" angle. I'll talk about his battles against censorship, his use of satire and pseudonyms. I'll connect this directly to the French Republic's motto and the modern, often fierce, defense of free speech, even when controversial (e.g., Charlie Hebdo). This shows a direct, living legacy. + * **Theme 3: The Rise of the Public Intellectual and *L'Esprit Critique*.** This is about the *style* and *role* of the thinker in France. Voltaire wasn't just a philosopher; he was a public figure, a celebrity. He used his wit and platform to influence public opinion. I'll connect this to the modern status of intellectuals in France (Sartre, Foucault, Bourdieu) and the cultural value placed on sharp, critical debate (*l'esprit critique*). + * **Theme 4: The Seeds of Human Rights and Social Justice.** This focuses on his activism. The Calas and Sirven cases are perfect examples. He wasn't just writing abstract philosophy; he was fighting for individual rights against an arbitrary system. I'll link this to the Declaration of the Rights of Man and of the Citizen (1789) and the modern French focus on universal human rights. + * **Theme 5: The Shaping of the French Language and Style.** This is a more subtle but important point. Voltaire was a master of prose. He championed clarity, precision, and wit. I'll connect this to the French Academy's role and the general cultural appreciation for well-argued, elegant prose. His style became a model. + + * **Nuance and Counter-arguments (The "Think about it" part):** A great answer doesn't just praise. It acknowledges complexity. + * Was Voltaire a true democrat? No, he was a monarchist who believed in an "enlightened despot." This is a crucial nuance. The French Revolution took his ideas much further than he ever intended. + * What about his own prejudices? He had documented anti-Semitic and racist views. This is important to mention to avoid hagiography and show a balanced understanding. It complicates his legacy as a champion of universal tolerance. + + * **Conclusion:** Summarize the main points. Reiterate the central thesis: Voltaire is not just a historical figure but a "ghost in the machine" of French culture. End with a powerful, memorable sentence that ties it all together, perhaps linking his spirit to a contemporary French phrase or ideal. + + 4. **Drafting and Refining:** + * Write the introduction, making it punchy. + * Flesh out each thematic section using the brainstormed points. Use specific examples (*Candide*, Calas affair, *Écrasez l'infâme*). + * Use French terms where appropriate (*laïcité*, *l'esprit critique*, *Liberté, Égalité, Fraternité*) and briefly explain them. This adds authenticity and precision. + * Write the "Nuance" section carefully. Frame it not as a takedown but as a necessary part of a complete picture. + * Write the conclusion, ensuring it flows logically from the body and provides a sense of closure. + * Review the whole text. Check for flow, clarity, and repetition. Is the language strong? Is the argument coherent? Does it directly answer the user's prompt with the requested depth? (For instance, I'll make sure to explicitly use the phrase "modern French culture" throughout to stay on topic). The structure I've planned—Intro, 5 Thematic Points, Nuance, Conclusion—is robust and demonstrates the "thinking" process. reasoning_details: - format: unknown index: 0 @@ -132,185 +143,60 @@ interactions: 1. **Deconstruct the User's Prompt:** * **Core Subject:** Voltaire's writings. * **Core Question:** What was their impact? - * **Specific Context:** *Modern French culture*. - * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's asking for depth, not just breadth. + * **Specific Focus:** Modern French culture. + * **Implicit Instruction:** "Think about your answer." This means I need to go beyond a simple list. I need to analyze, connect ideas, show nuance, and structure the response logically. It's a prompt for depth and quality. 2. **Initial Brainstorming & Keyword Association:** - * **Voltaire:** Enlightenment, *Philosophe*, reason, logic, tolerance, freedom of speech, freedom of religion, criticism of the Church (l'Infâme), criticism of absolute monarchy, satire, wit, *Candide*, *Dictionnaire philosophique*, *Lettres philosophiques*, Deism, Calas affair, Émilie du Châtelet. - * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), debate, protest, Gauloiserie (a bit cheeky, rebellious), universalism, human rights, cafés, education system, political discourse. - - 3. **Structure the Answer:** A good structure is key for a "think about your answer" type of question. - * **Introduction:** Start with a strong, concise thesis statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. Something like, "Voltaire isn't just a historical figure in France; he is a foundational pillar of the modern French Republic and its cultural DNA." - * **Body Paragraphs (Thematic Approach):** Instead of just listing his works, I'll group the impacts into key themes. This shows analysis. Good themes would be: - * **The Birth of *Laïcité* and the Critique of Religious Power:** This is a huge one. Connect his fight against "l'Infâme" (the Church's intolerance) directly to the 1905 law on the separation of church and state and the modern concept of *laïcité*. Mention specific examples like the Calas affair. - * **The Spirit of *L'Esprit Critique* and Satire:** This is about the *style* of French discourse. Connect his wit and satire (e.g., *Candide*) to the modern French love of debate, political satire (like *Le Canard Enchaîné* or Charlie Hebdo), and a certain intellectual skepticism. This is a very "cultural" point. - * **The Foundations of the Republic: Liberty and Human Rights:** This is the political legacy. Link his ideas on freedom of speech, expression, and religious tolerance to the *Déclaration des Droits de l'Homme et du Citoyen* (1789) and the modern Republican motto "Liberté, Égalité, Fraternité." He directly inspired "Liberté." - * **The French Intellectual Tradition:** Position Voltaire as the archetype of the *intellectuel engagé* (the engaged intellectual). This connects him to later figures like Zola (Dreyfus Affair), Sartre, and Foucault. Explain that the idea of the intellectual using their platform for social and political justice starts with him. - * **Language and Education:** How did his work shape the French language itself? He championed clarity, precision, and wit. Mention his influence on the *Académie française* and the style of writing prized in the French education system (*la dissertation*). He made French the language of Enlightenment, not just the court. - * **Nuance and Counterarguments (The "Think About It" part):** A great answer doesn't just praise. It acknowledges complexity. - * Was he a true democrat? No, he was an enlightened monarchist, not a republican in the modern sense. He believed in a wise king, not popular rule. - * Was he free from the prejudices of his time? No. He had documented antisemitic and anti-African sentiments, common among the aristocracy of his era. This is crucial for a balanced view. It shows that his legacy is complex and has been reinterpreted over time. - * **Conclusion:** Summarize the main points. Reiterate the thesis in a new way. End with a powerful, concluding thought. Emphasize that his legacy is a living, breathing part of French culture, constantly debated and reinterpreted. The phrase "a ghost in the machine of French culture" or something similar could be effective. It captures his pervasive but sometimes contested presence. - - 4. **Drafting and Refining (Self-Correction):** - * As I write, I'll make sure to use specific French terms like *laïcité*, *l'esprit critique*, *l'Infâme*, and *philosophe*. This adds authenticity and precision. - * I'll check for flow between paragraphs. Do the transitions work? For example, moving from the political (Republic) to the cultural (Intellectual) needs a smooth link. - * I'll ensure the introduction and conclusion bookend the argument effectively. The conclusion shouldn't just repeat the intro but synthesize the body paragraphs. - * Is the tone right? It should be authoritative but accessible, analytical but not overly academic. - * I'll read it aloud to catch awkward phrasing. For instance, instead of "Voltaire's writings made the French culture," I'll use more sophisticated phrasing like "Voltaire's writings indelibly shaped the contours of modern French culture." + * **Voltaire:** Enlightenment, *Candide*, *Lettres philosophiques*, *Dictionnaire philosophique*, tolerance, freedom of speech, religion (critique of), deism, reason, satire, wit, irony, "Écrasez l'infâme" (Crush the infamous thing), Calas affair, social justice, monarchy, absolutism. + * **Modern French Culture:** *Laïcité* (secularism), Republic (Liberté, Égalité, Fraternité), intellectualism, *l'esprit critique* (critical spirit), satire (Charlie Hebdo), debate, protest, universalism, human rights, French language (academy, precision), gastronomy? (Maybe a stretch, but let's keep it in mind), education system. + * **Connecting the two:** + * Critique of Church -> *Laïcité* + * Freedom of Speech/Reason -> *Liberté*, *l'esprit critique*, satire + * Social Justice (Calas) -> Human rights, *Égalité*, protest culture + * Wit/Irony -> French intellectual tradition, debate culture + * Use of French -> Precision of the language, the role of the public intellectual + + 3. **Structure the Answer:** A good structure is key to a "think about your answer" response. + * **Introduction:** Start with a strong, summary statement. Acknowledge Voltaire's foundational role. State that his impact isn't just historical but is woven into the very fabric of modern French identity. Use a powerful opening sentence. + * **Thematic Sections (Body Paragraphs):** This is where the real analysis happens. I'll group the impacts into clear, thematic categories. This is better than a chronological list or a random jumble. My brainstormed connections are perfect for this. + * **Theme 1: The Birth of *Laïcité* and the Critique of Religious Power.** This is arguably the most significant impact. I'll explain *Écrasez l'infâme*, connect it to the Calas affair, and show how this evolved into the strict secularism of the Third Republic and modern *laïcité*. I'll mention the 1905 law on the separation of Church and State. + * **Theme 2: The Spirit of *Liberté* and Freedom of Expression.** This is the "free speech" angle. I'll talk about his battles against censorship, his use of satire and pseudonyms. I'll connect this directly to the French Republic's motto and the modern, often fierce, defense of free speech, even when controversial (e.g., Charlie Hebdo). This shows a direct, living legacy. + * **Theme 3: The Rise of the Public Intellectual and *L'Esprit Critique*.** This is about the *style* and *role* of the thinker in France. Voltaire wasn't just a philosopher; he was a public figure, a celebrity. He used his wit and platform to influence public opinion. I'll connect this to the modern status of intellectuals in France (Sartre, Foucault, Bourdieu) and the cultural value placed on sharp, critical debate (*l'esprit critique*). + * **Theme 4: The Seeds of Human Rights and Social Justice.** This focuses on his activism. The Calas and Sirven cases are perfect examples. He wasn't just writing abstract philosophy; he was fighting for individual rights against an arbitrary system. I'll link this to the Declaration of the Rights of Man and of the Citizen (1789) and the modern French focus on universal human rights. + * **Theme 5: The Shaping of the French Language and Style.** This is a more subtle but important point. Voltaire was a master of prose. He championed clarity, precision, and wit. I'll connect this to the French Academy's role and the general cultural appreciation for well-argued, elegant prose. His style became a model. + + * **Nuance and Counter-arguments (The "Think about it" part):** A great answer doesn't just praise. It acknowledges complexity. + * Was Voltaire a true democrat? No, he was a monarchist who believed in an "enlightened despot." This is a crucial nuance. The French Revolution took his ideas much further than he ever intended. + * What about his own prejudices? He had documented anti-Semitic and racist views. This is important to mention to avoid hagiography and show a balanced understanding. It complicates his legacy as a champion of universal tolerance. + + * **Conclusion:** Summarize the main points. Reiterate the central thesis: Voltaire is not just a historical figure but a "ghost in the machine" of French culture. End with a powerful, memorable sentence that ties it all together, perhaps linking his spirit to a contemporary French phrase or ideal. + + 4. **Drafting and Refining:** + * Write the introduction, making it punchy. + * Flesh out each thematic section using the brainstormed points. Use specific examples (*Candide*, Calas affair, *Écrasez l'infâme*). + * Use French terms where appropriate (*laïcité*, *l'esprit critique*, *Liberté, Égalité, Fraternité*) and briefly explain them. This adds authenticity and precision. + * Write the "Nuance" section carefully. Frame it not as a takedown but as a necessary part of a complete picture. + * Write the conclusion, ensuring it flows logically from the body and provides a sense of closure. + * Review the whole text. Check for flow, clarity, and repetition. Is the language strong? Is the argument coherent? Does it directly answer the user's prompt with the requested depth? (For instance, I'll make sure to explicitly use the phrase "modern French culture" throughout to stay on topic). The structure I've planned—Intro, 5 Thematic Points, Nuance, Conclusion—is robust and demonstrates the "thinking" process. type: reasoning.text refusal: null role: assistant native_finish_reason: stop - created: 1761580402 - id: gen-1761580402-57zjmoISWVKHAKdbyUOs + created: 1761603589 + id: gen-1761603589-2zW4FJLny121WIZ4fOS4 model: z-ai/glm-4.6 object: chat.completion - provider: GMICloud - usage: - completion_tokens: 2545 - prompt_tokens: 24 - prompt_tokens_details: null - total_tokens: 2569 - status: - code: 200 - message: OK -- request: - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate - connection: - - keep-alive - content-length: - - '171' - content-type: - - application/json - host: - - openrouter.ai - method: POST - parsed_body: - messages: - - content: What was the impact of Voltaire's writings on modern french culture? Think about your answer. - role: user - model: openai/o3 - stream: false - uri: https://openrouter.ai/api/v1/chat/completions - response: - headers: - access-control-allow-origin: - - '*' - connection: - - keep-alive - content-length: - - '11659' - 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: stop - index: 0 - logprobs: null - message: - content: "Voltaire (François-Marie Arouet, 1694-1778) wrote plays, poems, histories, pamphlets, tens of thousands - of letters and—above all—philosophical tales such as Candide. In the two-and-a-half centuries since his death - he has remained one of the most frequently quoted, taught and contested figures in France. His influence is felt - less in any single institution than in a collection of habits, reflexes and reference points that shape contemporary - French culture.\n\n1. Secularism (laïcité) and the place of religion \n • Voltaire’s most persistent target - was clerical power. By ridiculing dogma (“Écrasez l’infâme!”) and championing natural religion, he set the tone - for an anticlerical current that ran through the Revolution, the Third Republic and the 1905 separation law. \n - \ • The modern French consensus that religious belief is a private matter and that public authority must be - neutral—still fiercely debated in policies on head-scarves, schools and blasphemy—draws intellectual ancestry - from Voltaire’s writings and the style of reasoning they popularised.\n\n2. Free speech as a civic value \n - \ • The line “I disapprove of what you say, but I will defend to the death your right to say it” is apocryphal, - yet the principle it sums up is recognisably Voltairian. \n • Contemporary French jurisprudence on press freedom, - the satirical tradition of Le Canard enchaîné, Charlie Hebdo and the visibility of polemical essayists all cite - Voltaire as a legitimising ancestor.\n\n3. Critical rationalism and the “spirit of 1789” \n • Voltaire helped - make sceptical, analytic reason a civic virtue. His essays on Newtonian physics, his histories (Siècle de Louis - XIV) and his relentless fact-checking of rumours (e.g., the Calas case) taught readers to test authority against - evidence. \n • That epistemic stance informed the philosophes’ contribution to the Declaration of the Rights - of Man and ultimately France’s self-image as a “République de la raison.” French educational curricula still - present Voltaire as the archetype of the engaged, critical mind.\n\n4. Human rights and the defense of minorities - \ \n • Voltaire’s campaigns for Jean Calas, Sirven and other judicial victims made “l’affaire” a French civic - genre (later the Dreyfus affair). \n • His texts supplied language—“tolerance,” “the right to doubt,” “cruel - and unusual punishment”—that reappears in modern activist and legal discourse, from Amnesty International (founded - in France) to recent debates on police violence.\n\n5. Literary form and the French sense of wit \n • Candide - and the contes philosophiques created a template for mixing narrative, satire and philosophical provocation that - runs from Flaubert’s Bouvard et Pécuchet to modern writers like Houellebecq. \n • Voltaire’s aphoristic style - (“Le mieux est l’ennemi du bien,” “Il faut cultiver notre jardin”) supplies quotable commonplaces still used in - editorials, classrooms and everyday speech. \n • His terse, elegant prose helped standardise 18th-century - French; the Académie française still cites him as a model.\n\n6. The figure of the “intellectuel engagé” \n - \ • Voltaire turned the writer into a public actor who intervenes in courts, parliaments and the international - press. \n • Zola’s “J’accuse …!”, Sartre’s political essays, and contemporary “rock-star historians” or philosophers - on television follow a path he mapped: the intellectual who litigates ideas in the public square.\n\n7. Memory - culture and national mythology \n • Voltaire’s transfer to the Panthéon in July 1791 inaugurated the building - as a civic temple. His tomb, visited by school groups, helps embed Enlightenment ideals in the Republican liturgy. - \ \n • Town squares, streets, schools and metro stations named after him act as constant mnemonic prompts linking - present-day France to the Enlightenment project.\n\nAmbivalences and limitations \n • Catholic, royalist and - some post-colonial thinkers accuse Voltaire of fostering a reductive view of religion or ignoring non-European - voices. \n • Yet even the counter-currents define themselves against him, confirming his role as a reference - point.\n\nIn sum, Voltaire’s writings did not merely enrich French literature; they helped script a cultural operating - system. Whenever contemporary France prizes lucid argument over piety, lampoons the powerful, invokes laïcité, - or expects writers to weigh in on trials and treaties, it is living in the wake of Voltaire." - reasoning: "**Analyzing Voltaire's Impact**\n\nTo answer the question about Voltaire's influence on modern French - culture, I'll consider his impact on various aspects like society, literature, and attitudes. His writings shaped - Enlightenment values and ideals of secularism, human rights, and political critique.\n\nI should highlight how - he influenced the French Revolution by advocating for reason, civil liberties, and critique of the monarchy and - the Church. Also, his secularism affected the 1905 law and the French approach to religion. Finally, his advocacy - for freedom of speech and tolerance impacted modern human rights movements in France.**Drafting Voltaire's Influence**\n\nI - want to explore Voltaire's significant impact on modern French culture. First, I’ll discuss his literary style, - particularly his satire and philosophical tales, like *Candide*, which are part of school curricula and shape - humor and irony today. \n\nNext, I'll consider his influence on the French language, introducing wit and expressions - that enrich it. His role in defining the tradition of public intellectuals and his impact on legal frameworks, - like the Declaration of Rights of Man and the Napoleonic Code, are also important. Additionally, I need to address - controversies surrounding his views and presence at the Panthéon. I’ll structure the answer as a cohesive essay - or bullet points." - reasoning_details: - - format: openai-responses-v1 - index: 0 - summary: "**Analyzing Voltaire's Impact**\n\nTo answer the question about Voltaire's influence on modern French - culture, I'll consider his impact on various aspects like society, literature, and attitudes. His writings shaped - Enlightenment values and ideals of secularism, human rights, and political critique.\n\nI should highlight how - he influenced the French Revolution by advocating for reason, civil liberties, and critique of the monarchy - and the Church. Also, his secularism affected the 1905 law and the French approach to religion. Finally, his - advocacy for freedom of speech and tolerance impacted modern human rights movements in France.**Drafting Voltaire's - Influence**\n\nI want to explore Voltaire's significant impact on modern French culture. First, I’ll discuss - his literary style, particularly his satire and philosophical tales, like *Candide*, which are part of school - curricula and shape humor and irony today. \n\nNext, I'll consider his influence on the French language, introducing - wit and expressions that enrich it. His role in defining the tradition of public intellectuals and his impact - on legal frameworks, like the Declaration of Rights of Man and the Napoleonic Code, are also important. Additionally, - I need to address controversies surrounding his views and presence at the Panthéon. I’ll structure the answer - as a cohesive essay or bullet points." - type: reasoning.summary - - data: gAAAAABo_5XHkkwCuk-f0waWF42hzBg4R9rD9YUpiXWCgX81P6W2mXf-FLIDTmdvRxm3ctZjMD8Uw4Om_8HIu4TCVHd56avFGbdKVUHf7xNzSBJqYxlGLsp7OB3LKukXWjekw9i9dotrHHZQkXyRRt5esuDGujsquFbI8WYFhMCEhPZaAIJ-IKrnxiS2f2MJxVyGWv9eRWRzZFJmJUr8MHZpIbJS6tLumThbN1fGhn0hes-OWWxfNKfclSoZz86qego4k0Zo8PF2tYqX1uKvLOBr-SSPwplUU798j3DFxMQo6pdAZRT4pJGd-L19nMlrn8DQ5LyIFEV7hMIRD-ieJThuQ3OBei5xJaH1fmZwDFKJHQn_agcZDflY36HlCbIrt1ab-sLgsB4D0TCRq4j42cH0xc3qXC1wrMGuPUOO8CsvbDssJgdXSmTJKhrmsCMH4pPKh0PY983sFlGp_WeRT7RX--NA7JD7sUe7ZlVAWeaQdkXtNmLcvlMl8GdAUrErpUCLvvxYSgD5skISESjgY_gMKCi7NHPaOdgKvRgTc6S5aW2J_xzUyJWDGfPwzIWirVlesEjtUsloeieWRwa-C9YNDi9ZrDhSTqdoAHBW6J6sm1cDGOVN9GqtZ_SmOMGYrYVQZxkvV4nheM6lShDVUHqxh7P5IPazkWGjQBGTccje6RDFDLpBJ2x_gP9qR9aS0VWRieVN6swzpln--lDiKXNvK2RP_5sm0wiCiR14yjxsLibSudCnZWj3f3PWbqcT0xXoqJc0sERzwSrNldt9my7hN8dgWwG2q-atczccNNLSwut7dgiGSazaXHz0SsGvQRi2Gw5bAYfh2mJSyVbA0ZyRYv6nFpilNrQlpeXCoqbvBGbsDDZSfOqnO_OxfNr4yKSeP6JeJnY1-DMZ-zl6eN80ipjlTlJ60opdsJmSa3hGQxsGDVqIL2Ep3sHGT78MZXj2bPEtU5kEhfyD4f4ghfHZeKczJ5TvYKNFEfS9kUnoIbP0uiB1udDOz5mij_GwlvqeqnHSdOaaOoSxxDviPbcbPZgDTVPhADwpAGVkOK3TzysnQZjijAmOzcLp6-LpBG2LBrLatzHH4_wJUEWXFi-ORzvnxTaVGDR8Dn2UuKF4v81blN2-j14xp_2DaojIqIg2XhyDq6Q2a6s6a0rdqtnWC0QhiBC6-TCyDlBHTbENIOsGpmAykGD4_-hnx9Pp69CvfCmwjpgsRF319nNL_2awDWpQyIfxQ-smC-v_ljCF7JDwianEyKsM30tIqNsAcJwtf5_f98TUpbSHzDbw_7tGdzIZKIJQkqteMyKJXOtaZ_XxDcwCs9cswsDwutvTxdqtIXZN526l59zQ9uNYx9roLdN8n6WbhuZUSkQK7xrS-KgqdxZPlMg-ImqlwgWkDM8G8DLLcHuAzrb1r7F2tXjGjUDSDWS1zSCScg79WezPcL4bQ_zTAMDOQ0w4OMCxCJKwRye4KYY7c4QnCVdW3JiFrHByo2oVZOtknTiJOllsY6OkziVeiRrMwiRMhwgGKAisTosFcqzHILptzApWYIb8Jdx3glaJStoWbdgV80Rne_u333z3FfCajpwsfvkGM_yss5jAgcN_eNDXKBTZxx8NUu3d3Kz2u0tr_5MBOILwuItUNqWLhc1oMqkFnHrXwna874t9bgOvxR3ve552BjXL54XU7aGjTPKAGTAcWsxnvS2tx-wpTO8vZ9tsCnbDItpu9JDZnd-JqAfIr37qqeygxt0iIwPhRLz6Jlrjd4aqnpAhsFTb3CPkUX0hOeiCWYRkuTuqLXCUsycCjc_6Y5oznlPd6Pf2_IEpGBn819ob32Z8vbXsO7eTtBz3Yxc0CkuizkQQa_Efgz5kFFJoWwmzlMbl-SlArEgvNaiXv1WFWTR9jrUFZ1GRscczFbTjYWanmLawtR9NFyTj4G7XjB4Ikc4cyZIIsqEtRJH7IMd-3HVSnJX5jICyHagugShAPpwnyA_dn4_kiyXl821_nLCyWWMrQQNxQvoKA9EDKaXRLJ6RpnKWB5vaOm4el2v8rIgpE7OAhNW4wowVTdnn9lk3FcYa2arv_4415X07VY03njlmCV025HRxMNbc9ay4B6nndEBLHbP5TpfJHOzF6o57keP3LauKOzyVLN9YOsuc2Ht9vd4JZiyCuVzAaj4Z_G-ZvcSvRvXkTCS-bjyGH2FPw7MAQWDBw6ArBi-WTSYFwX4_k_bb0QjGmMjrLrbn4Vt_ClGKaapUajENwcnPBIpQ-p1yQBq0lSEQeVPwX76zIiktgiBFOD0MkJiWZSgybnwSdM3sN1kr7mP6Lph9DP7JiTHLakd-htJAyVmJbvRuT2_vpH9ywMddWpeHQiwBGhBjYxVWv0AdYUw7Gs7pVCC9ccPM1A727NGs-ecXFvxutO3lyR16zgw-e2dEt6eITYS4e1IsCe05r01WDUbR8B6IsMFl0sd7qG69X8nVLbK-m8sfURYLSrLsiXvsrmWNBaqraVfj1y6ALTKuJ657heQsF0BuNYVJldK_SgmmefTExc_t6ApkbkokWWMLZxk3J9wtCs4xrUzFkHX3AkqLpiAdi3yUyTjr-vPA5KHXLcBoDuj883w4yfwmKN_hGphWAcjv3N99_ao9UGYfNVIJmxcg7vMGlA1uEyawY3WjA5xlSrM6k--lph3PrT9Ukm8ojiZCMaMiJDVNjKORUJUyiSW8qJTcZEvKmfoju9KDuVfPbf0zT8vmQXWmAzWuH0QMi1KXjQEqtVoqgetV-YwzaZ-i7m8KPWxkRjV4t8aM1P6k71fA7DnOunbySPlEG-jNqxIrY5HNTbinDBDF_zp52JpL0saMKUfnY2EHL8gWXoXG4OguxzNFofp3tPk_uadv8rbmdno3RVPB7KrJqZFizoQ35F7MahgHCunKr9oK4uJ82sWQEa-tXgX8GI7a_rp-O5U6faibRjFSZODU-WXukzoSMhQrcJDpXT_1s5imdkJDV0wM20e-f18fjniMaSaCmgXOA3RdnPlZc26c6giZ7InttDaNRZCr-RCsDjQQVN4AKwnE5XM3yHL2usRx8ILmXZYWfTDNn-UDeueocDcuPhx9aMFf2rRMcw== - format: openai-responses-v1 - id: rs_068633cde4ea68920168ff95bdf3d881969382f905c626cbcd - index: 0 - type: reasoning.encrypted - refusal: null - role: assistant - native_finish_reason: completed - created: 1761580476 - id: gen-1761580476-UuSCBRFtiKPk6k5NiD84 - model: openai/o3 - object: chat.completion - provider: OpenAI + provider: BaseTen + system_fingerprint: null usage: - completion_tokens: 1351 + completion_tokens: 2801 completion_tokens_details: - reasoning_tokens: 320 - prompt_tokens: 25 - total_tokens: 1376 + reasoning_tokens: 0 + prompt_tokens: 24 + prompt_tokens_details: + audio_tokens: 0 + cached_tokens: 0 + total_tokens: 2825 status: code: 200 message: OK diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index b3bae1788f..2a310160e8 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -84,22 +84,46 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ assert thinking_part.content is not None assert thinking_part.signature is None + +async def test_openrouter_preserve_reasoning_block(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('openai/o3', provider=provider) - response = await model_request(model, [request]) - assert len(response.parts) == 3 - - thinking_summary_part = response.parts[0] - thinking_redacted_part = response.parts[1] - assert isinstance(thinking_summary_part, ThinkingPart) - assert isinstance(thinking_redacted_part, ThinkingPart) - assert thinking_summary_part.id == snapshot(None) - assert thinking_summary_part.content is not None - assert thinking_summary_part.signature is None - assert thinking_redacted_part.id == snapshot('rs_068633cde4ea68920168ff95bdf3d881969382f905c626cbcd') - assert thinking_redacted_part.content == '' - assert thinking_redacted_part.signature == snapshot( - 'gAAAAABo_5XHkkwCuk-f0waWF42hzBg4R9rD9YUpiXWCgX81P6W2mXf-FLIDTmdvRxm3ctZjMD8Uw4Om_8HIu4TCVHd56avFGbdKVUHf7xNzSBJqYxlGLsp7OB3LKukXWjekw9i9dotrHHZQkXyRRt5esuDGujsquFbI8WYFhMCEhPZaAIJ-IKrnxiS2f2MJxVyGWv9eRWRzZFJmJUr8MHZpIbJS6tLumThbN1fGhn0hes-OWWxfNKfclSoZz86qego4k0Zo8PF2tYqX1uKvLOBr-SSPwplUU798j3DFxMQo6pdAZRT4pJGd-L19nMlrn8DQ5LyIFEV7hMIRD-ieJThuQ3OBei5xJaH1fmZwDFKJHQn_agcZDflY36HlCbIrt1ab-sLgsB4D0TCRq4j42cH0xc3qXC1wrMGuPUOO8CsvbDssJgdXSmTJKhrmsCMH4pPKh0PY983sFlGp_WeRT7RX--NA7JD7sUe7ZlVAWeaQdkXtNmLcvlMl8GdAUrErpUCLvvxYSgD5skISESjgY_gMKCi7NHPaOdgKvRgTc6S5aW2J_xzUyJWDGfPwzIWirVlesEjtUsloeieWRwa-C9YNDi9ZrDhSTqdoAHBW6J6sm1cDGOVN9GqtZ_SmOMGYrYVQZxkvV4nheM6lShDVUHqxh7P5IPazkWGjQBGTccje6RDFDLpBJ2x_gP9qR9aS0VWRieVN6swzpln--lDiKXNvK2RP_5sm0wiCiR14yjxsLibSudCnZWj3f3PWbqcT0xXoqJc0sERzwSrNldt9my7hN8dgWwG2q-atczccNNLSwut7dgiGSazaXHz0SsGvQRi2Gw5bAYfh2mJSyVbA0ZyRYv6nFpilNrQlpeXCoqbvBGbsDDZSfOqnO_OxfNr4yKSeP6JeJnY1-DMZ-zl6eN80ipjlTlJ60opdsJmSa3hGQxsGDVqIL2Ep3sHGT78MZXj2bPEtU5kEhfyD4f4ghfHZeKczJ5TvYKNFEfS9kUnoIbP0uiB1udDOz5mij_GwlvqeqnHSdOaaOoSxxDviPbcbPZgDTVPhADwpAGVkOK3TzysnQZjijAmOzcLp6-LpBG2LBrLatzHH4_wJUEWXFi-ORzvnxTaVGDR8Dn2UuKF4v81blN2-j14xp_2DaojIqIg2XhyDq6Q2a6s6a0rdqtnWC0QhiBC6-TCyDlBHTbENIOsGpmAykGD4_-hnx9Pp69CvfCmwjpgsRF319nNL_2awDWpQyIfxQ-smC-v_ljCF7JDwianEyKsM30tIqNsAcJwtf5_f98TUpbSHzDbw_7tGdzIZKIJQkqteMyKJXOtaZ_XxDcwCs9cswsDwutvTxdqtIXZN526l59zQ9uNYx9roLdN8n6WbhuZUSkQK7xrS-KgqdxZPlMg-ImqlwgWkDM8G8DLLcHuAzrb1r7F2tXjGjUDSDWS1zSCScg79WezPcL4bQ_zTAMDOQ0w4OMCxCJKwRye4KYY7c4QnCVdW3JiFrHByo2oVZOtknTiJOllsY6OkziVeiRrMwiRMhwgGKAisTosFcqzHILptzApWYIb8Jdx3glaJStoWbdgV80Rne_u333z3FfCajpwsfvkGM_yss5jAgcN_eNDXKBTZxx8NUu3d3Kz2u0tr_5MBOILwuItUNqWLhc1oMqkFnHrXwna874t9bgOvxR3ve552BjXL54XU7aGjTPKAGTAcWsxnvS2tx-wpTO8vZ9tsCnbDItpu9JDZnd-JqAfIr37qqeygxt0iIwPhRLz6Jlrjd4aqnpAhsFTb3CPkUX0hOeiCWYRkuTuqLXCUsycCjc_6Y5oznlPd6Pf2_IEpGBn819ob32Z8vbXsO7eTtBz3Yxc0CkuizkQQa_Efgz5kFFJoWwmzlMbl-SlArEgvNaiXv1WFWTR9jrUFZ1GRscczFbTjYWanmLawtR9NFyTj4G7XjB4Ikc4cyZIIsqEtRJH7IMd-3HVSnJX5jICyHagugShAPpwnyA_dn4_kiyXl821_nLCyWWMrQQNxQvoKA9EDKaXRLJ6RpnKWB5vaOm4el2v8rIgpE7OAhNW4wowVTdnn9lk3FcYa2arv_4415X07VY03njlmCV025HRxMNbc9ay4B6nndEBLHbP5TpfJHOzF6o57keP3LauKOzyVLN9YOsuc2Ht9vd4JZiyCuVzAaj4Z_G-ZvcSvRvXkTCS-bjyGH2FPw7MAQWDBw6ArBi-WTSYFwX4_k_bb0QjGmMjrLrbn4Vt_ClGKaapUajENwcnPBIpQ-p1yQBq0lSEQeVPwX76zIiktgiBFOD0MkJiWZSgybnwSdM3sN1kr7mP6Lph9DP7JiTHLakd-htJAyVmJbvRuT2_vpH9ywMddWpeHQiwBGhBjYxVWv0AdYUw7Gs7pVCC9ccPM1A727NGs-ecXFvxutO3lyR16zgw-e2dEt6eITYS4e1IsCe05r01WDUbR8B6IsMFl0sd7qG69X8nVLbK-m8sfURYLSrLsiXvsrmWNBaqraVfj1y6ALTKuJ657heQsF0BuNYVJldK_SgmmefTExc_t6ApkbkokWWMLZxk3J9wtCs4xrUzFkHX3AkqLpiAdi3yUyTjr-vPA5KHXLcBoDuj883w4yfwmKN_hGphWAcjv3N99_ao9UGYfNVIJmxcg7vMGlA1uEyawY3WjA5xlSrM6k--lph3PrT9Ukm8ojiZCMaMiJDVNjKORUJUyiSW8qJTcZEvKmfoju9KDuVfPbf0zT8vmQXWmAzWuH0QMi1KXjQEqtVoqgetV-YwzaZ-i7m8KPWxkRjV4t8aM1P6k71fA7DnOunbySPlEG-jNqxIrY5HNTbinDBDF_zp52JpL0saMKUfnY2EHL8gWXoXG4OguxzNFofp3tPk_uadv8rbmdno3RVPB7KrJqZFizoQ35F7MahgHCunKr9oK4uJ82sWQEa-tXgX8GI7a_rp-O5U6faibRjFSZODU-WXukzoSMhQrcJDpXT_1s5imdkJDV0wM20e-f18fjniMaSaCmgXOA3RdnPlZc26c6giZ7InttDaNRZCr-RCsDjQQVN4AKwnE5XM3yHL2usRx8ILmXZYWfTDNn-UDeueocDcuPhx9aMFf2rRMcw==' + messages = [ + ModelRequest.user_text_prompt( + "What was the impact of Voltaire's writings on modern french culture? Think about your answer." + ) + ] + response = await model_request(model, messages) + messages.append(response) + + openai_messages = await model._map_messages(messages) + + assistant_message = openai_messages[1] + assert 'reasoning_details' in assistant_message + assert assistant_message['reasoning_details'] == snapshot( + [ + { + 'id': None, + 'format': 'openai-responses-v1', + 'index': 0, + 'type': 'reasoning.summary', + 'summary': """\ +**Exploring Voltaire's impact** + +The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + +I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects.\ +""", + }, + { + 'id': 'rs_06569cab051e4c78016900b1eb409c81909668d636e3a424f9', + 'format': 'openai-responses-v1', + 'index': 0, + 'type': 'reasoning.encrypted', + 'data': 'gAAAAABpALH0SrlqG_JSYqQT07H5yKQO7GcmENK5_dmCbkx_o6J5Qg7kZHHNvKynIDieAzknNQwmLf4SB96VU59uasmIK5oOyInBjdpczoYyFEoN8zKWVOolEaCLXSMJfybQb6U_CKEYmPrh_B4CsGDuLzKq7ak6ERC0qFb0vh6ctchIyzWN7MztgnrNt85TReEN3yPmox0suv_kjEc4K5nB6L8C5NOK8ZG4Y3X88feoIvteZq0u2AapGPAYJ-tqWqbwYBBBocX7nYfOw3mVGgHb1Ku7pqf13IoWtgR-hz0lmgsLuzGucmjaBKE881oodwUGKUkDWuUbIiFIxGLH5V6cR53XttM91wAKoUgizg0HuFHS_TEYeP2rJhVBv-8OpUmdKFs-lIrCqVBlJDeIwQS_jGSaY4_z-6Prjh797X3_mSDtXBNqaiAgRQkMHHytW6mrVfZVA-cXikTLX5CRc266KNY6MkaJRAS7-tOKxjMwE-IyvmrIIMW4YTdnoaTfVcZhE5YpbrqilZllTW2RtU4lLFh4DFmBRplJsh2F4the_VXm1LITRYrZlrkLB3qTkA_oPslxFxGk_BApWRmbpCxs9mNgwzqqDCsYyvkGqUNAqCTdgPZMApWwJyRNURu_s8yHo-wcLS42zgPvC64E2GvNaO5G5xPFApbHyN950seaSiivqLc-TysXpk6RxNwKm2l1EJDPvMk0G6sZnLlQVPSXQQsCcSfZmJFSHUNSk7u99o5JsuHWsW5oco2cD111Ghd2EAujdimTRGbhjhTTt1SOGl0DL7EgYVWFiYXxgB7XsXy6PgzuIXBuJkJRn4qpk6VeRHpHujbntbVlxlt5ah-lcvRqka8JEew5NXv4qL5zuMQiSIhmHdw_zVucZv7TqknUPJSovsFte40pYwVIeQP23HMekTqsAwEjc4S28Kg300IchGuEi9ihEL9-5_kgrFTgGOOQhNYo28ftnTD7LtoS5m3Av9CraHdbK9Y4bs1u7-qFfCopcamLXJPQe1ZQ0xqR3_zGQJtK24N_oi2Et5g4o_flqzyVwrd83B5nrcbUuayJL3C9SQg4NR2VD8eS96c3qIl_FxCsD6SoTQu22VbrRngvkM_WP1EtvBSKwMtYHnHlQSufV1bkv4E3JXfHg2UJZdvJ0MtfNMTY9qx39YlI1A1Ds4ctMjCF4qAS2XPkUvvgIpwFq4JzH3v2d-f57itMmqamINLmxP2Pv1J69kj7M_shl_FWTJrWn_MtKLsS77Awxc3NdhXhvA2ketiLp_wOE8CED-o4j_Yh0NKy2AVNqeQcmZvJ3FK2vysB2oAjRqTemcad_B2fHkdceoMvSqAYk26gGm8Nvu8GK_atpKOfi1akGKQBRoERZmPT2wyDpXXS4GdVMyC8m5MUa7xJHwUsRDn4ucW792Pt_5skKrBK_So2pGhmoZa8nJYZ8x7O9ZNEXF6a4OIRgbGKnkVpP95YzlQAsVxR31YXkE1pcdM4nRqCpPjdoQjZ0Twr0ute5v4J6Lhb1F3FsNrg3Sm9YRkJ9h-yfUfvyt1bK1V4nFMtRFt120WjfIvlZZ-1qyenToySK8doSSUZ6VOQWG_ieBkf-IRAN3eONC0n6BfGogsVlPXhXHLznouLnzapC4pGWWBIDsGlTvZj_o7UpHMPr_20PDC5d2jSGGtXf6kJvtfsAnJjtQPHs41VfDLyT-yQIlnUd8QvdwUlQ22A79I-rg38C8BWJNqg3sbOtzMMpt6R8Cvyp4dmB1ksS24tpiEZZ42aH8JIgoqs1sRbFPsC1v3kDPd3XRbbKpliQxseR_xWMNZkGj2F8q9HH1lgLkkCod_97OYrYBROxn9K79wlkZBUFjrNXA3EuiBf-IDOvQeKtDRypAaTKnHybIEOIypTNOWjhGT6oQutKSFswfvSeJGA0fF26FAgxnVmzFS7eAyzSHDqygQfhB7Yp7N2yEbD0eFLUs8qgete-eDIn6eM5E5eMnT1JeP6LD8ku5iR30sDdU8O6BrsGvUypMSID-hoBDytF1_GS6yOhMsU4pXZHTJ4yYNUOFyMH3ReE3SeAuFFohR9aXTpUA5YeLy6-Xo0_ZA9FuFMDVK4Bp1F5f-2BJ3FXRc1aqtyROpMdBtY4ehEqm-FKqbYd4VlaIMb9adG1LnOgWpnWCr9ciOP-c75rxX885yZLXO8rHJ_wbg04JzobGFnKdZHPtCYiTgkpnFavesiy9iI_bRO0Mu7SaDwXne6u4NY4YIHGRRCKR7o98lvSCOw7PT3SgWPoHEML6Na0QnycJeiPayB7megnFGfQZn_lSzDDeAiKgOBJ42LJZf3ysH1Dueqb7icX6xn4HlrMJdDLhMCgvwry4QQkrgrsIhyrTFNt2j--0IO7hX4RbwU5v5yudb0QRQovbmjoPRk4qeZKOyH-YSW-J1lu2MJcrk9Z-Sc4d875cZ6B3HxUuJFYQWqMoJ6EkZjNRLpp3XBkFEu5ip4md7yu_FYK7SInsuVI0igMVRx5i9vURIiGnVf60yBfWpqJac0Jp_7V3ftXsdXk3pSE4_GF9QgKM4l9chXH-frUEExxXS13BRQJP1b29-0B7ciG-48c18uSktRItBmXv2_baSiyo7_nvnPWVUgpig9qOuiFFmVPPvFRGTQS6jh8dPZR8PCFnpxuMhVrrJDJUNu8wmVGdVDMZP6kO2PYhNpz35RrX25SSgzjbl6V4uFDb7KQdWQAv-78QkLibUeB7w47I_2G47TGxbUsmnTt_sss1LW0peGrmCMKncgSQKro8rSBQ==', + }, + ] ) From 1d7a8a41bf6008d095320905d01bbef82b6a775d Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Tue, 28 Oct 2025 11:19:40 -0600 Subject: [PATCH 17/42] fix typing --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 15 +++++++++------ tests/models/test_openrouter.py | 9 ++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 5c70287e2c..689d48bac7 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,5 +1,5 @@ from dataclasses import asdict, dataclass -from typing import Literal, cast +from typing import Any, Literal, cast from openai import AsyncOpenAI from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam @@ -272,6 +272,7 @@ class ReasoningText(BaseReasoningDetail): OpenRouterReasoningDetail = ReasoningSummary | ReasoningEncrypted | ReasoningText +_reasoning_detail_adapter: TypeAdapter[OpenRouterReasoningDetail] = TypeAdapter(OpenRouterReasoningDetail) @dataclass(repr=False) @@ -279,7 +280,7 @@ class OpenRouterThinkingPart(ThinkingPart): """A special ThinkingPart that includes reasoning attributes specific to OpenRouter.""" type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text'] - index: int + index: int | None format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] __repr__ = _utils.dataclasses_no_defaults_repr @@ -317,7 +318,7 @@ def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail, provider_na ) def into_reasoning_detail(self): - return TypeAdapter(OpenRouterReasoningDetail).validate_python(asdict(self)).model_dump() + return _reasoning_detail_adapter.validate_python(asdict(self)).model_dump() class OpenRouterCompletionMessage(ChatCompletionMessage): @@ -368,7 +369,7 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti Returns: An 'OpenAIChatModelSettings' object with equivalent settings. """ - extra_body = model_settings.get('extra_body', {}) + extra_body = cast(dict[str, Any], model_settings.get('extra_body', {})) if models := model_settings.pop('openrouter_models', None): extra_body['models'] = models @@ -379,7 +380,9 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti if transforms := model_settings.pop('openrouter_transforms', None): extra_body['transforms'] = transforms - return OpenAIChatModelSettings(**model_settings, extra_body=extra_body) + model_settings['extra_body'] = extra_body + + return OpenAIChatModelSettings(**model_settings) # type: ignore[reportCallIssue] class OpenRouterModel(OpenAIChatModel): @@ -463,6 +466,6 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti if reasoning_details := [ part.into_reasoning_detail() for part in message.parts if isinstance(part, OpenRouterThinkingPart) ]: - openai_message['reasoning_details'] = reasoning_details + openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] return openai_messages diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 2a310160e8..c3c2229425 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import cast import pytest @@ -6,6 +7,7 @@ from pydantic_ai import ( Agent, ModelHTTPError, + ModelMessage, ModelRequest, TextPart, ThinkingPart, @@ -89,15 +91,16 @@ async def test_openrouter_preserve_reasoning_block(allow_model_requests: None, o provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('openai/o3', provider=provider) - messages = [ + messages: Sequence[ModelMessage] = [] + messages.append( ModelRequest.user_text_prompt( "What was the impact of Voltaire's writings on modern french culture? Think about your answer." ) - ] + ) response = await model_request(model, messages) messages.append(response) - openai_messages = await model._map_messages(messages) + openai_messages = await model._map_messages(messages) # type: ignore[reportPrivateUsage] assistant_message = openai_messages[1] assert 'reasoning_details' in assistant_message From 516e8230d2196ba39aa4d652443c48825629d1cc Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 29 Oct 2025 10:17:11 -0600 Subject: [PATCH 18/42] remove tags from content --- .../pydantic_ai/models/openrouter.py | 16 +- ...t_openrouter_preserve_reasoning_block.yaml | 186 +++++++++++------- tests/models/test_openrouter.py | 51 +++-- 3 files changed, 155 insertions(+), 98 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 689d48bac7..0852ed9c0a 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,3 +1,4 @@ +import re from dataclasses import asdict, dataclass from typing import Any, Literal, cast @@ -286,7 +287,8 @@ class OpenRouterThinkingPart(ThinkingPart): __repr__ = _utils.dataclasses_no_defaults_repr @classmethod - def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail, provider_name: str): + def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail): + provider_name = 'openrouter' if isinstance(reasoning, ReasoningText): return cls( id=reasoning.id, @@ -447,8 +449,7 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: if reasoning_details := choice.message.reasoning_details: new_parts: list[ThinkingPart] = [ - OpenRouterThinkingPart.from_reasoning_detail(reasoning, native_response.provider) - for reasoning in reasoning_details + OpenRouterThinkingPart.from_reasoning_detail(reasoning) for reasoning in reasoning_details ] model_response.parts = [*new_parts, *model_response.parts] @@ -464,8 +465,15 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti for message, openai_message in zip(messages, openai_messages): if isinstance(message, ModelResponse): if reasoning_details := [ - part.into_reasoning_detail() for part in message.parts if isinstance(part, OpenRouterThinkingPart) + part.into_reasoning_detail() + for part in message.parts + if isinstance(part, OpenRouterThinkingPart) and part.provider_name == self.system ]: openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] + if openai_message['role'] == 'assistant' and isinstance( + contents := openai_message['content'], str + ): # pragma: lax no cover + openai_message['content'] = re.sub(r'.*?\s*', '', contents, flags=re.DOTALL).strip() + return openai_messages diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml index 74a253a510..a99de7aa6d 100644 --- a/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml +++ b/tests/models/cassettes/test_openrouter/test_openrouter_preserve_reasoning_block.yaml @@ -8,7 +8,7 @@ interactions: connection: - keep-alive content-length: - - '171' + - '92' content-type: - application/json host: @@ -16,9 +16,9 @@ interactions: method: POST parsed_body: messages: - - content: What was the impact of Voltaire's writings on modern french culture? Think about your answer. + - content: Hello! role: user - model: openai/o3 + model: openai/gpt-5-mini stream: false uri: https://openrouter.ai/api/v1/chat/completions response: @@ -28,7 +28,7 @@ interactions: connection: - keep-alive content-length: - - '11648' + - '2824' content-type: - application/json permissions-policy: @@ -46,86 +46,140 @@ interactions: index: 0 logprobs: null message: - content: "Impact of Voltaire’s Writings on Modern French Culture\n\n1. A civic vocabulary of liberty and tolerance\n• - “Écrasez l’infâme” (Crush the infamous thing) and “Il faut cultiver notre jardin” (We must cultivate our garden) - are still quoted by politicians, journalists and schoolchildren. \n• His defense of minor-religion victims (Calas, - Sirven, La Barre) supplied iconic cases that taught the French the meaning of liberté de conscience. \n• Concepts - central to the Revolution (droits de l’homme, liberté d’expression, égalité civile) were first popularized not - by legal texts but by Voltaire’s pamphlets, letters and contes philosophiques. When the Déclaration des droits - de l’homme et du citoyen (1789) was drafted, deputies openly cited him.\n\n2. Laïcité as a cultural reflex\n• - Voltaire’s relentless criticism of clerical power helped dissociate “being French” from “being Catholic.” \n• - The 1905 law separating Church and State and the contemporary consensus that religion is a private matter (laïcité) - both rest on an attitude—skepticism toward organized religion—that Voltaire normalized. \n• His nickname l’Athée - de la Sorbonne is still invoked in current debates about headscarves, bio-ethics or blasphemy; op-ed writers speak - of a “Voltaire moment” whenever satire confronts religion (Charlie Hebdo, exhibitions, plays, etc.).\n\n3. Freedom - of speech as a near-sacred principle\n• Voltaire’s legendary—if apocryphal—phrase “I disapprove of what you say, - but I will defend to the death your right to say it,” regularly appears in parliamentary debates, media codes - of ethics and lycée textbooks. \n• Modern defamation and press-liberty laws (1881 and after) were drafted in - a climate steeped in Voltairian skepticism toward censorship.\n\n4. The French taste for “esprit” and satire\n• - Candide, Lettres philosophiques, and Dictionnaire philosophique established the short, witty, corrosive form as - a French ideal of prose. \n• Newspapers like Le Canard enchaîné, TV programs such as “Les Guignols,” and graphic - novels by Luz or Jul draw directly on the Voltairian strategy: humor plus moral indignation. \n• Even serious - political commentary in France prizes the mot d’esprit and the reductive punch line—an unwritten stylistic legacy - of Voltaire.\n\n5. Educational canon and cultural literacy\n• Voltaire is compulsory reading in collège and lycée; - exam questions on Candide are perennial. \n• His letters model the “dissertation française” structure (thèse, - antithèse, synthèse) taught nationwide. \n• The annual “Prix Voltaire” (CLEMI) rewards high-school press clubs - that fight censorship, rooting his ideals in adolescent civic training.\n\n6. Influence on French legal and political - institutions\n• The Council of State and the Constitutional Council frequently cite “liberté de penser” (Voltaire, - Traité sur la tolérance) when striking down laws that restrict expression. \n• The secular “Journée de la laïcité,” - celebrated each 9 December, uses excerpts from Traité sur la tolérance in official posters distributed to town - halls.\n\n7. Literary forms and genres\n• The conte philosophique (Candide, Zadig, Micromégas) paved the way for - the modern nouvelle, the hybrid “essay-novel” of Sartre, Camus, Yourcenar, and for the philosophical BD (Sfar’s - Le chat du rabbin). \n• Voltaire’s mixing of reportage, satire, philosophy and fiction prefigured the essayistic - style of today’s “livres de société” by writers such as Houellebecq or Mona Chollet.\n\n8. Language: a living - imprint\n• “Voltairien/Voltairienne” denotes caustic wit; “voltairisme” means sharp, secular critique. \n• His - aphorisms—“Le mieux est l’ennemi du bien,” “Les hommes naissent égaux” —crop up in talk-shows and business seminars - alike.\n\n9. National memory\n• Burial in the Panthéon (1791) created the template for the République’s secular - sanctuaries. \n• Libraries, streets, Lycée Voltaire (Paris, Orléans, Wingles) and the high-speed train “TGV 220 - Voltaire” embed him in daily geography. \n• Bicentenary celebrations in 1978 and the 2014 republication of Traité - sur la tolérance (after the Charlie Hebdo attacks) both caused nationwide spikes in sales, proving enduring resonance.\n\n10. - A benchmark for intellectual engagement\n• When French public intellectuals sign manifestos (from Zola’s “J’accuse” - to the recent petitions on climate or pensions), the very act echoes Voltaire’s pamphlet warfare: use the pen - to influence power. \n• The Académie française and PEN International invoke him as the patron saint of the “écrivain - engagé,” a figure central to modern French self-understanding.\n\nIn short, Voltaire’s writings did more than - enrich French literature; they installed reflexes—skepticism, satire, secularism, and the primacy of individual - rights—that continue to structure French laws, education, media tone, and collective identity. Modern France’s - attachment to laïcité, its bawdy political humor, its fierce defense of free expression, and even the way essays - are written in lycée classrooms all carry a Voltairian fingerprint." - reasoning: |- - **Exploring Voltaire's impact** + content: Hello! How can I help you today? + refusal: null + role: assistant + native_finish_reason: completed + created: 1761751488 + id: gen-1761751488-sw4FP5A0ecwISVPjA4ec + model: openai/gpt-5-mini + object: chat.completion + provider: OpenAI + usage: + completion_tokens: 15 + completion_tokens_details: + reasoning_tokens: 0 + prompt_tokens: 8 + total_tokens: 23 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '2107' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Hello! + role: user + - content: Hello! How can I help you today? + role: assistant + - content: What was the impact of Voltaire's writings on modern french culture? + role: user + model: openai/gpt-5-mini + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '11808' + 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: stop + index: 0 + logprobs: null + message: + content: |- + Short answer: Very large. Voltaire’s writings helped shape the core values, public culture, and intellectual practices of modern France — especially skepticism of religious authority, commitment to freedom of expression, secular public institutions, and the model of the engaged public intellectual — while also leaving a lasting mark on French literature and satire. + + Key impacts + + - Secularism and anti-clericalism: Voltaire’s relentless attacks on superstition, clerical power and intolerance (e.g., Traité sur la tolérance, his campaign in the Calas affair, his slogan “écrasez l’infâme”) fed the anti‑clerical currents that culminated in revolutionary and 19th‑century reforms and, ultimately, the laïcité regime formalized in 1905. He helped normalize the idea that religious authority must be questioned in public life. + + - Freedom of thought and speech: Through polemics, pamphlets and the Dictionnaire philosophique, Voltaire popularized the ideal of free enquiry and the right to criticize rulers and institutions. That ethos is central to modern French republican self‑understanding and the role of public debate. + + - Humanitarian and legal reform impulses: Voltaire’s advocacy in specific cases (notably the campaign to clear Jean Calas) brought public attention to miscarriages of justice and influenced later penal and judicial reforms. His writings helped make legal equality and protections against religiously motivated persecution part of the public agenda. + + - Political culture and republican values: While Voltaire was not a democrat in the modern sense (he often favored enlightened monarchy), his critique of arbitrary power, privilege and superstition contributed to the intellectual background of the Revolution and to contemporary French commitments to liberté, égalité and civic criticism. + + - Literary influence and the public sphere: Voltaire’s style (wit, irony, concise prose), his use of satire (most famously Candide), and his role in the book trade and salon culture helped create a lively literate public sphere. The model of the “littérateur engagé” — an intellectual who intervenes in public affairs — is a direct cultural inheritance. - The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + - Influence on education and culture: Voltaire championed science, reason and history; these priorities influenced modern curricula and cultural institutions that prize critical thinking, secular instruction and the canon of Enlightenment literature in France. - I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects. + Nuance and limits + + - Not an ideological ancestor of all modern French values: Voltaire advocated religious tolerance but was sometimes elitist, anti-democratic, and even expressed prejudiced opinions by modern standards. His support for enlightened despotism (at times) and skepticism about popular rule complicate simplistic portrayals. + + - Part of a broader movement: Voltaire was one of several Enlightenment figures (Rousseau, Montesquieu, Diderot, etc.) whose ideas intersected and sometimes conflicted. Modern French culture is the product of that whole discourse and subsequent political struggles. + + Concrete legacy today + + - Laïcité and a strong public secular culture that tolerates religious practice but restricts religious influence on the state. + - A national reverence for free debate and the intellectual’s role in society — newspapers, op‑eds and public intellectuals remain central to French politics. + - Candide and other works are staples of school curricula; Voltaire’s phrases and arguments appear in political rhetoric and public memory. + - Legal and human‑rights traditions that trace part of their genealogy to Enlightenment critiques of theology and absolutism. + + If you’d like, I can: + - Give short passages from Voltaire that illustrate these themes, + - Trace his influence more precisely on a particular French institution (e.g., laïcité or the legal code), + - Or recommend readable books or articles for deeper study. + reasoning: |- + **Structuring Voltaire’s Impact** + + I’m thinking about creating a structured answer that includes an overview introduction and then dives into main impacts with explanations. I’ll mention his notable works like "Candide", "Lettres philosophiques", and "Traité sur la tolérance," plus his criticism of religious fanaticism in "Mahomet." I'll also highlight his phrase "Écrasez l'infâme" and his influence on laïcité, shaping the French tradition of public intellectuals. To wrap up, I’ll suggest further reading, including biographies about him for a well-rounded exploration. reasoning_details: - format: openai-responses-v1 index: 0 summary: |- - **Exploring Voltaire's impact** - - The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + **Structuring Voltaire’s Impact** - I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects. + I’m thinking about creating a structured answer that includes an overview introduction and then dives into main impacts with explanations. I’ll mention his notable works like "Candide", "Lettres philosophiques", and "Traité sur la tolérance," plus his criticism of religious fanaticism in "Mahomet." I'll also highlight his phrase "Écrasez l'infâme" and his influence on laïcité, shaping the French tradition of public intellectuals. To wrap up, I’ll suggest further reading, including biographies about him for a well-rounded exploration. type: reasoning.summary - - data: gAAAAABpALH0SrlqG_JSYqQT07H5yKQO7GcmENK5_dmCbkx_o6J5Qg7kZHHNvKynIDieAzknNQwmLf4SB96VU59uasmIK5oOyInBjdpczoYyFEoN8zKWVOolEaCLXSMJfybQb6U_CKEYmPrh_B4CsGDuLzKq7ak6ERC0qFb0vh6ctchIyzWN7MztgnrNt85TReEN3yPmox0suv_kjEc4K5nB6L8C5NOK8ZG4Y3X88feoIvteZq0u2AapGPAYJ-tqWqbwYBBBocX7nYfOw3mVGgHb1Ku7pqf13IoWtgR-hz0lmgsLuzGucmjaBKE881oodwUGKUkDWuUbIiFIxGLH5V6cR53XttM91wAKoUgizg0HuFHS_TEYeP2rJhVBv-8OpUmdKFs-lIrCqVBlJDeIwQS_jGSaY4_z-6Prjh797X3_mSDtXBNqaiAgRQkMHHytW6mrVfZVA-cXikTLX5CRc266KNY6MkaJRAS7-tOKxjMwE-IyvmrIIMW4YTdnoaTfVcZhE5YpbrqilZllTW2RtU4lLFh4DFmBRplJsh2F4the_VXm1LITRYrZlrkLB3qTkA_oPslxFxGk_BApWRmbpCxs9mNgwzqqDCsYyvkGqUNAqCTdgPZMApWwJyRNURu_s8yHo-wcLS42zgPvC64E2GvNaO5G5xPFApbHyN950seaSiivqLc-TysXpk6RxNwKm2l1EJDPvMk0G6sZnLlQVPSXQQsCcSfZmJFSHUNSk7u99o5JsuHWsW5oco2cD111Ghd2EAujdimTRGbhjhTTt1SOGl0DL7EgYVWFiYXxgB7XsXy6PgzuIXBuJkJRn4qpk6VeRHpHujbntbVlxlt5ah-lcvRqka8JEew5NXv4qL5zuMQiSIhmHdw_zVucZv7TqknUPJSovsFte40pYwVIeQP23HMekTqsAwEjc4S28Kg300IchGuEi9ihEL9-5_kgrFTgGOOQhNYo28ftnTD7LtoS5m3Av9CraHdbK9Y4bs1u7-qFfCopcamLXJPQe1ZQ0xqR3_zGQJtK24N_oi2Et5g4o_flqzyVwrd83B5nrcbUuayJL3C9SQg4NR2VD8eS96c3qIl_FxCsD6SoTQu22VbrRngvkM_WP1EtvBSKwMtYHnHlQSufV1bkv4E3JXfHg2UJZdvJ0MtfNMTY9qx39YlI1A1Ds4ctMjCF4qAS2XPkUvvgIpwFq4JzH3v2d-f57itMmqamINLmxP2Pv1J69kj7M_shl_FWTJrWn_MtKLsS77Awxc3NdhXhvA2ketiLp_wOE8CED-o4j_Yh0NKy2AVNqeQcmZvJ3FK2vysB2oAjRqTemcad_B2fHkdceoMvSqAYk26gGm8Nvu8GK_atpKOfi1akGKQBRoERZmPT2wyDpXXS4GdVMyC8m5MUa7xJHwUsRDn4ucW792Pt_5skKrBK_So2pGhmoZa8nJYZ8x7O9ZNEXF6a4OIRgbGKnkVpP95YzlQAsVxR31YXkE1pcdM4nRqCpPjdoQjZ0Twr0ute5v4J6Lhb1F3FsNrg3Sm9YRkJ9h-yfUfvyt1bK1V4nFMtRFt120WjfIvlZZ-1qyenToySK8doSSUZ6VOQWG_ieBkf-IRAN3eONC0n6BfGogsVlPXhXHLznouLnzapC4pGWWBIDsGlTvZj_o7UpHMPr_20PDC5d2jSGGtXf6kJvtfsAnJjtQPHs41VfDLyT-yQIlnUd8QvdwUlQ22A79I-rg38C8BWJNqg3sbOtzMMpt6R8Cvyp4dmB1ksS24tpiEZZ42aH8JIgoqs1sRbFPsC1v3kDPd3XRbbKpliQxseR_xWMNZkGj2F8q9HH1lgLkkCod_97OYrYBROxn9K79wlkZBUFjrNXA3EuiBf-IDOvQeKtDRypAaTKnHybIEOIypTNOWjhGT6oQutKSFswfvSeJGA0fF26FAgxnVmzFS7eAyzSHDqygQfhB7Yp7N2yEbD0eFLUs8qgete-eDIn6eM5E5eMnT1JeP6LD8ku5iR30sDdU8O6BrsGvUypMSID-hoBDytF1_GS6yOhMsU4pXZHTJ4yYNUOFyMH3ReE3SeAuFFohR9aXTpUA5YeLy6-Xo0_ZA9FuFMDVK4Bp1F5f-2BJ3FXRc1aqtyROpMdBtY4ehEqm-FKqbYd4VlaIMb9adG1LnOgWpnWCr9ciOP-c75rxX885yZLXO8rHJ_wbg04JzobGFnKdZHPtCYiTgkpnFavesiy9iI_bRO0Mu7SaDwXne6u4NY4YIHGRRCKR7o98lvSCOw7PT3SgWPoHEML6Na0QnycJeiPayB7megnFGfQZn_lSzDDeAiKgOBJ42LJZf3ysH1Dueqb7icX6xn4HlrMJdDLhMCgvwry4QQkrgrsIhyrTFNt2j--0IO7hX4RbwU5v5yudb0QRQovbmjoPRk4qeZKOyH-YSW-J1lu2MJcrk9Z-Sc4d875cZ6B3HxUuJFYQWqMoJ6EkZjNRLpp3XBkFEu5ip4md7yu_FYK7SInsuVI0igMVRx5i9vURIiGnVf60yBfWpqJac0Jp_7V3ftXsdXk3pSE4_GF9QgKM4l9chXH-frUEExxXS13BRQJP1b29-0B7ciG-48c18uSktRItBmXv2_baSiyo7_nvnPWVUgpig9qOuiFFmVPPvFRGTQS6jh8dPZR8PCFnpxuMhVrrJDJUNu8wmVGdVDMZP6kO2PYhNpz35RrX25SSgzjbl6V4uFDb7KQdWQAv-78QkLibUeB7w47I_2G47TGxbUsmnTt_sss1LW0peGrmCMKncgSQKro8rSBQ== + - data: gAAAAABpAjHR-iS0FxMStS_v-8ZA-bqfQViKFq_Z7ZRd0AvUM9A63Luvm1kIAusn73GmlLEvagOa9Ckvab0NseO538_gxhT7jdQAqvTHgHfVLcwr0p-BRJxILXeGeX2gZgJ2l6QLFXai6I9UBTTMD6T3nVOVGP1rhjO6fVaHO13B4P_z717CRiEULGDgLPeHsVs2VzXbO_TupyfATqy7HVcLNm7SsTbT0O7zNCBYXIOFq1SrFQzvgkVCnH2Q5qmc29Ha7hyAH2WBN8yRwS9bx7fMEqs-NTH3zCMdl_OU3OhkxFpYUnW0V5HgF041SZkLId3DnK1vcjtLeUd3jdtc9cOl4GxBzbSXPo7hNmsk62V_ryyHcxpEPHm-sqARRX94C8Z61Hj2OlFFRGdFRlyO8ddVXUtxT6LPawFPOuJ9fRixoAYb1704_NriJtArPGJfEAhMIMzMNExCzzbf1SA9TxXHc1_RMgSeBHPe6wJibAQ2Cw1rRXJPUppSO-5izQCN3mGRMmsXk5-FOCRfiFLpBqQg0fSTmXWD3jkh_0JeWDY2_YELKOLcTr88PVN39pZKF14GcoplOsCi6ry6_iojV4lpPxXDno6m0nMu2jzTehv1v1reQioUtY9my4UyGUCaCJer1MtKYgyCbQIXSkKRAQmbXi7mE32dzDif8gwXy1X8GK_ViRng_ep3uKjIiBSiAE2uGuKW5sHyQ80h-C_wvEn8Hn3-B7Czntqeh3rHm5gJnZl1YMAhS5PpVWY9JfmVdxlJOtKl_T_z5CsL-Dcg4K9OFXNl5SHVFZ3fw-M0b7ftk0dHskE7GF0pxWVbhSOemQl0HDygYCp6r3QUl4YKNX5XNnovJfM5EhLMXdos6ePWvGOZF-GvHAd4QrgvluawTXdlUti0dpfOTk9Bb5_UL_j9kAVAvCqcXxAX_GQBpp-YWfNZS-3NDHJPIWro1wWZsBJR8ZC07uPiq5HDnWiaunBZgGoncpLUQSvAa9LFRaLULxcA-vCJOzgyD8x5gJxVKM-wxKHf5KhAXTIJQb_qimoZTwGRicJxUiyiyCZTBDN5LLt-UumGWa0UTjQeg8lb5ZQwYD7I8Md9enO84chdyDXpDIHIaCarbe6EIJqc4Itv-aG7sH5XLNxJRQEdzxZc9mt5-GYUNIk3iBJ8g0WBOIyS608UYWPTqJa4M4xofLgBH0ZX4b7TX5MIA50EKuPPzjKgCedIbtarLoRWd9iNPU-xG8VaxTjF1NFk6bk0dOVnnB8G1WDXz6cBDpJHUngcu9aKsPKSl9mOJZOV5pDFBBJzLcXuduzQ43A5yhuAbnPpJMXSpHE917e_iA_lpewmCQjE67VSIo9FdPWELJFvTgCrCYYeY3imAdWqVXjIeKxIV4yblaqugrMwEx86-nL3RxoFP3oT78FTRA7x9vy8LhBSRqHQyFQuA9hUj8QClRDuEHYHPKhCZgNdiIRG72iYnERC7_mKawc4ZG92qYgCu1qm19QWCWt6defgfCbIziaynRe--DoJBlrdRypwFJG8mojmwCcGF6x4N_YadGoMQIbsNARv65YPGX-5yU5CTMfgtD1vPAeIg4gfFyb6FrlFoIYfe3537wZBF6zXTMwmxwcx-Z6GRv-m1Va79dJtq67WegpOaK3J5oTfYNgZ_hRQ6b5VGxEfdgLBk7k6iZ8j_a8Yk8DT-HB0hdRyv-DRX_JnBVpmHPR8-OJjamfTyvg16supZLjZYfrzpU8QaPFQNuZ5fiuNchdYbBkmGDswHjr_H-2uNjS-JGNkJoBL1Z24x72Lwu06W1-m79pN8ZJUvcmNTi4yXIUHYWxAfnAmZBNRSeBlj0wxkJ0z6KNqS23h8XjC8B0FcpmFU-QzGJZUZHQ6YHIoHtIkdfVnImVMh_DYVyBF9Xt-XEP651_SGu9vBeK7VAiaD8owzfKyMJ0Gw6FBVfmRZIKN54uNfvBdDZOnbyhfoXfXrjEKjkME9WMRhikdrJfRlyH-50skdJjLRnBr5TZhUBHb5J-e1Ta3JwJ2u_3t3Il_ycLL8WXKTs9e9DUGg8w-qNO781AlzjPc9W3RftmI5QbN3Ozdxf5HD1yBxmqMX70manOzkzKJUIG5yjgPVR3t2E4FB7pJ-cyPuaJJAdSUVlXQ33E3LLrQJHClbdv8KDaR5ECubNU1l3Uwl4yJYuJKeE348tiedH7tBuPR0FIrhuqxyuVkVj8O2TP73GZCm5iscMaMmHIDlFfLP0dSE9NoXTZpdYGMbpX9MAPztgnLR_cxAAAaKWdHV6XKyuK6T3WFnCop0xmpJZu5naKHFlQvTWryQLoI-PFuJYwe-fE9B3TfPloDheGBsKWPU29zk89SX5t0aHJSd0z45NkPIfSXCT_HK_W10k86KduSc3LVMfxUdRQOdtBt58l9Ct4nAbsK3cIK-inNpu7D6hPcLcIttDWsAIH030max9HnnYe258K3cIAthTo6zEiT87ftB6S7tFZ8xRMJHmBcZWE1PZZ0j6Ubnx1xRPJ5ZaQps5hsrxkkfiok0kHAcwAtLwaBN8QjV7vmcH-S9Ysybu7aykR2Fpn3y2YImEi96lw1d3r8RteUhIRHGYJLtStp5EnGifxnFdO28W7DUlAdIWFSFWxzK_iQDp-ldY2g2dlvteV-w_NzMl-VhZr-uijQQYkLYuFIGkqkMF9tJpsr2ojMSplIhi00bnaF8FW_W_GN_3lkCmxsAjNBXypFzEWHeDlvysn7NVP8riGRC5sVMBNxi4r7CPUizSiuoGs5C1fTy_dsNwFiSjvAMLKKxSj8Op8_WCgPZb73vun4vCtRbyjL420WY8psVMy-LXFPejVqdZsmpT7s699CVFhEwwAZsOoz8i0Y7SSsH2h5VBSvkBbVFrJ-bCSvwVEHaJoJK9sgf_gKSHtWtnjysRF1SZe6WFblC8VGlYtQOK8NYfMQH37ABKlU8ND2ijcVm6UgHvvAoiAoUFRRGewk2MD5QFxKfJ4gJQmWMqXRXA8NaMWzjhLFOnUa0ncXi73KGjWQPZjZtXzY2dbJHdIRU6JGHTBJNPYi-nnj39m4k3gKhImGqKgIBLxphUN5LzdmdsZIsYnun1-H3V7KAqZQClBtAzw4bofdX1ZqtFBlj4WdDp-YdF6hgjDKyXqCQT7B1QqoiH2Pt2kwCidw0PZNEBq9aqXuTzziPFfE3yodepfeAfmWAZEESmdLXMJ1LhGMqMLbJeiXPn0zdMrSZVa6P3LZfs_jw2olbnKVlVgCph3MvhxtVfb6afSeqKPjj6yUql-LsUEbrjoepFASHzOGXlreyky2vu5bDuKMbW7jzUTmXKsvxNurNIoZqL5dHL_YvyegHr4VuymrsaVzWBd22xOW84RmYen_ZXvIpa1hxsK4W285AxsSouvWLfp9ixnmqUq8x_FKvPkqkzHs7trcPFFsVYKPquVxhUKsVm3nYalT3K-cBCH2T8UOYNls2kNJf9uVWPVMjLai3fkdPuGGlIh6YtWwBH7X2lLTmJLYH7uu1fKZawfu9eSTHgDla8JmMmnQz6lF6Aa2J5_kI-PRWEdgStidUH8vOpJaVqYH6T463HVCL8mYlvNFaOMXS9bStfcOzcOV2nHtrLCFrgU4W4MxX-2BFgSlDcBNH--rYauiaiiTQzu5fr7HQii1C1xdn16ngt2z48E1nMbj_x_FR3Vvu95cBlwKgxF1V2lrnWbTgwS_znfdVYL53o7TYvU0fls0usTtU6y0krKcMzvNNNQXRJN2thDVwwKMclt0NafHSO3ZPGLhcrjm_pK4yRKN6t7sR_JdNbT76YuAkMOf-8k5lmOljFnnnD5n24j3p-31zLIhzt0V555ngi4DmEX4w3s-h4KGWpqeu0zEN9c0YtXuzabWWxQYykkgWFcfC63biloqsrMzhunDSQVG8P-BHxXTOy8_-Q-hX-tM0cLb6TEcj26dKAZwJPMKLWPFtCiTNaagN3bxUDFge0Grps1slsAK_0s66uPV0cIyOLu2vXyDZm99uhCuH72g1LuE5Zl70HspSmP59RlUUX2HB_EDi4Q26Jaza6Ag2cjInNZF-77nABZzjWnWnuSLbBbEf26PcTthnuX2SbcZouZRgVDuJEt98Xly27HSR0Put05pf7VBsT6UvB2GLjOcg-mY7Ym_kc4L-rkjWTzQIMY5pQ5piWo2lwkXQpiTWA9TRcVS996luXV5xBh5830odmXDGQAKsVCp1avzuGF704qwboAnh_rsOBZ4-ZA_yWrorqQD-1lupTAdCSeomvpNVofb0znIfpCT_tDrLeZUWt2P7GcUiTLFgNtt6EJIcEmVfBZ-o7-lHy6wXFbodxoROwVPnz1uT8QZBNAzknBQlmMr5Xk3GmZlL7AlbySHV9fM7cXslqoyre25N3V9KQ8yKVSgoMNcoxnymmN4hS0bfBpxgP2llVt2fvm5ST4cOt4WWtYJXYmc5wk4tkHEi92p1XHtG3HPYIxooZTUov0O7ntFqtgb95-g1DOPvbsKJUbCFLU4K8sNkYU9aUwHslm4umJPdNAI6jhz717cy463w9gm-dBtcxzOeS5gSz3ZLVB7Eo-c-4r3OI_cNTEkQAP8kmwStlrApYMNLSbrqfw0TNzQTkcEh5dBEaqCIO3sfNbJqQzU-hD6C0LJOvzWD077YLkqieBrZcW5K_PuHAPB9_CoeMp-PYfk5ovcM1_Q_BMW-vEkrq9hN2PNJnoi6Ya76Q8ecpvFKue8Cfzms3aqt5ulBdKCF-FKcfRGLxAH-TyXrfq1TbOSqZgabnqUcKLXjNn-nrvSo8Qmyavt0RI3Co8Dr3ZtuQwj52V3KsE5nvXl3Tlthw0zcg4EpkYz7kVfcbcjXHsEX7N-85Mhd7lAKqje-K4HLcXMjx9F9eyuBZ_Wfr1iZFgCojhoyEsNjh8UcVnBYNECRwa5rj-cim0NvE93Xn0C8VzS7dexy1wKx6BGTqhMb_ng3kBWT9tl2qNM6DsHsibVLJ3XUCF_kJDSVUBcRjG_xBuGOeMxpo3GzAYgXxHTrt4dBj4nmO7mCcMTj3veZkLVL0dtFvyeRggnWCi-qgvYecIaA44xJyTzNSt8lo8oYzpvb3VtET7zmi6TSGb5xgv9IsJZ_Aea8UDKGfL4cKYs4vErGkXEF6OEyUUhP6kHrsNRb2wXt_nYshkxyM0pC20iRyncso14gNh4YDuUemoJcM5bPZBHRW4-urDOT0NlpcLaOUdowQPb1VEZg-ylWzTbnV5y698SIekHFXkz9d_MjtdX5U9bxuuV-7YKfAxxKiBxK-x19-5R_LUIFkKXhrehnPcQ5x8Okbk1K1HPep_ufUGigSVpQS0xOc9c7IxciKOj2sD6RE_6pivdfJ5xcEarGP9utp70DiAiVvesYZTiTEN7rdmnOGfBjhlgyg== format: openai-responses-v1 - id: rs_06569cab051e4c78016900b1eb409c81909668d636e3a424f9 + id: rs_0e67143e70e2c03a01690231c4b7c08195b5bfcc5c216e88b2 index: 0 type: reasoning.encrypted refusal: null role: assistant native_finish_reason: completed - created: 1761653226 - id: gen-1761653226-LJcHbeILDwsw5Tlqupp9 - model: openai/o3 + created: 1761751492 + id: gen-1761751492-aqUa1mlCAz0HhuH0IC5P + model: openai/gpt-5-mini object: chat.completion provider: OpenAI usage: - completion_tokens: 1431 + completion_tokens: 1457 completion_tokens_details: - reasoning_tokens: 256 - prompt_tokens: 25 - total_tokens: 1456 + reasoning_tokens: 704 + prompt_tokens: 41 + total_tokens: 1498 status: code: 200 message: OK diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index c3c2229425..5a24545448 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -89,45 +89,40 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ async def test_openrouter_preserve_reasoning_block(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) - model = OpenRouterModel('openai/o3', provider=provider) + model = OpenRouterModel('openai/gpt-5-mini', provider=provider) messages: Sequence[ModelMessage] = [] + messages.append(ModelRequest.user_text_prompt('Hello!')) + messages.append(await model_request(model, messages)) messages.append( - ModelRequest.user_text_prompt( - "What was the impact of Voltaire's writings on modern french culture? Think about your answer." - ) + ModelRequest.user_text_prompt("What was the impact of Voltaire's writings on modern french culture?") ) - response = await model_request(model, messages) - messages.append(response) + messages.append(await model_request(model, messages)) openai_messages = await model._map_messages(messages) # type: ignore[reportPrivateUsage] assistant_message = openai_messages[1] + assert assistant_message['role'] == 'assistant' + assert 'reasoning_details' not in assistant_message + + assistant_message = openai_messages[3] + assert assistant_message['role'] == 'assistant' assert 'reasoning_details' in assistant_message - assert assistant_message['reasoning_details'] == snapshot( - [ - { - 'id': None, - 'format': 'openai-responses-v1', - 'index': 0, - 'type': 'reasoning.summary', - 'summary': """\ -**Exploring Voltaire's impact** -The user seeks a thoughtful answer about Voltaire's influence on modern French culture. I should summarize his significance by discussing Enlightenment values like liberté and human rights, his role in shaping French Revolution ideas, and his distinctive use of satire. It’s also important to address his anti-clerical stance that encouraged secularism, the promotion of freedom of speech, his legacy in literature, and presence in institutions. Finally, I want to touch on his enduring impact on contemporary discussions around laïcité and cultural narrative.**Structuring the response** + reasoning_details = assistant_message['reasoning_details'] + assert len(reasoning_details) == 2 -I’m thinking about how to organize the response. I want to start with an introduction that highlights Voltaire as a central figure of the Enlightenment, noting how his writings shaped essential French values like rationality, secularism, and tolerance. After that, I should break the discussion into categories such as political thought, secularism, literature, education, and popular culture. I aim to develop this into a thoughtful answer of around 800 to 1000 words, which will cover all these important aspects.\ -""", - }, - { - 'id': 'rs_06569cab051e4c78016900b1eb409c81909668d636e3a424f9', - 'format': 'openai-responses-v1', - 'index': 0, - 'type': 'reasoning.encrypted', - 'data': 'gAAAAABpALH0SrlqG_JSYqQT07H5yKQO7GcmENK5_dmCbkx_o6J5Qg7kZHHNvKynIDieAzknNQwmLf4SB96VU59uasmIK5oOyInBjdpczoYyFEoN8zKWVOolEaCLXSMJfybQb6U_CKEYmPrh_B4CsGDuLzKq7ak6ERC0qFb0vh6ctchIyzWN7MztgnrNt85TReEN3yPmox0suv_kjEc4K5nB6L8C5NOK8ZG4Y3X88feoIvteZq0u2AapGPAYJ-tqWqbwYBBBocX7nYfOw3mVGgHb1Ku7pqf13IoWtgR-hz0lmgsLuzGucmjaBKE881oodwUGKUkDWuUbIiFIxGLH5V6cR53XttM91wAKoUgizg0HuFHS_TEYeP2rJhVBv-8OpUmdKFs-lIrCqVBlJDeIwQS_jGSaY4_z-6Prjh797X3_mSDtXBNqaiAgRQkMHHytW6mrVfZVA-cXikTLX5CRc266KNY6MkaJRAS7-tOKxjMwE-IyvmrIIMW4YTdnoaTfVcZhE5YpbrqilZllTW2RtU4lLFh4DFmBRplJsh2F4the_VXm1LITRYrZlrkLB3qTkA_oPslxFxGk_BApWRmbpCxs9mNgwzqqDCsYyvkGqUNAqCTdgPZMApWwJyRNURu_s8yHo-wcLS42zgPvC64E2GvNaO5G5xPFApbHyN950seaSiivqLc-TysXpk6RxNwKm2l1EJDPvMk0G6sZnLlQVPSXQQsCcSfZmJFSHUNSk7u99o5JsuHWsW5oco2cD111Ghd2EAujdimTRGbhjhTTt1SOGl0DL7EgYVWFiYXxgB7XsXy6PgzuIXBuJkJRn4qpk6VeRHpHujbntbVlxlt5ah-lcvRqka8JEew5NXv4qL5zuMQiSIhmHdw_zVucZv7TqknUPJSovsFte40pYwVIeQP23HMekTqsAwEjc4S28Kg300IchGuEi9ihEL9-5_kgrFTgGOOQhNYo28ftnTD7LtoS5m3Av9CraHdbK9Y4bs1u7-qFfCopcamLXJPQe1ZQ0xqR3_zGQJtK24N_oi2Et5g4o_flqzyVwrd83B5nrcbUuayJL3C9SQg4NR2VD8eS96c3qIl_FxCsD6SoTQu22VbrRngvkM_WP1EtvBSKwMtYHnHlQSufV1bkv4E3JXfHg2UJZdvJ0MtfNMTY9qx39YlI1A1Ds4ctMjCF4qAS2XPkUvvgIpwFq4JzH3v2d-f57itMmqamINLmxP2Pv1J69kj7M_shl_FWTJrWn_MtKLsS77Awxc3NdhXhvA2ketiLp_wOE8CED-o4j_Yh0NKy2AVNqeQcmZvJ3FK2vysB2oAjRqTemcad_B2fHkdceoMvSqAYk26gGm8Nvu8GK_atpKOfi1akGKQBRoERZmPT2wyDpXXS4GdVMyC8m5MUa7xJHwUsRDn4ucW792Pt_5skKrBK_So2pGhmoZa8nJYZ8x7O9ZNEXF6a4OIRgbGKnkVpP95YzlQAsVxR31YXkE1pcdM4nRqCpPjdoQjZ0Twr0ute5v4J6Lhb1F3FsNrg3Sm9YRkJ9h-yfUfvyt1bK1V4nFMtRFt120WjfIvlZZ-1qyenToySK8doSSUZ6VOQWG_ieBkf-IRAN3eONC0n6BfGogsVlPXhXHLznouLnzapC4pGWWBIDsGlTvZj_o7UpHMPr_20PDC5d2jSGGtXf6kJvtfsAnJjtQPHs41VfDLyT-yQIlnUd8QvdwUlQ22A79I-rg38C8BWJNqg3sbOtzMMpt6R8Cvyp4dmB1ksS24tpiEZZ42aH8JIgoqs1sRbFPsC1v3kDPd3XRbbKpliQxseR_xWMNZkGj2F8q9HH1lgLkkCod_97OYrYBROxn9K79wlkZBUFjrNXA3EuiBf-IDOvQeKtDRypAaTKnHybIEOIypTNOWjhGT6oQutKSFswfvSeJGA0fF26FAgxnVmzFS7eAyzSHDqygQfhB7Yp7N2yEbD0eFLUs8qgete-eDIn6eM5E5eMnT1JeP6LD8ku5iR30sDdU8O6BrsGvUypMSID-hoBDytF1_GS6yOhMsU4pXZHTJ4yYNUOFyMH3ReE3SeAuFFohR9aXTpUA5YeLy6-Xo0_ZA9FuFMDVK4Bp1F5f-2BJ3FXRc1aqtyROpMdBtY4ehEqm-FKqbYd4VlaIMb9adG1LnOgWpnWCr9ciOP-c75rxX885yZLXO8rHJ_wbg04JzobGFnKdZHPtCYiTgkpnFavesiy9iI_bRO0Mu7SaDwXne6u4NY4YIHGRRCKR7o98lvSCOw7PT3SgWPoHEML6Na0QnycJeiPayB7megnFGfQZn_lSzDDeAiKgOBJ42LJZf3ysH1Dueqb7icX6xn4HlrMJdDLhMCgvwry4QQkrgrsIhyrTFNt2j--0IO7hX4RbwU5v5yudb0QRQovbmjoPRk4qeZKOyH-YSW-J1lu2MJcrk9Z-Sc4d875cZ6B3HxUuJFYQWqMoJ6EkZjNRLpp3XBkFEu5ip4md7yu_FYK7SInsuVI0igMVRx5i9vURIiGnVf60yBfWpqJac0Jp_7V3ftXsdXk3pSE4_GF9QgKM4l9chXH-frUEExxXS13BRQJP1b29-0B7ciG-48c18uSktRItBmXv2_baSiyo7_nvnPWVUgpig9qOuiFFmVPPvFRGTQS6jh8dPZR8PCFnpxuMhVrrJDJUNu8wmVGdVDMZP6kO2PYhNpz35RrX25SSgzjbl6V4uFDb7KQdWQAv-78QkLibUeB7w47I_2G47TGxbUsmnTt_sss1LW0peGrmCMKncgSQKro8rSBQ==', - }, - ] - ) + reasoning_summary = reasoning_details[0] + + assert 'summary' in reasoning_summary + assert reasoning_summary['type'] == 'reasoning.summary' + assert reasoning_summary['format'] == 'openai-responses-v1' + + reasoning_encrypted = reasoning_details[1] + + assert 'data' in reasoning_encrypted + assert reasoning_encrypted['type'] == 'reasoning.encrypted' + assert reasoning_encrypted['format'] == 'openai-responses-v1' async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_api_key: str) -> None: From c16c960e278e0d5e915a5bac65ba63ca11f1260a Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 29 Oct 2025 11:23:49 -0600 Subject: [PATCH 19/42] fix typing --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 0852ed9c0a..5fea3cea19 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -472,7 +472,7 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompleti openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] if openai_message['role'] == 'assistant' and isinstance( - contents := openai_message['content'], str + contents := openai_message.get('content'), str ): # pragma: lax no cover openai_message['content'] = re.sub(r'.*?\s*', '', contents, flags=re.DOTALL).strip() From baede41fb6cad79c100f948e6956b0ede388899f Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 1 Nov 2025 09:39:43 -0600 Subject: [PATCH 20/42] add _map_model_response method --- pydantic_ai_slim/pydantic_ai/models/openai.py | 65 +++++++++-------- .../pydantic_ai/models/openrouter.py | 71 +++++++++++-------- 2 files changed, 74 insertions(+), 62 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 96133538d4..26ebdcd3cf 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -668,6 +668,39 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete f'`{tool.__class__.__name__}` is not supported by `OpenAIChatModel`. If it should be, please file an issue.' ) + def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: + texts: list[str] = [] + tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [] + for item in message.parts: + if isinstance(item, TextPart): + texts.append(item.content) + elif isinstance(item, ThinkingPart): + # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, + # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. + # If you need this changed, please file an issue. + start_tag, end_tag = self.profile.thinking_tags + texts.append('\n'.join([start_tag, item.content, end_tag])) + elif isinstance(item, ToolCallPart): + tool_calls.append(self._map_tool_call(item)) + # OpenAI doesn't return built-in tool calls + elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover + pass + elif isinstance(item, FilePart): # pragma: no cover + # Files generated by models are not sent back to models that don't themselves generate files. + pass + else: + assert_never(item) + message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') + if texts: + # Note: model responses from this model should only have one text item, so the following + # shouldn't merge multiple texts into one unless you switch models between runs: + message_param['content'] = '\n\n'.join(texts) + else: + message_param['content'] = None + if tool_calls: + message_param['tool_calls'] = tool_calls + return message_param + async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam`.""" openai_messages: list[chat.ChatCompletionMessageParam] = [] @@ -676,37 +709,7 @@ async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCom async for item in self._map_user_message(message): openai_messages.append(item) elif isinstance(message, ModelResponse): - texts: list[str] = [] - tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [] - for item in message.parts: - if isinstance(item, TextPart): - texts.append(item.content) - elif isinstance(item, ThinkingPart): - # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, - # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. - # If you need this changed, please file an issue. - start_tag, end_tag = self.profile.thinking_tags - texts.append('\n'.join([start_tag, item.content, end_tag])) - elif isinstance(item, ToolCallPart): - tool_calls.append(self._map_tool_call(item)) - # OpenAI doesn't return built-in tool calls - elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover - pass - elif isinstance(item, FilePart): # pragma: no cover - # Files generated by models are not sent back to models that don't themselves generate files. - pass - else: - assert_never(item) - message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') - if texts: - # Note: model responses from this model should only have one text item, so the following - # shouldn't merge multiple texts into one unless you switch models between runs: - message_param['content'] = '\n\n'.join(texts) - else: - message_param['content'] = None - if tool_calls: - message_param['tool_calls'] = tool_calls - openai_messages.append(message_param) + openai_messages.append(self._map_model_response(message)) else: assert_never(message) if instructions := self._get_instructions(messages): diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 5fea3cea19..8cacab4d7e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,9 +1,8 @@ -import re from dataclasses import asdict, dataclass -from typing import Any, Literal, cast +from typing import Any, Literal, assert_never, cast from openai import AsyncOpenAI -from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam +from openai.types import chat from openai.types.chat.chat_completion import Choice from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict @@ -11,9 +10,13 @@ from .. import _utils from ..exceptions import ModelHTTPError, UnexpectedModelBehavior from ..messages import ( - ModelMessage, + BuiltinToolCallPart, + BuiltinToolReturnPart, + FilePart, ModelResponse, + TextPart, ThinkingPart, + ToolCallPart, ) from ..profiles import ModelProfileSpec from ..providers import Provider @@ -323,7 +326,7 @@ def into_reasoning_detail(self): return _reasoning_detail_adapter.validate_python(asdict(self)).model_dump() -class OpenRouterCompletionMessage(ChatCompletionMessage): +class OpenRouterCompletionMessage(chat.ChatCompletionMessage): """Wrapped chat completion message with OpenRouter specific attributes.""" reasoning: str | None = None @@ -349,7 +352,7 @@ class OpenRouterChoice(Choice): """A wrapped chat completion message with OpenRouter specific attributes.""" -class OpenRouterChatCompletion(ChatCompletion): +class OpenRouterChatCompletion(chat.ChatCompletion): """Wraps OpenAI chat completion with OpenRouter specific attributes.""" provider: str @@ -419,8 +422,8 @@ def prepare_request( new_settings = _openrouter_settings_to_openai_settings(cast(OpenRouterModelSettings, merged_settings or {})) return new_settings, customized_parameters - def _process_response(self, response: ChatCompletion | str) -> ModelResponse: - if not isinstance(response, ChatCompletion): + def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse: + if not isinstance(response, chat.ChatCompletion): raise UnexpectedModelBehavior( 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' ) @@ -448,32 +451,38 @@ def _process_response(self, response: ChatCompletion | str) -> ModelResponse: provider_details['native_finish_reason'] = choice.native_finish_reason if reasoning_details := choice.message.reasoning_details: - new_parts: list[ThinkingPart] = [ - OpenRouterThinkingPart.from_reasoning_detail(reasoning) for reasoning in reasoning_details - ] - + new_parts = [OpenRouterThinkingPart.from_reasoning_detail(reasoning) for reasoning in reasoning_details] model_response.parts = [*new_parts, *model_response.parts] model_response.provider_details = provider_details return model_response - async def _map_messages(self, messages: list[ModelMessage]) -> list[ChatCompletionMessageParam]: - """Maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam` and adds OpenRouter specific parameters.""" - openai_messages = await super()._map_messages(messages) - - for message, openai_message in zip(messages, openai_messages): - if isinstance(message, ModelResponse): - if reasoning_details := [ - part.into_reasoning_detail() - for part in message.parts - if isinstance(part, OpenRouterThinkingPart) and part.provider_name == self.system - ]: - openai_message['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] - - if openai_message['role'] == 'assistant' and isinstance( - contents := openai_message.get('content'), str - ): # pragma: lax no cover - openai_message['content'] = re.sub(r'.*?\s*', '', contents, flags=re.DOTALL).strip() - - return openai_messages + def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: + texts: list[str] = [] + tool_calls: list[chat.ChatCompletionMessageFunctionToolCallParam] = [] + reasoning_details: list[dict[str, Any]] = [] + for item in message.parts: + if isinstance(item, TextPart): + texts.append(item.content) + elif isinstance(item, ThinkingPart): + if isinstance(item, OpenRouterThinkingPart) and item.provider_name == self.system: + reasoning_details.append(item.into_reasoning_detail()) + elif isinstance(item, ToolCallPart): + tool_calls.append(self._map_tool_call(item)) + elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover + pass + elif isinstance(item, FilePart): # pragma: no cover + pass + else: + assert_never(item) + message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') + if texts: + message_param['content'] = '\n\n'.join(texts) + else: + message_param['content'] = None + if tool_calls: + message_param['tool_calls'] = tool_calls + if reasoning_details: + message_param['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] + return message_param From 89ef9a8fbadd7a1c7d78de2d60991160ef4bfa77 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 1 Nov 2025 09:48:51 -0600 Subject: [PATCH 21/42] move assert_never import to typing_extensions --- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 8cacab4d7e..f5f9e6698c 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,11 +1,11 @@ from dataclasses import asdict, dataclass -from typing import Any, Literal, assert_never, cast +from typing import Any, Literal, cast from openai import AsyncOpenAI from openai.types import chat from openai.types.chat.chat_completion import Choice from pydantic import AliasChoices, BaseModel, Field, TypeAdapter -from typing_extensions import TypedDict +from typing_extensions import TypedDict, assert_never from .. import _utils from ..exceptions import ModelHTTPError, UnexpectedModelBehavior From ebc8d088179403f840aead2b43731173407ae78d Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 1 Nov 2025 20:16:42 -0600 Subject: [PATCH 22/42] add tool calling test --- .../pydantic_ai/models/openrouter.py | 4 +- .../test_openrouter_tool_calling.yaml | 97 +++++++++++++++++++ tests/models/test_openrouter.py | 58 ++++++++++- 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_tool_calling.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index f5f9e6698c..07169e5df2 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -466,8 +466,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess if isinstance(item, TextPart): texts.append(item.content) elif isinstance(item, ThinkingPart): - if isinstance(item, OpenRouterThinkingPart) and item.provider_name == self.system: + if item.provider_name == self.system and isinstance(item, OpenRouterThinkingPart): reasoning_details.append(item.into_reasoning_detail()) + else: # pragma: no cover + pass elif isinstance(item, ToolCallPart): tool_calls.append(self._map_tool_call(item)) elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_tool_calling.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_tool_calling.yaml new file mode 100644 index 0000000000..37fa760883 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_tool_calling.yaml @@ -0,0 +1,97 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '514' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: What is 123 / 456? + role: user + model: mistralai/mistral-small + stream: false + tool_choice: auto + tools: + - function: + description: Divide two numbers. + name: divide + parameters: + additionalProperties: false + description: Divide two numbers. + properties: + denominator: + type: number + numerator: + type: number + on_inf: + default: infinity + enum: + - error + - infinity + type: string + required: + - numerator + - denominator + type: object + type: function + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '585' + 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: '{"numerator": 123, "denominator": 456, "on_inf": "infinity"}' + name: divide + id: 3sniiMddS + index: 0 + type: function + native_finish_reason: tool_calls + created: 1762047030 + id: gen-1762047030-dJUcJW4ildNGqK4UV6iJ + model: mistralai/mistral-small + object: chat.completion + provider: Mistral + usage: + completion_tokens: 43 + prompt_tokens: 134 + total_tokens: 177 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 5a24545448..8e922c17da 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -1,8 +1,9 @@ from collections.abc import Sequence -from typing import cast +from typing import Literal, cast import pytest from inline_snapshot import snapshot +from pydantic import BaseModel from pydantic_ai import ( Agent, @@ -11,9 +12,12 @@ ModelRequest, TextPart, ThinkingPart, + ToolCallPart, + ToolDefinition, UnexpectedModelBehavior, ) from pydantic_ai.direct import model_request +from pydantic_ai.models import ModelRequestParameters from ..conftest import try_import @@ -69,6 +73,58 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro assert response.provider_details['native_finish_reason'] == 'stop' +async def test_openrouter_tool_calling(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + + class Divide(BaseModel): + """Divide two numbers.""" + + numerator: float + denominator: float + on_inf: Literal['error', 'infinity'] = 'infinity' + + model = OpenRouterModel('mistralai/mistral-small', provider=provider) + response = await model_request( + model, + [ModelRequest.user_text_prompt('What is 123 / 456?')], + model_request_parameters=ModelRequestParameters( + function_tools=[ + ToolDefinition( + name=Divide.__name__.lower(), + description=Divide.__doc__, + parameters_json_schema=Divide.model_json_schema(), + ) + ], + allow_text_output=True, # Allow model to either use tools or respond directly + ), + ) + + assert len(response.parts) == 1 + + tool_call_part = response.parts[0] + assert isinstance(tool_call_part, ToolCallPart) + assert tool_call_part.tool_call_id == snapshot('3sniiMddS') + assert tool_call_part.tool_name == 'divide' + assert tool_call_part.args == snapshot('{"numerator": 123, "denominator": 456, "on_inf": "infinity"}') + + mapped_messages = await model._map_messages([response]) # type: ignore[reportPrivateUsage] + tool_call_message = mapped_messages[0] + assert tool_call_message['role'] == 'assistant' + assert tool_call_message.get('content') is None + assert tool_call_message.get('tool_calls') == snapshot( + [ + { + 'id': '3sniiMddS', + 'type': 'function', + 'function': { + 'name': 'divide', + 'arguments': '{"numerator": 123, "denominator": 456, "on_inf": "infinity"}', + }, + } + ] + ) + + async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) request = ModelRequest.user_text_prompt( From 21a78e45c1255f0566ef4b632a2167a3c8755efd Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sun, 2 Nov 2025 14:40:00 -0600 Subject: [PATCH 23/42] replace process_response with hooks --- pydantic_ai_slim/pydantic_ai/models/openai.py | 157 ++++++++----- .../pydantic_ai/models/openrouter.py | 156 +++++++++--- ...openrouter_stream_with_native_options.yaml | 222 ++++++++++++++++++ tests/models/test_openai.py | 4 +- tests/models/test_openrouter.py | 45 ++-- 5 files changed, 465 insertions(+), 119 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_stream_with_native_options.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 26ebdcd3cf..dcd2c506b1 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -9,6 +9,7 @@ from datetime import datetime from typing import Any, Literal, cast, overload +from openai.types.chat.chat_completion_chunk import Choice from pydantic import ValidationError from pydantic_core import to_json from typing_extensions import assert_never, deprecated @@ -529,6 +530,50 @@ async def _completions_create( raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e raise # pragma: lax no cover + def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatCompletion: + return chat.ChatCompletion.model_validate(response.model_dump()) + + def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]: + message = response.choices[0].message + items: list[ThinkingPart] = [] + + # The `reasoning_content` field is only present in DeepSeek models. + # https://api-docs.deepseek.com/guides/reasoning_model + if reasoning_content := getattr(message, 'reasoning_content', None): + items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system)) + + # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. + # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api + # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens + if reasoning := getattr(message, 'reasoning', None): + items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) + + return items + + def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: + choice = response.choices[0] + provider_details: dict[str, Any] = {} + + # Add logprobs to vendor_details if available + if choice.logprobs is not None and choice.logprobs.content: + # Convert logprobs to a serializable format + provider_details['logprobs'] = [ + { + 'token': lp.token, + 'bytes': lp.bytes, + 'logprob': lp.logprob, + 'top_logprobs': [ + {'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs + ], + } + for lp in choice.logprobs.content + ] + + raw_finish_reason = choice.finish_reason + provider_details['finish_reason'] = raw_finish_reason + + return provider_details + def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" # Although the OpenAI SDK claims to return a Pydantic model (`ChatCompletion`) from the chat completions function: @@ -536,7 +581,9 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons # * if the endpoint returns plain text, the return type is a string # Thus we validate it fully here. if not isinstance(response, chat.ChatCompletion): - raise UnexpectedModelBehavior('Invalid response from OpenAI chat completions endpoint, expected JSON data') + raise UnexpectedModelBehavior( + f'Invalid response from {self.system} chat completions endpoint, expected JSON data' + ) if response.created: timestamp = number_to_datetime(response.created) @@ -549,23 +596,15 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons choice.finish_reason = 'stop' try: - response = chat.ChatCompletion.model_validate(response.model_dump()) + response = self._validate_completion(response) except ValidationError as e: - raise UnexpectedModelBehavior(f'Invalid response from OpenAI chat completions endpoint: {e}') from e + raise UnexpectedModelBehavior(f'Invalid response from {self.system} chat completions endpoint: {e}') from e choice = response.choices[0] items: list[ModelResponsePart] = [] - # The `reasoning_content` field is only present in DeepSeek models. - # https://api-docs.deepseek.com/guides/reasoning_model - if reasoning_content := getattr(choice.message, 'reasoning_content', None): - items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system)) - - # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. - # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api - # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens - if reasoning := getattr(choice.message, 'reasoning', None): - items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) + if thinking_parts := self._process_reasoning(response): + items.extend(thinking_parts) if choice.message.content: items.extend( @@ -585,36 +624,15 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons part.tool_call_id = _guard_tool_call_id(part) items.append(part) - vendor_details: dict[str, Any] = {} - - # Add logprobs to vendor_details if available - if choice.logprobs is not None and choice.logprobs.content: - # Convert logprobs to a serializable format - vendor_details['logprobs'] = [ - { - 'token': lp.token, - 'bytes': lp.bytes, - 'logprob': lp.logprob, - 'top_logprobs': [ - {'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs - ], - } - for lp in choice.logprobs.content - ] - - raw_finish_reason = choice.finish_reason - vendor_details['finish_reason'] = raw_finish_reason - finish_reason = _CHAT_FINISH_REASON_MAP.get(raw_finish_reason) - return ModelResponse( parts=items, usage=_map_usage(response, self._provider.name, self._provider.base_url, self._model_name), model_name=response.model, timestamp=timestamp, - provider_details=vendor_details or None, + provider_details=self._process_provider_details(response), provider_response_id=response.id, provider_name=self._provider.name, - finish_reason=finish_reason, + finish_reason=self._map_finish_reason(choice.finish_reason), ) async def _process_streamed_response( @@ -701,6 +719,11 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess message_param['tool_calls'] = tool_calls return message_param + def _map_finish_reason( + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] + ) -> FinishReason | None: + return _CHAT_FINISH_REASON_MAP.get(key) + async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam`.""" openai_messages: list[chat.ChatCompletionMessageParam] = [] @@ -1679,9 +1702,35 @@ class OpenAIStreamedResponse(StreamedResponse): _provider_name: str _provider_url: str + def _handle_thinking_delta(self, choice: Choice): + # The `reasoning_content` field is only present in DeepSeek models. + # https://api-docs.deepseek.com/guides/reasoning_model + if reasoning_content := getattr(choice.delta, 'reasoning_content', None): + yield self._parts_manager.handle_thinking_delta( + vendor_part_id='reasoning_content', + id='reasoning_content', + content=reasoning_content, + provider_name=self.provider_name, + ) + + # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. + # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api + # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens + if reasoning := getattr(choice.delta, 'reasoning', None): # pragma: no cover + yield self._parts_manager.handle_thinking_delta( + vendor_part_id='reasoning', + id='reasoning', + content=reasoning, + provider_name=self.provider_name, + ) + + def _handle_provider_details(self, choice: Choice) -> dict[str, str] | None: + if raw_finish_reason := choice.finish_reason: + return {'finish_reason': raw_finish_reason} + async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: async for chunk in self._response: - self._usage += _map_usage(chunk, self._provider_name, self._provider_url, self._model_name) + self._usage += self._map_usage(chunk) if chunk.id: # pragma: no branch self.provider_response_id = chunk.id @@ -1699,29 +1748,13 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: continue if raw_finish_reason := choice.finish_reason: - self.provider_details = {'finish_reason': raw_finish_reason} - self.finish_reason = _CHAT_FINISH_REASON_MAP.get(raw_finish_reason) + self.finish_reason = self._map_finish_reason(raw_finish_reason) - # The `reasoning_content` field is only present in DeepSeek models. - # https://api-docs.deepseek.com/guides/reasoning_model - if reasoning_content := getattr(choice.delta, 'reasoning_content', None): - yield self._parts_manager.handle_thinking_delta( - vendor_part_id='reasoning_content', - id='reasoning_content', - content=reasoning_content, - provider_name=self.provider_name, - ) + if provider_details := self._handle_provider_details(choice): + self.provider_details = provider_details - # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. - # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api - # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens - if reasoning := getattr(choice.delta, 'reasoning', None): # pragma: no cover - yield self._parts_manager.handle_thinking_delta( - vendor_part_id='reasoning', - id='reasoning', - content=reasoning, - provider_name=self.provider_name, - ) + for thinking_part in self._handle_thinking_delta(choice): + yield thinking_part # Handle the text part of the response content = choice.delta.content @@ -1748,6 +1781,14 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if maybe_event is not None: yield maybe_event + def _map_usage(self, response: ChatCompletionChunk): + return _map_usage(response, self._provider_name, self._provider_url, self._model_name) + + def _map_finish_reason( + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] + ) -> FinishReason | None: + return _CHAT_FINISH_REASON_MAP.get(key) + @property def model_name(self) -> OpenAIModelName: """Get the model name of the response.""" diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 07169e5df2..ab9e67c4b9 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,7 +1,7 @@ from dataclasses import asdict, dataclass -from typing import Any, Literal, cast +from typing import Any, Literal, cast, override -from openai import AsyncOpenAI +from openai import AsyncStream from openai.types import chat from openai.types.chat.chat_completion import Choice from pydantic import AliasChoices, BaseModel, Field, TypeAdapter @@ -13,16 +13,26 @@ BuiltinToolCallPart, BuiltinToolReturnPart, FilePart, + FinishReason, ModelResponse, TextPart, ThinkingPart, ToolCallPart, ) from ..profiles import ModelProfileSpec -from ..providers import Provider +from ..providers.openrouter import OpenRouterProvider from ..settings import ModelSettings +from ..usage import RequestUsage from . import ModelRequestParameters -from .openai import OpenAIChatModel, OpenAIChatModelSettings +from .openai import OpenAIChatModel, OpenAIChatModelSettings, OpenAIStreamedResponse + +_CHAT_FINISH_REASON_MAP: dict[Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'], FinishReason] = { + 'stop': 'stop', + 'length': 'length', + 'tool_calls': 'tool_call', + 'content_filter': 'content_filter', + 'error': 'error', +} class OpenRouterMaxPrice(TypedDict, total=False): @@ -102,7 +112,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): ] """Known providers in the OpenRouter marketplace""" -OpenRouterProvider = str | KnownOpenRouterProviders +OpenRouterMarketplaceProvider = str | KnownOpenRouterProviders """Possible OpenRouter provider slugs. Since OpenRouter is constantly updating their list of providers, we explicitly list some known providers but @@ -120,7 +130,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): class OpenRouterProviderConfig(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" - order: list[OpenRouterProvider] + order: list[OpenRouterMarketplaceProvider] """List of provider slugs to try in order (e.g. ["anthropic", "openai"]). [See details](https://openrouter.ai/docs/features/provider-routing#ordering-specific-providers)""" allow_fallbacks: bool @@ -135,7 +145,7 @@ class OpenRouterProviderConfig(TypedDict, total=False): zdr: bool """Restrict routing to only ZDR (Zero Data Retention) endpoints. [See details](https://openrouter.ai/docs/features/provider-routing#zero-data-retention-enforcement)""" - only: list[OpenRouterProvider] + only: list[OpenRouterMarketplaceProvider] """List of provider slugs to allow for this request. [See details](https://openrouter.ai/docs/features/provider-routing#allowing-only-specific-providers)""" ignore: list[str] @@ -365,6 +375,60 @@ class OpenRouterChatCompletion(chat.ChatCompletion): """OpenRouter specific error attribute.""" +class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): + """Wraps OpenAI chat completion with OpenRouter specific attributes.""" + + provider: str + """The downstream provider that was used by OpenRouter.""" + + choices: list[OpenRouterChoice] # type: ignore[reportIncompatibleVariableOverride] + """A list of chat completion choices modified with OpenRouter specific attributes.""" + + error: OpenRouterError | None = None + """OpenRouter specific error attribute.""" + + +def _map_usage( + response: chat.ChatCompletion | chat.ChatCompletionChunk, + provider: str, + provider_url: str, + model: str, +) -> RequestUsage: + response_usage = response.usage + if response_usage is None: + return RequestUsage() + + usage_data = response_usage.model_dump(exclude_none=True) + details = { + k: v + for k, v in usage_data.items() + if k not in {'prompt_tokens', 'completion_tokens', 'input_tokens', 'output_tokens', 'total_tokens'} + if isinstance(v, int) + } + response_data = dict(model=model, usage=usage_data) + + if response_usage.completion_tokens_details is not None: + details.update(response_usage.completion_tokens_details.model_dump(exclude_none=True)) + + return RequestUsage.extract( + response_data, + provider=provider, + provider_url=provider_url, + provider_fallback='openai', + api_flavor='chat', + details=details, + ) + + +@dataclass +class OpenRouterStreamedResponse(OpenAIStreamedResponse): + """Implementation of `StreamedResponse` for OpenAI models.""" + + @override + def _map_usage(self, response: chat.ChatCompletionChunk): + return _map_usage(response, self._provider_name, self._provider_url, self._model_name) + + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -397,7 +461,7 @@ def __init__( self, model_name: str, *, - provider: Literal['openrouter'] | Provider[AsyncOpenAI] = 'openrouter', + provider: OpenRouterProvider | None = None, profile: ModelProfileSpec | None = None, settings: ModelSettings | None = None, ): @@ -405,13 +469,11 @@ def __init__( Args: model_name: The name of the model to use. - provider: The provider to use for authentication and API access. Currently, uses OpenAI as the internal client. Can be either the string - 'openrouter' or an instance of `Provider[AsyncOpenAI]`. If not provided, a new provider will be - created using the other parameters. + provider: The provider to use for authentication and API access. If not provided, a new provider will be created with the default settings. profile: The model profile to use. Defaults to a profile picked by the provider based on the model name. settings: Model-specific settings that will be used as defaults for this model. """ - super().__init__(model_name, provider=provider, profile=profile, settings=settings) + super().__init__(model_name, provider=provider or OpenRouterProvider(), profile=profile, settings=settings) def prepare_request( self, @@ -422,41 +484,59 @@ def prepare_request( new_settings = _openrouter_settings_to_openai_settings(cast(OpenRouterModelSettings, merged_settings or {})) return new_settings, customized_parameters - def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse: - if not isinstance(response, chat.ChatCompletion): - raise UnexpectedModelBehavior( - 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' - ) + @override + def _map_finish_reason( + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] + ) -> FinishReason | None: # type: ignore[reportIncompatibleMethodOverride] + return _CHAT_FINISH_REASON_MAP.get(key) + + @override + def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[ThinkingPart]: + message = response.choices[0].message + items: list[ThinkingPart] = [] - native_response = OpenRouterChatCompletion.model_validate(response.model_dump()) - choice = native_response.choices[0] + if reasoning_details := message.reasoning_details: + for detail in reasoning_details: + items.append(OpenRouterThinkingPart.from_reasoning_detail(detail)) - if error := native_response.error: + return items + + @override + def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[str, Any]: + if error := response.error: raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) - else: - if choice.finish_reason == 'error': - raise UnexpectedModelBehavior( - 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' - ) - # This is done because 'super()._process_response' reads 'reasoning' to create a ThinkingPart. - # but this method will also create a ThinkingPart using 'reasoning_details'; Delete 'reasoning' to avoid duplication - if choice.message.reasoning is not None: - delattr(response.choices[0].message, 'reasoning') + provider_details = super()._process_provider_details(response) - model_response = super()._process_response(response=response) + provider_details['downstream_provider'] = response.provider + provider_details['native_finish_reason'] = response.choices[0].native_finish_reason - provider_details = model_response.provider_details or {} - provider_details['downstream_provider'] = native_response.provider - provider_details['native_finish_reason'] = choice.native_finish_reason + return provider_details - if reasoning_details := choice.message.reasoning_details: - new_parts = [OpenRouterThinkingPart.from_reasoning_detail(reasoning) for reasoning in reasoning_details] - model_response.parts = [*new_parts, *model_response.parts] + @override + def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatCompletion: + return OpenRouterChatCompletion.model_validate(response.model_dump()) - model_response.provider_details = provider_details + async def _process_streamed_response( + self, response: AsyncStream[chat.ChatCompletionChunk], model_request_parameters: ModelRequestParameters + ) -> OpenRouterStreamedResponse: + """Process a streamed response, and prepare a streaming response to return.""" + peekable_response = _utils.PeekableAsyncStream(response) + first_chunk = await peekable_response.peek() + if isinstance(first_chunk, _utils.Unset): + raise UnexpectedModelBehavior( # pragma: no cover + 'Streamed response ended without content or tool calls' + ) - return model_response + return OpenRouterStreamedResponse( + model_request_parameters=model_request_parameters, + _model_name=self._model_name, + _model_profile=self.profile, + _response=peekable_response, + _timestamp=_utils.number_to_datetime(first_chunk.created), + _provider_name=self._provider.name, + _provider_url=self._provider.base_url, + ) def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: texts: list[str] = [] diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_native_options.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_native_options.yaml new file mode 100644 index 0000000000..84f64743ed --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_native_options.yaml @@ -0,0 +1,222 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '232' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Who are you + role: user + model: google/gemini-2.0-flash-exp:free + models: + - x-ai/grok-4 + provider: + only: + - xai + stream: true + stream_options: + include_usage: true + transforms: + - middle-out + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: |+ + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","data":"B7oH6rUO9kQMWKmZOE/vO5bKz16j1SCeoDLf5c1JLB+JRnshhzOUTvc9I1LUTBNRXe4Beug/yrWrMPNJaMHxfQ49MFLUkkBZWoc1BDNc9t+z9fb5PQiaCCaCQn4bh81RouEbDK1IuwcrFfYtDe/GFHWVqrUN0cLVy3sASDPb8KXhogJ0RnxAZlvxfF2AaAovWtIX46ZpKj1KWWTXruxkT2ZpMEhBbmfRKOTRD7OskwShbq/FeWiOmRLwWjO0coGE/kR6NQGU5sb9frTWNLNXtOgYZ47xHTDNnGoPueg3yRU/z78nW40UEOKHVCzEwyNnhaZPy5IQt+NarFVGydc3UMp1tiQtoq7CcRqBeZgE4N+DG2NyNz4gbaTgZBMFdpHtvxdSF05R2KNqlk62b6TtHY/tBEhmlLBVO3AA5pwyjYXNjVFTAgizni6ENDUDiNiBtzv0QKHpRoGV/7NrtR5cs2kb8+Rhv7mNsh+JKeZ6pjSSAq9ChdI2RsMlFitKFD1HA81+eElZVgacMPgBhd3WHQ3QVkwwFWhmjF9GG/obyDoGC3zXpIBWncWVwjVoMXUa/Js+vWOA0yKa0SgznIW1KK2LIMHARaT4IgSAPQGgY+4T2DEEqT+nEu+E0s4CosBXC81Uto+5rxL1ce/R87q7Wnqsg+5Vq00zmttxZ7e5b40/V83i4FOsfMg29ruWVY0d93+rUWvUNpOVGDKWr/VvoGseRZfCIN/pbHbvCAoGJPhFMQaTpwpw2RmvHlj8nvCNCoZjky9DL8E9Mm89bGZgNW63Tbqiw38","id":"rs_619f3702-e30e-6ba5-7b57-466db8036365_us-east-1","format":"xai-responses-v1","index":0}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"I'm"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Gro"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"k"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" an"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" AI"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" built"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" by"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" x"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"AI"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" I'm"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" designed"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" be"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" helpful"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" maximally"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" truthful"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" a"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" bit"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" witty"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"—"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"think"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" a"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" mix"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Hitch"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"hik"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"er's"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Guide"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Galaxy"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" JAR"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"VIS"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" from"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Iron"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" Man"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" My"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" goal"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" help"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" you"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" understand"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" universe"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" ("},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" maybe"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" crack"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" a"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" few"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" jokes"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" along"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" way"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":")."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" What's"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" on"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" your"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":" mind"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":"?"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"completed","logprobs":null}]} + + data: {"id":"gen-1762064096-m5VxL2xrxOREwashCey6","provider":"xAI","model":"x-ai/grok-4","object":"chat.completion.chunk","created":1762064097,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":687,"completion_tokens":187,"total_tokens":874,"cost":0.00333825,"is_byok":false,"prompt_tokens_details":{"cached_tokens":679,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00053325,"upstream_inference_completions_cost":0.002805},"completion_tokens_details":{"reasoning_tokens":118,"image_tokens":0}}} + + data: [DONE] + + headers: + access-control-allow-origin: + - '*' + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + 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 + status: + code: 200 + message: OK +version: 1 +... diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index 2307b3fd1b..d7ba1f6434 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -2889,7 +2889,7 @@ async def test_invalid_response(allow_model_requests: None): with pytest.raises(UnexpectedModelBehavior) as exc_info: await agent.run('What is the capital of France?') assert exc_info.value.message.startswith( - 'Invalid response from OpenAI chat completions endpoint: 4 validation errors for ChatCompletion' + 'Invalid response from openai chat completions endpoint: 4 validation errors for ChatCompletion' ) @@ -2903,7 +2903,7 @@ async def test_text_response(allow_model_requests: None): with pytest.raises(UnexpectedModelBehavior) as exc_info: await agent.run('What is the capital of France?') assert exc_info.value.message == snapshot( - 'Invalid response from OpenAI chat completions endpoint, expected JSON data' + 'Invalid response from openai chat completions endpoint, expected JSON data' ) diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 8e922c17da..1aab88ed4e 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -16,7 +16,7 @@ ToolDefinition, UnexpectedModelBehavior, ) -from pydantic_ai.direct import model_request +from pydantic_ai.direct import model_request, model_request_stream from pydantic_ai.models import ModelRequestParameters from ..conftest import try_import @@ -73,6 +73,28 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro assert response.provider_details['native_finish_reason'] == 'stop' +async def test_openrouter_stream_with_native_options(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) + # These specific settings will force OpenRouter to use the fallback model, since Gemini is not available via the xAI provider. + settings = OpenRouterModelSettings( + openrouter_models=['x-ai/grok-4'], + openrouter_transforms=['middle-out'], + openrouter_provider={'only': ['xai']}, + ) + + async with model_request_stream( + model, [ModelRequest.user_text_prompt('Who are you')], model_settings=settings + ) as stream: + assert stream.provider_details == snapshot(None) + assert stream.finish_reason == snapshot(None) + + _ = [chunk async for chunk in stream] + + assert stream.provider_details == snapshot({'finish_reason': 'stop'}) + assert stream.finish_reason == snapshot('stop') + + async def test_openrouter_tool_calling(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) @@ -200,7 +222,7 @@ async def test_openrouter_validate_non_json_response(openrouter_api_key: str) -> model._process_response('This is not JSON!') # type: ignore[reportPrivateUsage] assert str(exc_info.value) == snapshot( - 'Invalid response from OpenRouter chat completions endpoint, expected JSON data' + 'Invalid response from openrouter chat completions endpoint, expected JSON data' ) @@ -224,25 +246,6 @@ async def test_openrouter_validate_error_response(openrouter_api_key: str) -> No ) -async def test_openrouter_validate_error_finish_reason(openrouter_api_key: str) -> None: - provider = OpenRouterProvider(api_key=openrouter_api_key) - model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) - - choice = Choice.model_construct( - index=0, message={'role': 'assistant'}, finish_reason='error', native_finish_reason='stop' - ) - response = ChatCompletion.model_construct( - id='', choices=[choice], created=0, object='chat.completion', model='test', provider='test' - ) - - with pytest.raises(UnexpectedModelBehavior) as exc_info: - model._process_response(response) # type: ignore[reportPrivateUsage] - - assert str(exc_info.value) == snapshot( - 'Invalid response from OpenRouter chat completions endpoint, error finish_reason without error data' - ) - - async def test_openrouter_map_messages_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('anthropic/claude-3.7-sonnet:thinking', provider=provider) From 0b37792248925887f755840bba70d704dae04bd0 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 3 Nov 2025 00:11:31 -0600 Subject: [PATCH 24/42] add stream hooks --- pydantic_ai_slim/pydantic_ai/models/openai.py | 77 ++--- .../pydantic_ai/models/openrouter.py | 97 ++++++- ...test_openrouter_stream_with_reasoning.yaml | 271 ++++++++++++++++++ tests/models/test_openrouter.py | 30 +- 4 files changed, 430 insertions(+), 45 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_stream_with_reasoning.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index dcd2c506b1..b8a4957316 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -7,9 +7,9 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field, replace from datetime import datetime +from itertools import chain from typing import Any, Literal, cast, overload -from openai.types.chat.chat_completion_chunk import Choice from pydantic import ValidationError from pydantic_core import to_json from typing_extensions import assert_never, deprecated @@ -1702,7 +1702,8 @@ class OpenAIStreamedResponse(StreamedResponse): _provider_name: str _provider_url: str - def _handle_thinking_delta(self, choice: Choice): + def _handle_thinking_delta(self, chunk: ChatCompletionChunk): + choice = chunk.choices[0] # The `reasoning_content` field is only present in DeepSeek models. # https://api-docs.deepseek.com/guides/reasoning_model if reasoning_content := getattr(choice.delta, 'reasoning_content', None): @@ -1724,12 +1725,45 @@ def _handle_thinking_delta(self, choice: Choice): provider_name=self.provider_name, ) - def _handle_provider_details(self, choice: Choice) -> dict[str, str] | None: + def _handle_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | None: + choice = chunk.choices[0] if raw_finish_reason := choice.finish_reason: return {'finish_reason': raw_finish_reason} - async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: + def _handle_text_delta(self, chunk: ChatCompletionChunk): + # Handle the text part of the response + content = chunk.choices[0].delta.content + if content: + maybe_event = self._parts_manager.handle_text_delta( + vendor_part_id='content', + content=content, + thinking_tags=self._model_profile.thinking_tags, + ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace, + ) + if maybe_event is not None: # pragma: no branch + if isinstance(maybe_event, PartStartEvent) and isinstance(maybe_event.part, ThinkingPart): + maybe_event.part.id = 'content' + maybe_event.part.provider_name = self.provider_name + yield maybe_event + + def _handle_tool_delta(self, chunk: ChatCompletionChunk): + choice = chunk.choices[0] + for dtc in choice.delta.tool_calls or []: + maybe_event = self._parts_manager.handle_tool_call_delta( + vendor_part_id=dtc.index, + tool_name=dtc.function and dtc.function.name, + args=dtc.function and dtc.function.arguments, + tool_call_id=dtc.id, + ) + if maybe_event is not None: + yield maybe_event + + async def _validate_response(self): async for chunk in self._response: + yield chunk + + async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: + async for chunk in self._validate_response(): self._usage += self._map_usage(chunk) if chunk.id: # pragma: no branch @@ -1750,36 +1784,15 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if raw_finish_reason := choice.finish_reason: self.finish_reason = self._map_finish_reason(raw_finish_reason) - if provider_details := self._handle_provider_details(choice): + if provider_details := self._handle_provider_details(chunk): self.provider_details = provider_details - for thinking_part in self._handle_thinking_delta(choice): - yield thinking_part - - # Handle the text part of the response - content = choice.delta.content - if content: - maybe_event = self._parts_manager.handle_text_delta( - vendor_part_id='content', - content=content, - thinking_tags=self._model_profile.thinking_tags, - ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace, - ) - if maybe_event is not None: # pragma: no branch - if isinstance(maybe_event, PartStartEvent) and isinstance(maybe_event.part, ThinkingPart): - maybe_event.part.id = 'content' - maybe_event.part.provider_name = self.provider_name - yield maybe_event - - for dtc in choice.delta.tool_calls or []: - maybe_event = self._parts_manager.handle_tool_call_delta( - vendor_part_id=dtc.index, - tool_name=dtc.function and dtc.function.name, - args=dtc.function and dtc.function.arguments, - tool_call_id=dtc.id, - ) - if maybe_event is not None: - yield maybe_event + for event in chain( + self._handle_thinking_delta(chunk), + self._handle_text_delta(chunk), + self._handle_tool_delta(chunk), + ): + yield event def _map_usage(self, response: ChatCompletionChunk): return _map_usage(response, self._provider_name, self._provider_url, self._model_name) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index ab9e67c4b9..f933c08ca7 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -3,7 +3,7 @@ from openai import AsyncStream from openai.types import chat -from openai.types.chat.chat_completion import Choice +from openai.types.chat import chat_completion, chat_completion_chunk from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict, assert_never @@ -346,7 +346,7 @@ class OpenRouterCompletionMessage(chat.ChatCompletionMessage): """The reasoning details associated with the message, if any.""" -class OpenRouterChoice(Choice): +class OpenRouterChoice(chat_completion.Choice): """Wraps OpenAI chat completion choice with OpenRouter specific attributes.""" native_finish_reason: str @@ -375,14 +375,40 @@ class OpenRouterChatCompletion(chat.ChatCompletion): """OpenRouter specific error attribute.""" +class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): + """Wrapped chat completion message with OpenRouter specific attributes.""" + + reasoning: str | None = None + """The reasoning text associated with the message, if any.""" + + reasoning_details: list[OpenRouterReasoningDetail] | None = None + """The reasoning details associated with the message, if any.""" + + +class OpenRouterChunkChoice(chat_completion_chunk.Choice): + """Wraps OpenAI chat completion chunk choice with OpenRouter specific attributes.""" + + native_finish_reason: str | None + """The provided finish reason by the downstream provider from OpenRouter.""" + + finish_reason: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] | None # type: ignore[reportIncompatibleVariableOverride] + """OpenRouter specific finish reasons for streaming chunks. + + Notably, removes 'function_call' and adds 'error' finish reasons. + """ + + delta: OpenRouterChoiceDelta # type: ignore[reportIncompatibleVariableOverride] + """A wrapped chat completion delta with OpenRouter specific attributes.""" + + class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): """Wraps OpenAI chat completion with OpenRouter specific attributes.""" provider: str """The downstream provider that was used by OpenRouter.""" - choices: list[OpenRouterChoice] # type: ignore[reportIncompatibleVariableOverride] - """A list of chat completion choices modified with OpenRouter specific attributes.""" + choices: list[OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] + """A list of chat completion chunk choices modified with OpenRouter specific attributes.""" error: OpenRouterError | None = None """OpenRouter specific error attribute.""" @@ -428,6 +454,48 @@ class OpenRouterStreamedResponse(OpenAIStreamedResponse): def _map_usage(self, response: chat.ChatCompletionChunk): return _map_usage(response, self._provider_name, self._provider_url, self._model_name) + @override + def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] + ) -> FinishReason | None: + return _CHAT_FINISH_REASON_MAP.get(key) + + @override + def _handle_thinking_delta(self, chunk: OpenRouterChatCompletionChunk): # type: ignore[reportIncompatibleMethodOverride] + delta = chunk.choices[0].delta + if reasoning_details := delta.reasoning_details: + for detail in reasoning_details: + thinking_part = OpenRouterThinkingPart.from_reasoning_detail(detail) + yield self._parts_manager.handle_thinking_delta( + vendor_part_id='reasoning_detail', + id=thinking_part.id, + content=thinking_part.content, + provider_name=self._provider_name, + ) + + @override + def _handle_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: + native_chunk = OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) + + if provider_details := super()._handle_provider_details(chunk): + if provider := native_chunk.provider: + provider_details['downstream_provider'] = provider + + if native_finish_reason := native_chunk.choices[0].native_finish_reason: + provider_details['native_finish_reason'] = native_finish_reason + + return provider_details + + @override + async def _validate_response(self): + async for chunk in self._response: + chunk = OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) + + if error := chunk.error: + raise ModelHTTPError(status_code=error.code, model_name=chunk.model, body=error.message) + + yield chunk + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -475,6 +543,7 @@ def __init__( """ super().__init__(model_name, provider=provider or OpenRouterProvider(), profile=profile, settings=settings) + @override def prepare_request( self, model_settings: ModelSettings | None, @@ -485,13 +554,13 @@ def prepare_request( return new_settings, customized_parameters @override - def _map_finish_reason( + def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] - ) -> FinishReason | None: # type: ignore[reportIncompatibleMethodOverride] + ) -> FinishReason | None: return _CHAT_FINISH_REASON_MAP.get(key) @override - def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[ThinkingPart]: + def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[ThinkingPart]: # type: ignore[reportIncompatibleMethodOverride] message = response.choices[0].message items: list[ThinkingPart] = [] @@ -502,10 +571,7 @@ def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[Thinkin return items @override - def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[str, Any]: - if error := response.error: - raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) - + def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[str, Any]: # type: ignore[reportIncompatibleMethodOverride] provider_details = super()._process_provider_details(response) provider_details['downstream_provider'] = response.provider @@ -515,8 +581,14 @@ def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[ @override def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatCompletion: - return OpenRouterChatCompletion.model_validate(response.model_dump()) + response = OpenRouterChatCompletion.model_validate(response.model_dump()) + if error := response.error: + raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) + + return response + + @override async def _process_streamed_response( self, response: AsyncStream[chat.ChatCompletionChunk], model_request_parameters: ModelRequestParameters ) -> OpenRouterStreamedResponse: @@ -538,6 +610,7 @@ async def _process_streamed_response( _provider_url=self._provider.base_url, ) + @override def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: texts: list[str] = [] tool_calls: list[chat.ChatCompletionMessageFunctionToolCallParam] = [] diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_reasoning.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_reasoning.yaml new file mode 100644 index 0000000000..fd5bf6c460 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_stream_with_reasoning.yaml @@ -0,0 +1,271 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '128' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Who are you + role: user + model: openai/o3 + stream: true + stream_options: + include_usage: true + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: |+ + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","data":"gAAAAABpCCSIBxdevbk-8QtJuf4C6mgqDwXaUXZGqXqKQRX62aYMAYCg7DbFt3-A3KE_tihu5b36YJmI6393LEIF7lTmdXSxaHgROpGQA6sDVpJHPzcfWvTv774-JhpsbKSxisRYKsPkR5SSsJMULqCQShe_ypqQokHmOw8xHW_9g6-LXfRvv24bGjWMN_Cf994O4EQH4ZtgHwlVweADjseXYi9ShekxcuLRiEHpL4FKcvm5tFmylbSovKuZcu1HTTPMDVcRCgn9D3c56KcHYJGm-WJXuG9DmUO1q97Qj4pSBbSblCBK3qt0V6vbiXRfhmTaOU0gYNXWkmdWyr0UZFj2K5Kq5x9CLdYEdKFW4iMnjeEGIAMcuVvGtQOC406lF6_CnmC-ktPmWRspnoJeBhxGAeviaAyHC7tKO7cTFPOft_sVhWA8xJGy1vfz-cOQh3JcGSjm62MzfFjxh2G2jHixqqyVBopgckt1mDTlr0sU3m7COFWb4hiPNo6fmxiWxiT-umwYdfZngQ4yUkvZiTzZLqhJJzNu90Xl4BNRS_GDJVVrEKgy4kIT-PH1l2iCvf1AHOpefJR1MvIql8jlbrqY8Y7agIpR8XhnHeiCVE_oQg25Zg7x73f6g18rSt_DN3C05tzQCDCvvK4hAIIzxbBoiByErLJGCTOTOH8U_8UJLWSWpo13Cr2reXb7bbVVxtGUacQlTZi01Sz6WHMqKUmACTcNI9EUOwa8nytbSdqifXwkwc0Qpeegu6ibR8Z6P155c0KaKKi-c2iDmug4oTxcwJRWXbP5AUi-cWSC1Mhn9GW7SfqjQRqVdu4N9KcZ4IzLoEyuZtpJTHlCxzcpmQhBiVPxP8BOh8cnrbcn7ebVL2QfMejQS-9MZH_0vHLsbrx75fPHhp9Env9Vfhwx4awImdtIpN0IRYO-qwyp_RI2eGpaHpr8NV7YmkRDd5EwB0ylqO2kTEMPdGhpauQNae4-5CYa4C2_5fi6gU-6KLArCAO3MnvH_40RCGxoPlSxy_t5XX3NubMjy_paiuyTC_fIbkWAtdrd6HsZlDfv_6aZFxe_8C2IPaAuaLRvNSdTsLgzBXHfMPeaccV3c0-fYshNTOEkcvfC5b_v0wXh4sv0rU8rD2Fa3gBVt2QssutrbS0KIv6S4ySa","id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"I"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"’m"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" Chat"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"GPT"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" a"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" large"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"-"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"language"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"-"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"model"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" assistant"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" created"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" by"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" Open"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"AI"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" I"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" generate"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" text"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" responses"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" can"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" help"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" answer"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" questions"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" explain"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" concepts"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" brainstorm"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" ideas"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" draft"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" or"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" edit"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" writing"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" more"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" While"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" I"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" strive"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" be"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" accurate"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" helpful"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" I"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" don"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"’t"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" have"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" personal"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" feelings"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" or"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" consciousness"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" my"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" knowledge"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" limited"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" information"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" I"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" was"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" trained"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" on"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" ("},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"most"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" of"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" it"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" up"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" late"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" "},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"202"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"3"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":")."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" If"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" there"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"’s"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" something"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" specific"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" you"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"’d"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" like"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" help"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" with"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":","},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" just"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" let"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" me"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":" know"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":"!"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"completed","logprobs":null}]} + + data: {"id":"gen-1762141316-q3fB64DDMstJO0ZakdSK","provider":"OpenAI","model":"openai/o3","object":"chat.completion.chunk","created":1762141317,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":9,"completion_tokens":104,"total_tokens":113,"cost":0.00085,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.000018,"upstream_inference_completions_cost":0.000832},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}} + + data: [DONE] + + headers: + access-control-allow-origin: + - '*' + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + 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 + status: + code: 200 + message: OK +version: 1 +... diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 1aab88ed4e..c0157b6ccb 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -10,6 +10,8 @@ ModelHTTPError, ModelMessage, ModelRequest, + PartEndEvent, + PartStartEvent, TextPart, ThinkingPart, ToolCallPart, @@ -91,10 +93,36 @@ async def test_openrouter_stream_with_native_options(allow_model_requests: None, _ = [chunk async for chunk in stream] - assert stream.provider_details == snapshot({'finish_reason': 'stop'}) + assert stream.provider_details == snapshot( + {'finish_reason': 'stop', 'downstream_provider': 'xAI', 'native_finish_reason': 'completed'} + ) assert stream.finish_reason == snapshot('stop') +async def test_openrouter_stream_with_reasoning(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('openai/o3', provider=provider) + + async with model_request_stream(model, [ModelRequest.user_text_prompt('Who are you')]) as stream: + chunks = [chunk async for chunk in stream] + + thinking_event_start = chunks[0] + assert isinstance(thinking_event_start, PartStartEvent) + assert thinking_event_start.part == snapshot( + ThinkingPart( + content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter' + ) + ) + + thinking_event_end = chunks[1] + assert isinstance(thinking_event_end, PartEndEvent) + assert thinking_event_end.part == snapshot( + ThinkingPart( + content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter' + ) + ) + + async def test_openrouter_tool_calling(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) From 8d090f01b3d3587482514848407adc7861f7120c Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 3 Nov 2025 10:22:48 -0600 Subject: [PATCH 25/42] simplify hooks --- pydantic_ai_slim/pydantic_ai/models/openai.py | 106 +++--- .../pydantic_ai/models/openrouter.py | 319 +++++++++--------- .../test_openrouter_stream_error.yaml | 96 ++++++ tests/models/test_openrouter.py | 12 + 4 files changed, 334 insertions(+), 199 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_stream_error.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index b8a4957316..bd60db2c93 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -7,7 +7,6 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field, replace from datetime import datetime -from itertools import chain from typing import Any, Literal, cast, overload from pydantic import ValidationError @@ -650,7 +649,7 @@ async def _process_streamed_response( # so we set it from a later chunk in `OpenAIChatStreamedResponse`. model_name = first_chunk.model or self._model_name - return OpenAIStreamedResponse( + return self._streamed_response_cls( model_request_parameters=model_request_parameters, _model_name=model_name, _model_profile=self.profile, @@ -660,6 +659,10 @@ async def _process_streamed_response( _provider_url=self._provider.base_url, ) + @property + def _streamed_response_cls(self): + return OpenAIStreamedResponse + def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[chat.ChatCompletionToolParam]: return [self._map_tool_definition(r) for r in model_request_parameters.tool_defs.values()] @@ -687,6 +690,10 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete ) def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: + """Hook that determines how `ModelResponse` is mapped into `ChatCompletionMessageParam` objects before sending. + + Subclasses of `OpenAIChatModel` should override this method to provide their own mapping logic. + """ texts: list[str] = [] tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [] for item in message.parts: @@ -1702,7 +1709,49 @@ class OpenAIStreamedResponse(StreamedResponse): _provider_name: str _provider_url: str - def _handle_thinking_delta(self, chunk: ChatCompletionChunk): + async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: + async for chunk in self._validate_response(): + self._usage += self._map_usage(chunk) + + if chunk.id: # pragma: no branch + self.provider_response_id = chunk.id + + if chunk.model: + self._model_name = chunk.model + + try: + choice = chunk.choices[0] + except IndexError: + continue + + # When using Azure OpenAI and an async content filter is enabled, the openai SDK can return None deltas. + if choice.delta is None: # pyright: ignore[reportUnnecessaryComparison] + continue + + if raw_finish_reason := choice.finish_reason: + self.finish_reason = self._map_finish_reason(raw_finish_reason) + + if provider_details := self._map_provider_details(chunk): + self.provider_details = provider_details + + for event in self._map_part_delta(chunk): + yield event + + async def _validate_response(self): + """Hook that validates incoming chunks. + + This method should be overridden by subclasses of `OpenAIStreamedResponse` to apply custom chunk validations. + + By default, this is a no-op since `ChatCompletionChunk` is already validated. + """ + async for chunk in self._response: + yield chunk + + def _map_part_delta(self, chunk: ChatCompletionChunk): + """Hook that maps delta content to events. + + This method should be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. + """ choice = chunk.choices[0] # The `reasoning_content` field is only present in DeepSeek models. # https://api-docs.deepseek.com/guides/reasoning_model @@ -1725,14 +1774,8 @@ def _handle_thinking_delta(self, chunk: ChatCompletionChunk): provider_name=self.provider_name, ) - def _handle_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | None: - choice = chunk.choices[0] - if raw_finish_reason := choice.finish_reason: - return {'finish_reason': raw_finish_reason} - - def _handle_text_delta(self, chunk: ChatCompletionChunk): # Handle the text part of the response - content = chunk.choices[0].delta.content + content = choice.delta.content if content: maybe_event = self._parts_manager.handle_text_delta( vendor_part_id='content', @@ -1746,8 +1789,6 @@ def _handle_text_delta(self, chunk: ChatCompletionChunk): maybe_event.part.provider_name = self.provider_name yield maybe_event - def _handle_tool_delta(self, chunk: ChatCompletionChunk): - choice = chunk.choices[0] for dtc in choice.delta.tool_calls or []: maybe_event = self._parts_manager.handle_tool_call_delta( vendor_part_id=dtc.index, @@ -1758,41 +1799,14 @@ def _handle_tool_delta(self, chunk: ChatCompletionChunk): if maybe_event is not None: yield maybe_event - async def _validate_response(self): - async for chunk in self._response: - yield chunk - - async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: - async for chunk in self._validate_response(): - self._usage += self._map_usage(chunk) - - if chunk.id: # pragma: no branch - self.provider_response_id = chunk.id - - if chunk.model: - self._model_name = chunk.model - - try: - choice = chunk.choices[0] - except IndexError: - continue - - # When using Azure OpenAI and an async content filter is enabled, the openai SDK can return None deltas. - if choice.delta is None: # pyright: ignore[reportUnnecessaryComparison] - continue + def _map_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | None: + """Hook that generates the provider details from chunk content. - if raw_finish_reason := choice.finish_reason: - self.finish_reason = self._map_finish_reason(raw_finish_reason) - - if provider_details := self._handle_provider_details(chunk): - self.provider_details = provider_details - - for event in chain( - self._handle_thinking_delta(chunk), - self._handle_text_delta(chunk), - self._handle_tool_delta(chunk), - ): - yield event + This method should be overridden by subclasses of `OpenAIStreamResponse` to customize the provider details. + """ + choice = chunk.choices[0] + if raw_finish_reason := choice.finish_reason: + return {'finish_reason': raw_finish_reason} def _map_usage(self, response: ChatCompletionChunk): return _map_usage(response, self._provider_name, self._provider_url, self._model_name) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index f933c08ca7..c83798dccd 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,20 +1,21 @@ from dataclasses import asdict, dataclass from typing import Any, Literal, cast, override -from openai import AsyncStream +from openai import APIError from openai.types import chat from openai.types.chat import chat_completion, chat_completion_chunk from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict, assert_never from .. import _utils -from ..exceptions import ModelHTTPError, UnexpectedModelBehavior +from ..exceptions import ModelHTTPError from ..messages import ( BuiltinToolCallPart, BuiltinToolReturnPart, FilePart, FinishReason, ModelResponse, + PartStartEvent, TextPart, ThinkingPart, ToolCallPart, @@ -112,8 +113,8 @@ class OpenRouterMaxPrice(TypedDict, total=False): ] """Known providers in the OpenRouter marketplace""" -OpenRouterMarketplaceProvider = str | KnownOpenRouterProviders -"""Possible OpenRouter provider slugs. +OpenRouterProviderName = str | KnownOpenRouterProviders +"""Possible OpenRouter provider names. Since OpenRouter is constantly updating their list of providers, we explicitly list some known providers but allow any name in the type hints. @@ -130,7 +131,7 @@ class OpenRouterMaxPrice(TypedDict, total=False): class OpenRouterProviderConfig(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" - order: list[OpenRouterMarketplaceProvider] + order: list[OpenRouterProviderName] """List of provider slugs to try in order (e.g. ["anthropic", "openai"]). [See details](https://openrouter.ai/docs/features/provider-routing#ordering-specific-providers)""" allow_fallbacks: bool @@ -145,7 +146,7 @@ class OpenRouterProviderConfig(TypedDict, total=False): zdr: bool """Restrict routing to only ZDR (Zero Data Retention) endpoints. [See details](https://openrouter.ai/docs/features/provider-routing#zero-data-retention-enforcement)""" - only: list[OpenRouterMarketplaceProvider] + only: list[OpenRouterProviderName] """List of provider slugs to allow for this request. [See details](https://openrouter.ai/docs/features/provider-routing#allowing-only-specific-providers)""" ignore: list[str] @@ -259,7 +260,7 @@ class BaseReasoningDetail(BaseModel): """Common fields shared across all reasoning detail types.""" id: str | None = None - format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] + format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None index: int | None @@ -295,7 +296,7 @@ class OpenRouterThinkingPart(ThinkingPart): type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text'] index: int | None - format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] + format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None __repr__ = _utils.dataclasses_no_defaults_repr @@ -375,43 +376,29 @@ class OpenRouterChatCompletion(chat.ChatCompletion): """OpenRouter specific error attribute.""" -class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): - """Wrapped chat completion message with OpenRouter specific attributes.""" - - reasoning: str | None = None - """The reasoning text associated with the message, if any.""" - - reasoning_details: list[OpenRouterReasoningDetail] | None = None - """The reasoning details associated with the message, if any.""" - - -class OpenRouterChunkChoice(chat_completion_chunk.Choice): - """Wraps OpenAI chat completion chunk choice with OpenRouter specific attributes.""" - - native_finish_reason: str | None - """The provided finish reason by the downstream provider from OpenRouter.""" +def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: + """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. - finish_reason: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] | None # type: ignore[reportIncompatibleVariableOverride] - """OpenRouter specific finish reasons for streaming chunks. + Args: + model_settings: The 'OpenRouterModelSettings' object to transform. - Notably, removes 'function_call' and adds 'error' finish reasons. + Returns: + An 'OpenAIChatModelSettings' object with equivalent settings. """ + extra_body = cast(dict[str, Any], model_settings.get('extra_body', {})) - delta: OpenRouterChoiceDelta # type: ignore[reportIncompatibleVariableOverride] - """A wrapped chat completion delta with OpenRouter specific attributes.""" - - -class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): - """Wraps OpenAI chat completion with OpenRouter specific attributes.""" - - provider: str - """The downstream provider that was used by OpenRouter.""" + if models := model_settings.pop('openrouter_models', None): + extra_body['models'] = models + if provider := model_settings.pop('openrouter_provider', None): + extra_body['provider'] = provider + if preset := model_settings.pop('openrouter_preset', None): + extra_body['preset'] = preset + if transforms := model_settings.pop('openrouter_transforms', None): + extra_body['transforms'] = transforms - choices: list[OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] - """A list of chat completion chunk choices modified with OpenRouter specific attributes.""" + model_settings['extra_body'] = extra_body - error: OpenRouterError | None = None - """OpenRouter specific error attribute.""" + return OpenAIChatModelSettings(**model_settings) # type: ignore[reportCallIssue] def _map_usage( @@ -433,7 +420,7 @@ def _map_usage( } response_data = dict(model=model, usage=usage_data) - if response_usage.completion_tokens_details is not None: + if response_usage.completion_tokens_details is not None: # pragma: lax no cover details.update(response_usage.completion_tokens_details.model_dump(exclude_none=True)) return RequestUsage.extract( @@ -446,82 +433,6 @@ def _map_usage( ) -@dataclass -class OpenRouterStreamedResponse(OpenAIStreamedResponse): - """Implementation of `StreamedResponse` for OpenAI models.""" - - @override - def _map_usage(self, response: chat.ChatCompletionChunk): - return _map_usage(response, self._provider_name, self._provider_url, self._model_name) - - @override - def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] - self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] - ) -> FinishReason | None: - return _CHAT_FINISH_REASON_MAP.get(key) - - @override - def _handle_thinking_delta(self, chunk: OpenRouterChatCompletionChunk): # type: ignore[reportIncompatibleMethodOverride] - delta = chunk.choices[0].delta - if reasoning_details := delta.reasoning_details: - for detail in reasoning_details: - thinking_part = OpenRouterThinkingPart.from_reasoning_detail(detail) - yield self._parts_manager.handle_thinking_delta( - vendor_part_id='reasoning_detail', - id=thinking_part.id, - content=thinking_part.content, - provider_name=self._provider_name, - ) - - @override - def _handle_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: - native_chunk = OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) - - if provider_details := super()._handle_provider_details(chunk): - if provider := native_chunk.provider: - provider_details['downstream_provider'] = provider - - if native_finish_reason := native_chunk.choices[0].native_finish_reason: - provider_details['native_finish_reason'] = native_finish_reason - - return provider_details - - @override - async def _validate_response(self): - async for chunk in self._response: - chunk = OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) - - if error := chunk.error: - raise ModelHTTPError(status_code=error.code, model_name=chunk.model, body=error.message) - - yield chunk - - -def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: - """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. - - Args: - model_settings: The 'OpenRouterModelSettings' object to transform. - - Returns: - An 'OpenAIChatModelSettings' object with equivalent settings. - """ - extra_body = cast(dict[str, Any], model_settings.get('extra_body', {})) - - if models := model_settings.pop('openrouter_models', None): - extra_body['models'] = models - if provider := model_settings.pop('openrouter_provider', None): - extra_body['provider'] = provider - if preset := model_settings.pop('openrouter_preset', None): - extra_body['preset'] = preset - if transforms := model_settings.pop('openrouter_transforms', None): - extra_body['transforms'] = transforms - - model_settings['extra_body'] = extra_body - - return OpenAIChatModelSettings(**model_settings) # type: ignore[reportCallIssue] - - class OpenRouterModel(OpenAIChatModel): """Extends OpenAIModel to capture extra metadata for Openrouter.""" @@ -554,13 +465,19 @@ def prepare_request( return new_settings, customized_parameters @override - def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] - self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] - ) -> FinishReason | None: - return _CHAT_FINISH_REASON_MAP.get(key) + def _validate_completion(self, response: chat.ChatCompletion) -> OpenRouterChatCompletion: + response = OpenRouterChatCompletion.model_validate(response.model_dump()) + + if error := response.error: + raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) + + return response @override - def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[ThinkingPart]: # type: ignore[reportIncompatibleMethodOverride] + def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]: + # We can cast with confidence because response was validated in `_validate_completion` + response = cast(OpenRouterChatCompletion, response) + message = response.choices[0].message items: list[ThinkingPart] = [] @@ -571,7 +488,9 @@ def _process_reasoning(self, response: OpenRouterChatCompletion) -> list[Thinkin return items @override - def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[str, Any]: # type: ignore[reportIncompatibleMethodOverride] + def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: + response = cast(OpenRouterChatCompletion, response) + provider_details = super()._process_provider_details(response) provider_details['downstream_provider'] = response.provider @@ -579,37 +498,6 @@ def _process_provider_details(self, response: OpenRouterChatCompletion) -> dict[ return provider_details - @override - def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatCompletion: - response = OpenRouterChatCompletion.model_validate(response.model_dump()) - - if error := response.error: - raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) - - return response - - @override - async def _process_streamed_response( - self, response: AsyncStream[chat.ChatCompletionChunk], model_request_parameters: ModelRequestParameters - ) -> OpenRouterStreamedResponse: - """Process a streamed response, and prepare a streaming response to return.""" - peekable_response = _utils.PeekableAsyncStream(response) - first_chunk = await peekable_response.peek() - if isinstance(first_chunk, _utils.Unset): - raise UnexpectedModelBehavior( # pragma: no cover - 'Streamed response ended without content or tool calls' - ) - - return OpenRouterStreamedResponse( - model_request_parameters=model_request_parameters, - _model_name=self._model_name, - _model_profile=self.profile, - _response=peekable_response, - _timestamp=_utils.number_to_datetime(first_chunk.created), - _provider_name=self._provider.name, - _provider_url=self._provider.base_url, - ) - @override def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: texts: list[str] = [] @@ -641,3 +529,128 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess if reasoning_details: message_param['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] return message_param + + @property + @override + def _streamed_response_cls(self): + return OpenRouterStreamedResponse + + @override + def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] + ) -> FinishReason | None: + return _CHAT_FINISH_REASON_MAP.get(key) + + +class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): + """Wrapped chat completion message with OpenRouter specific attributes.""" + + reasoning: str | None = None + """The reasoning text associated with the message, if any.""" + + reasoning_details: list[OpenRouterReasoningDetail] | None = None + """The reasoning details associated with the message, if any.""" + + +class OpenRouterChunkChoice(chat_completion_chunk.Choice): + """Wraps OpenAI chat completion chunk choice with OpenRouter specific attributes.""" + + native_finish_reason: str | None + """The provided finish reason by the downstream provider from OpenRouter.""" + + finish_reason: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] | None # type: ignore[reportIncompatibleVariableOverride] + """OpenRouter specific finish reasons for streaming chunks. + + Notably, removes 'function_call' and adds 'error' finish reasons. + """ + + delta: OpenRouterChoiceDelta # type: ignore[reportIncompatibleVariableOverride] + """A wrapped chat completion delta with OpenRouter specific attributes.""" + + +class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): + """Wraps OpenAI chat completion with OpenRouter specific attributes.""" + + provider: str + """The downstream provider that was used by OpenRouter.""" + + choices: list[OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] + """A list of chat completion chunk choices modified with OpenRouter specific attributes.""" + + +@dataclass +class OpenRouterStreamedResponse(OpenAIStreamedResponse): + """Implementation of `StreamedResponse` for OpenRouter models.""" + + @override + async def _validate_response(self): + try: + async for chunk in self._response: + yield OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) + except APIError as e: + error = OpenRouterError.model_validate(e.body) + raise ModelHTTPError(status_code=error.code, model_name=self._model_name, body=error.message) + + @override + def _map_part_delta(self, chunk: chat.ChatCompletionChunk): + # We can cast with confidence because chunk was validated in `_validate_response` + chunk = cast(OpenRouterChatCompletionChunk, chunk) + choice = chunk.choices[0] + + if reasoning_details := choice.delta.reasoning_details: + for detail in reasoning_details: + thinking_part = OpenRouterThinkingPart.from_reasoning_detail(detail) + yield self._parts_manager.handle_thinking_delta( + vendor_part_id='reasoning_detail', + id=thinking_part.id, + content=thinking_part.content, + provider_name=self._provider_name, + ) + + # Handle the text part of the response + content = choice.delta.content + if content: + maybe_event = self._parts_manager.handle_text_delta( + vendor_part_id='content', + content=content, + thinking_tags=self._model_profile.thinking_tags, + ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace, + ) + if maybe_event is not None: # pragma: no branch + if isinstance(maybe_event, PartStartEvent) and isinstance(maybe_event.part, ThinkingPart): + maybe_event.part.id = 'content' + maybe_event.part.provider_name = self.provider_name + yield maybe_event + + for dtc in choice.delta.tool_calls or []: + maybe_event = self._parts_manager.handle_tool_call_delta( + vendor_part_id=dtc.index, + tool_name=dtc.function and dtc.function.name, + args=dtc.function and dtc.function.arguments, + tool_call_id=dtc.id, + ) + if maybe_event is not None: + yield maybe_event + + @override + def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: + chunk = cast(OpenRouterChatCompletionChunk, chunk) + + if provider_details := super()._map_provider_details(chunk): + if provider := chunk.provider: # pragma: lax no cover + provider_details['downstream_provider'] = provider + + if native_finish_reason := chunk.choices[0].native_finish_reason: # pragma: lax no cover + provider_details['native_finish_reason'] = native_finish_reason + + return provider_details + + @override + def _map_usage(self, response: chat.ChatCompletionChunk): + return _map_usage(response, self._provider_name, self._provider_url, self._model_name) + + @override + def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] + self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] + ) -> FinishReason | None: + return _CHAT_FINISH_REASON_MAP.get(key) diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_stream_error.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_stream_error.yaml new file mode 100644 index 0000000000..d7c70aac89 --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_stream_error.yaml @@ -0,0 +1,96 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '169' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + max_completion_tokens: 10 + messages: + - content: Hello there + role: user + model: minimax/minimax-m2:free + stream: true + stream_options: + include_usage: true + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: |+ + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + : OPENROUTER PROCESSING + + data: {"id":"gen-1762179802-UN8pkJI4AGZvryk0kFnb","provider":"Minimax","model":"minimax/minimax-m2:free","object":"chat.completion.chunk","created":1762179802,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"We need","reasoning_details":[{"type":"reasoning.text","text":"We need","index":0,"format":null}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + + data: {"id":"gen-1762179802-UN8pkJI4AGZvryk0kFnb","provider":"Minimax","model":"minimax/minimax-m2:free","object":"chat.completion.chunk","created":1762179802,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" to respond to a greeting. The user","reasoning_details":[{"type":"reasoning.text","text":" to respond to a greeting. The user","index":0,"format":null}]},"finish_reason":"length","native_finish_reason":"length","logprobs":null}]} + + data: {"id":"gen-1762179802-UN8pkJI4AGZvryk0kFnb","provider":"Minimax","model":"minimax/minimax-m2:free","object":"chat.completion.chunk","created":1762179802,"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[]},"finish_reason":"length","native_finish_reason":"length","logprobs":null}]} + + data: {"id":"gen-1762179802-UN8pkJI4AGZvryk0kFnb","provider":"Minimax","error":{"code":400,"message":"Token limit reached"},"model":"minimax/minimax-m2:free","object":"chat.completion.chunk","created":1762179802,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":43,"completion_tokens":10,"total_tokens":53,"cost":0,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0,"upstream_inference_completions_cost":0},"completion_tokens_details":{"reasoning_tokens":11,"image_tokens":0}}} + + data: [DONE] + + headers: + access-control-allow-origin: + - '*' + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + 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 + status: + code: 200 + message: OK +version: 1 +... diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index c0157b6ccb..ee8bc3c5a1 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -123,6 +123,18 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open ) +async def test_openrouter_stream_error(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('minimax/minimax-m2:free', provider=provider) + settings = OpenRouterModelSettings(max_tokens=10) + + with pytest.raises(ModelHTTPError): + async with model_request_stream( + model, [ModelRequest.user_text_prompt('Hello there')], model_settings=settings + ) as stream: + _ = [chunk async for chunk in stream] + + async def test_openrouter_tool_calling(allow_model_requests: None, openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) From e8c3c8164b4fc6ed24ed86fa61c3d7cbb0cc5c90 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 3 Nov 2025 14:51:35 -0600 Subject: [PATCH 26/42] fix coverage/linting --- pydantic_ai_slim/pydantic_ai/models/openai.py | 43 ++++++++++++++-- .../pydantic_ai/models/openrouter.py | 51 ++++++------------- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index bd60db2c93..59376bf5c7 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import base64 +import itertools import json import warnings from collections.abc import AsyncIterable, AsyncIterator, Sequence @@ -62,6 +63,7 @@ ChatCompletionContentPartParam, ChatCompletionContentPartTextParam, ) + from openai.types.chat.chat_completion_chunk import Choice from openai.types.chat.chat_completion_content_part_image_param import ImageURL from openai.types.chat.chat_completion_content_part_input_audio_param import InputAudio from openai.types.chat.chat_completion_content_part_param import File, FileFile @@ -530,9 +532,17 @@ async def _completions_create( raise # pragma: lax no cover def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatCompletion: + """Hook that validates chat completions before processing. + + This method may be overridden by subclasses of `OpenAIChatModel` to apply custom completion validations. + """ return chat.ChatCompletion.model_validate(response.model_dump()) def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]: + """Hook that maps reasoning tokens to thinking parts. + + This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings. + """ message = response.choices[0].message items: list[ThinkingPart] = [] @@ -550,6 +560,10 @@ def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart return items def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: + """Hook that response content to provider details. + + This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings. + """ choice = response.choices[0] provider_details: dict[str, Any] = {} @@ -692,7 +706,7 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: """Hook that determines how `ModelResponse` is mapped into `ChatCompletionMessageParam` objects before sending. - Subclasses of `OpenAIChatModel` should override this method to provide their own mapping logic. + Subclasses of `OpenAIChatModel` may override this method to provide their own mapping logic. """ texts: list[str] = [] tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [] @@ -1740,7 +1754,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: async def _validate_response(self): """Hook that validates incoming chunks. - This method should be overridden by subclasses of `OpenAIStreamedResponse` to apply custom chunk validations. + This method may be overridden by subclasses of `OpenAIStreamedResponse` to apply custom chunk validations. By default, this is a no-op since `ChatCompletionChunk` is already validated. """ @@ -1748,11 +1762,20 @@ async def _validate_response(self): yield chunk def _map_part_delta(self, chunk: ChatCompletionChunk): - """Hook that maps delta content to events. + """Hook that determines the sequence of mappings that will be called to produce events. - This method should be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. + This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. """ choice = chunk.choices[0] + return itertools.chain( + self._map_thinking_delta(choice), self._map_text_delta(choice), self._map_tool_call_delta(choice) + ) + + def _map_thinking_delta(self, choice: Choice): + """Hook that maps thinking delta content to events. + + This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. + """ # The `reasoning_content` field is only present in DeepSeek models. # https://api-docs.deepseek.com/guides/reasoning_model if reasoning_content := getattr(choice.delta, 'reasoning_content', None): @@ -1774,6 +1797,11 @@ def _map_part_delta(self, chunk: ChatCompletionChunk): provider_name=self.provider_name, ) + def _map_text_delta(self, choice: Choice): + """Hook that maps text delta content to events. + + This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. + """ # Handle the text part of the response content = choice.delta.content if content: @@ -1789,6 +1817,11 @@ def _map_part_delta(self, chunk: ChatCompletionChunk): maybe_event.part.provider_name = self.provider_name yield maybe_event + def _map_tool_call_delta(self, choice: Choice): + """Hook that maps tool call delta content to events. + + This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. + """ for dtc in choice.delta.tool_calls or []: maybe_event = self._parts_manager.handle_tool_call_delta( vendor_part_id=dtc.index, @@ -1802,7 +1835,7 @@ def _map_part_delta(self, chunk: ChatCompletionChunk): def _map_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | None: """Hook that generates the provider details from chunk content. - This method should be overridden by subclasses of `OpenAIStreamResponse` to customize the provider details. + This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the provider details. """ choice = chunk.choices[0] if raw_finish_reason := choice.finish_reason: diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index c83798dccd..4b9b43c687 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,11 +1,8 @@ from dataclasses import asdict, dataclass -from typing import Any, Literal, cast, override +from typing import Any, Literal, cast -from openai import APIError -from openai.types import chat -from openai.types.chat import chat_completion, chat_completion_chunk from pydantic import AliasChoices, BaseModel, Field, TypeAdapter -from typing_extensions import TypedDict, assert_never +from typing_extensions import TypedDict, assert_never, override from .. import _utils from ..exceptions import ModelHTTPError @@ -15,7 +12,6 @@ FilePart, FinishReason, ModelResponse, - PartStartEvent, TextPart, ThinkingPart, ToolCallPart, @@ -25,7 +21,18 @@ from ..settings import ModelSettings from ..usage import RequestUsage from . import ModelRequestParameters -from .openai import OpenAIChatModel, OpenAIChatModelSettings, OpenAIStreamedResponse + +try: + from openai import APIError + from openai.types import chat + from openai.types.chat import chat_completion, chat_completion_chunk + + from .openai import OpenAIChatModel, OpenAIChatModelSettings, OpenAIStreamedResponse +except ImportError as _import_error: + raise ImportError( + 'Please install `openai` to use the OpenRouter model, ' + 'you can use the `openai` optional group — `pip install "pydantic-ai-slim[openai]"`' + ) from _import_error _CHAT_FINISH_REASON_MAP: dict[Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'], FinishReason] = { 'stop': 'stop', @@ -592,10 +599,9 @@ async def _validate_response(self): raise ModelHTTPError(status_code=error.code, model_name=self._model_name, body=error.message) @override - def _map_part_delta(self, chunk: chat.ChatCompletionChunk): + def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): # We can cast with confidence because chunk was validated in `_validate_response` - chunk = cast(OpenRouterChatCompletionChunk, chunk) - choice = chunk.choices[0] + choice = cast(OpenRouterChunkChoice, choice) if reasoning_details := choice.delta.reasoning_details: for detail in reasoning_details: @@ -607,31 +613,6 @@ def _map_part_delta(self, chunk: chat.ChatCompletionChunk): provider_name=self._provider_name, ) - # Handle the text part of the response - content = choice.delta.content - if content: - maybe_event = self._parts_manager.handle_text_delta( - vendor_part_id='content', - content=content, - thinking_tags=self._model_profile.thinking_tags, - ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace, - ) - if maybe_event is not None: # pragma: no branch - if isinstance(maybe_event, PartStartEvent) and isinstance(maybe_event.part, ThinkingPart): - maybe_event.part.id = 'content' - maybe_event.part.provider_name = self.provider_name - yield maybe_event - - for dtc in choice.delta.tool_calls or []: - maybe_event = self._parts_manager.handle_tool_call_delta( - vendor_part_id=dtc.index, - tool_name=dtc.function and dtc.function.name, - args=dtc.function and dtc.function.arguments, - tool_call_id=dtc.id, - ) - if maybe_event is not None: - yield maybe_event - @override def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: chunk = cast(OpenRouterChatCompletionChunk, chunk) From 7c50f0706c6d3ca619d93e7caa4a5531a24d10ac Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 3 Nov 2025 15:21:22 -0600 Subject: [PATCH 27/42] fix lint --- pydantic_ai_slim/pydantic_ai/providers/openrouter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py index 4461fa1c46..0e55229556 100644 --- a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py @@ -86,7 +86,7 @@ def __init__(self, *, api_key: str, http_referer: str, x_title: str) -> None: .. @overload def __init__(self, *, api_key: str, http_referer: str, x_title: str, http_client: httpx.AsyncClient) -> None: ... - + @overload def __init__(self, *, http_client: httpx.AsyncClient) -> None: ... From 8e32475ab2d366e1ebe8b722fb2262c0b0990810 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Tue, 4 Nov 2025 07:49:40 -0600 Subject: [PATCH 28/42] replace OpenRouterThinking with encoding in 'id' --- pydantic_ai_slim/pydantic_ai/models/openai.py | 62 +++++---- .../pydantic_ai/models/openrouter.py | 124 ++++++++---------- tests/models/test_openrouter.py | 10 +- 3 files changed, 96 insertions(+), 100 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index c827585adc..6b131474c0 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -538,27 +538,6 @@ def _validate_completion(self, response: chat.ChatCompletion) -> chat.ChatComple """ return chat.ChatCompletion.model_validate(response.model_dump()) - def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]: - """Hook that maps reasoning tokens to thinking parts. - - This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings. - """ - message = response.choices[0].message - items: list[ThinkingPart] = [] - - # The `reasoning_content` field is only present in DeepSeek models. - # https://api-docs.deepseek.com/guides/reasoning_model - if reasoning_content := getattr(message, 'reasoning_content', None): - items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system)) - - # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. - # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api - # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens - if reasoning := getattr(message, 'reasoning', None): - items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) - - return items - def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: """Hook that response content to provider details. @@ -616,7 +595,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons choice = response.choices[0] items: list[ModelResponsePart] = [] - if thinking_parts := self._process_reasoning(response): + if thinking_parts := self._process_thinking(choice.message): items.extend(thinking_parts) if choice.message.content: @@ -648,6 +627,26 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons finish_reason=self._map_finish_reason(choice.finish_reason), ) + def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[ThinkingPart] | None: + """Hook that maps reasoning tokens to thinking parts. + + This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings. + """ + items: list[ThinkingPart] = [] + + # The `reasoning_content` field is only present in DeepSeek models. + # https://api-docs.deepseek.com/guides/reasoning_model + if reasoning_content := getattr(message, 'reasoning_content', None): + items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system)) + + # The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. + # - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api + # - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens + if reasoning := getattr(message, 'reasoning', None): + items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) + + return items + async def _process_streamed_response( self, response: AsyncStream[ChatCompletionChunk], model_request_parameters: ModelRequestParameters ) -> OpenAIStreamedResponse: @@ -674,7 +673,11 @@ async def _process_streamed_response( ) @property - def _streamed_response_cls(self): + def _streamed_response_cls(self) -> type[OpenAIStreamedResponse]: + """Returns the `StreamedResponse` type that will be used for streamed responses. + + This method may be overridden by subclasses of `OpenAIChatModel` to provide their own `StreamedResponse` type. + """ return OpenAIStreamedResponse def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[chat.ChatCompletionToolParam]: @@ -743,6 +746,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess def _map_finish_reason( self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] ) -> FinishReason | None: + """Hooks that maps a finish reason key to a [FinishReason](pydantic_ai.messages.FinishReason). + + This method may be overridden by subclasses of `OpenAIChatModel` to accommodate custom keys. + """ return _CHAT_FINISH_REASON_MAP.get(key) async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCompletionMessageParam]: @@ -1752,7 +1759,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if provider_details := self._map_provider_details(chunk): self.provider_details = provider_details - for event in self._map_part_delta(chunk): + for event in self._map_part_delta(choice): yield event async def _validate_response(self): @@ -1765,12 +1772,11 @@ async def _validate_response(self): async for chunk in self._response: yield chunk - def _map_part_delta(self, chunk: ChatCompletionChunk): + def _map_part_delta(self, choice: Choice): """Hook that determines the sequence of mappings that will be called to produce events. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. """ - choice = chunk.choices[0] return itertools.chain( self._map_thinking_delta(choice), self._map_text_delta(choice), self._map_tool_call_delta(choice) ) @@ -1851,6 +1857,10 @@ def _map_usage(self, response: ChatCompletionChunk): def _map_finish_reason( self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] ) -> FinishReason | None: + """Hooks that maps a finish reason key to a [FinishReason](pydantic_ai.messages.FinishReason). + + This method may be overridden by subclasses of `OpenAIChatModel` to accommodate custom keys. + """ return _CHAT_FINISH_REASON_MAP.get(key) @property diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 4b9b43c687..fefe35c052 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,11 +1,11 @@ +import json from dataclasses import asdict, dataclass from typing import Any, Literal, cast from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict, assert_never, override -from .. import _utils -from ..exceptions import ModelHTTPError +from ..exceptions import ModelHTTPError, UnexpectedModelBehavior from ..messages import ( BuiltinToolCallPart, BuiltinToolReturnPart, @@ -263,7 +263,7 @@ class OpenRouterError(BaseModel): message: str -class BaseReasoningDetail(BaseModel): +class _BaseReasoningDetail(BaseModel): """Common fields shared across all reasoning detail types.""" id: str | None = None @@ -271,21 +271,21 @@ class BaseReasoningDetail(BaseModel): index: int | None -class ReasoningSummary(BaseReasoningDetail): +class _ReasoningSummary(_BaseReasoningDetail): """Represents a high-level summary of the reasoning process.""" type: Literal['reasoning.summary'] summary: str = Field(validation_alias=AliasChoices('summary', 'content')) -class ReasoningEncrypted(BaseReasoningDetail): +class _ReasoningEncrypted(_BaseReasoningDetail): """Represents encrypted reasoning data.""" type: Literal['reasoning.encrypted'] data: str = Field(validation_alias=AliasChoices('data', 'signature')) -class ReasoningText(BaseReasoningDetail): +class _ReasoningText(_BaseReasoningDetail): """Represents raw text reasoning.""" type: Literal['reasoning.text'] @@ -293,55 +293,42 @@ class ReasoningText(BaseReasoningDetail): signature: str | None = None -OpenRouterReasoningDetail = ReasoningSummary | ReasoningEncrypted | ReasoningText -_reasoning_detail_adapter: TypeAdapter[OpenRouterReasoningDetail] = TypeAdapter(OpenRouterReasoningDetail) - - -@dataclass(repr=False) -class OpenRouterThinkingPart(ThinkingPart): - """A special ThinkingPart that includes reasoning attributes specific to OpenRouter.""" - - type: Literal['reasoning.summary', 'reasoning.encrypted', 'reasoning.text'] - index: int | None - format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None - - __repr__ = _utils.dataclasses_no_defaults_repr - - @classmethod - def from_reasoning_detail(cls, reasoning: OpenRouterReasoningDetail): - provider_name = 'openrouter' - if isinstance(reasoning, ReasoningText): - return cls( - id=reasoning.id, - content=reasoning.text, - signature=reasoning.signature, - provider_name=provider_name, - format=reasoning.format, - type=reasoning.type, - index=reasoning.index, - ) - elif isinstance(reasoning, ReasoningSummary): - return cls( - id=reasoning.id, - content=reasoning.summary, - provider_name=provider_name, - format=reasoning.format, - type=reasoning.type, - index=reasoning.index, - ) - else: - return cls( - id=reasoning.id, - content='', - signature=reasoning.data, - provider_name=provider_name, - format=reasoning.format, - type=reasoning.type, - index=reasoning.index, - ) - - def into_reasoning_detail(self): - return _reasoning_detail_adapter.validate_python(asdict(self)).model_dump() +_OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText +_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail) + + +def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart: + provider_name = 'openrouter' + reasoning_id = reasoning.model_dump_json(include={'id', 'format', 'index', 'type'}) + if isinstance(reasoning, _ReasoningText): + return ThinkingPart( + id=reasoning_id, + content=reasoning.text, + signature=reasoning.signature, + provider_name=provider_name, + ) + elif isinstance(reasoning, _ReasoningSummary): + return ThinkingPart( + id=reasoning_id, + content=reasoning.summary, + provider_name=provider_name, + ) + else: + return ThinkingPart( + id=reasoning_id, + content='', + signature=reasoning.data, + provider_name=provider_name, + ) + + +def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail: + if thinking_part.id is None: # pragma: lax no cover + raise UnexpectedModelBehavior('OpenRouter thinking part has no ID') + + data = asdict(thinking_part) + data.update(json.loads(thinking_part.id)) + return _reasoning_detail_adapter.validate_python(data) class OpenRouterCompletionMessage(chat.ChatCompletionMessage): @@ -350,7 +337,7 @@ class OpenRouterCompletionMessage(chat.ChatCompletionMessage): reasoning: str | None = None """The reasoning text associated with the message, if any.""" - reasoning_details: list[OpenRouterReasoningDetail] | None = None + reasoning_details: list[_OpenRouterReasoningDetail] | None = None """The reasoning details associated with the message, if any.""" @@ -481,22 +468,17 @@ def _validate_completion(self, response: chat.ChatCompletion) -> OpenRouterChatC return response @override - def _process_reasoning(self, response: chat.ChatCompletion) -> list[ThinkingPart]: - # We can cast with confidence because response was validated in `_validate_completion` - response = cast(OpenRouterChatCompletion, response) - - message = response.choices[0].message - items: list[ThinkingPart] = [] + def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[ThinkingPart] | None: + assert isinstance(message, OpenRouterCompletionMessage) if reasoning_details := message.reasoning_details: - for detail in reasoning_details: - items.append(OpenRouterThinkingPart.from_reasoning_detail(detail)) - - return items + return [_from_reasoning_detail(detail) for detail in reasoning_details] + else: + return super()._process_thinking(message) @override def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: - response = cast(OpenRouterChatCompletion, response) + assert isinstance(response, OpenRouterChatCompletion) provider_details = super()._process_provider_details(response) @@ -514,8 +496,8 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess if isinstance(item, TextPart): texts.append(item.content) elif isinstance(item, ThinkingPart): - if item.provider_name == self.system and isinstance(item, OpenRouterThinkingPart): - reasoning_details.append(item.into_reasoning_detail()) + if item.provider_name == self.system: + reasoning_details.append(_into_reasoning_detail(item).model_dump()) else: # pragma: no cover pass elif isinstance(item, ToolCallPart): @@ -555,7 +537,7 @@ class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): reasoning: str | None = None """The reasoning text associated with the message, if any.""" - reasoning_details: list[OpenRouterReasoningDetail] | None = None + reasoning_details: list[_OpenRouterReasoningDetail] | None = None """The reasoning details associated with the message, if any.""" @@ -605,7 +587,7 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): if reasoning_details := choice.delta.reasoning_details: for detail in reasoning_details: - thinking_part = OpenRouterThinkingPart.from_reasoning_detail(detail) + thinking_part = _from_reasoning_detail(detail) yield self._parts_manager.handle_thinking_delta( vendor_part_id='reasoning_detail', id=thinking_part.id, diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index ee8bc3c5a1..14bfa713f1 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -110,7 +110,9 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open assert isinstance(thinking_event_start, PartStartEvent) assert thinking_event_start.part == snapshot( ThinkingPart( - content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter' + content='', + id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}', + provider_name='openrouter', ) ) @@ -118,7 +120,9 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open assert isinstance(thinking_event_end, PartEndEvent) assert thinking_event_end.part == snapshot( ThinkingPart( - content='', id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter' + content='', + id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}', + provider_name='openrouter', ) ) @@ -200,7 +204,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ thinking_part = response.parts[0] assert isinstance(thinking_part, ThinkingPart) - assert thinking_part.id == snapshot(None) + assert thinking_part.id == snapshot('{"id":null,"format":"unknown","index":0,"type":"reasoning.text"}') assert thinking_part.content is not None assert thinking_part.signature is None From 0a110d2c49070c92db5791322a9b51668abb3c88 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 7 Nov 2025 17:39:15 -0600 Subject: [PATCH 29/42] replace cast with asserts --- pydantic_ai_slim/pydantic_ai/models/openai.py | 5 ++--- pydantic_ai_slim/pydantic_ai/models/openrouter.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 6b131474c0..e9cfb99495 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -1762,15 +1762,14 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: for event in self._map_part_delta(choice): yield event - async def _validate_response(self): + def _validate_response(self): """Hook that validates incoming chunks. This method may be overridden by subclasses of `OpenAIStreamedResponse` to apply custom chunk validations. By default, this is a no-op since `ChatCompletionChunk` is already validated. """ - async for chunk in self._response: - yield chunk + return self._response def _map_part_delta(self, choice: Choice): """Hook that determines the sequence of mappings that will be called to produce events. diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index fefe35c052..bf0f4f063b 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -582,8 +582,7 @@ async def _validate_response(self): @override def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): - # We can cast with confidence because chunk was validated in `_validate_response` - choice = cast(OpenRouterChunkChoice, choice) + assert isinstance(choice, OpenRouterChunkChoice) if reasoning_details := choice.delta.reasoning_details: for detail in reasoning_details: @@ -597,7 +596,7 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): @override def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: - chunk = cast(OpenRouterChatCompletionChunk, chunk) + assert isinstance(chunk, OpenRouterChatCompletionChunk) if provider_details := super()._map_provider_details(chunk): if provider := chunk.provider: # pragma: lax no cover From a1f5385542a55fa481a1578851231de09e8c1e3b Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Sat, 8 Nov 2025 09:15:01 -0600 Subject: [PATCH 30/42] fix merge changes --- tests/models/test_openrouter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 14bfa713f1..ccf138ad32 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -173,7 +173,7 @@ class Divide(BaseModel): assert tool_call_part.tool_name == 'divide' assert tool_call_part.args == snapshot('{"numerator": 123, "denominator": 456, "on_inf": "infinity"}') - mapped_messages = await model._map_messages([response]) # type: ignore[reportPrivateUsage] + mapped_messages = await model._map_messages([response], None) # type: ignore[reportPrivateUsage] tool_call_message = mapped_messages[0] assert tool_call_message['role'] == 'assistant' assert tool_call_message.get('content') is None @@ -221,7 +221,7 @@ async def test_openrouter_preserve_reasoning_block(allow_model_requests: None, o ) messages.append(await model_request(model, messages)) - openai_messages = await model._map_messages(messages) # type: ignore[reportPrivateUsage] + openai_messages = await model._map_messages(messages, None) # type: ignore[reportPrivateUsage] assistant_message = openai_messages[1] assert assistant_message['role'] == 'assistant' @@ -297,7 +297,7 @@ async def test_openrouter_map_messages_reasoning(allow_model_requests: None, ope user_message = ModelRequest.user_text_prompt('Who are you. Think about it.') response = await model_request(model, [user_message]) - mapped_messages = await model._map_messages([user_message, response]) # type: ignore[reportPrivateUsage] + mapped_messages = await model._map_messages([user_message, response], None) # type: ignore[reportPrivateUsage] assert len(mapped_messages) == 2 assert mapped_messages[1]['reasoning_details'] == snapshot( # type: ignore[reportGeneralTypeIssues] From ba40e0539ecfc0cee44e852e38655b5bce46e1e3 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Mon, 10 Nov 2025 22:17:08 -0600 Subject: [PATCH 31/42] Update pydantic_ai_slim/pydantic_ai/models/openai.py Co-authored-by: Douwe Maan --- pydantic_ai_slim/pydantic_ai/models/openai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index d2eb7e2a1b..37b18e5bd9 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -746,7 +746,7 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess def _map_finish_reason( self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] ) -> FinishReason | None: - """Hooks that maps a finish reason key to a [FinishReason](pydantic_ai.messages.FinishReason). + """Hooks that maps a finish reason key to a [FinishReason][pydantic_ai.messages.FinishReason]. This method may be overridden by subclasses of `OpenAIChatModel` to accommodate custom keys. """ From d50e24f691dd0ae0efde4ba00ef3439c8c2fe367 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 13 Nov 2025 10:19:41 -0600 Subject: [PATCH 32/42] add usage --- pydantic_ai_slim/pydantic_ai/models/openai.py | 77 ++-- .../pydantic_ai/models/openrouter.py | 108 ++++-- .../test_openrouter_usage.yaml | 346 ++++++++++++++++++ tests/models/test_openrouter.py | 26 ++ 4 files changed, 499 insertions(+), 58 deletions(-) create mode 100644 tests/models/cassettes/test_openrouter/test_openrouter_usage.yaml diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 37b18e5bd9..a23e35a8ab 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -4,7 +4,7 @@ import itertools import json import warnings -from collections.abc import AsyncIterable, AsyncIterator, Sequence +from collections.abc import AsyncIterable, AsyncIterator, Iterable, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass, field, replace from datetime import datetime @@ -62,8 +62,9 @@ ChatCompletionContentPartInputAudioParam, ChatCompletionContentPartParam, ChatCompletionContentPartTextParam, + chat_completion, + chat_completion_chunk, ) - from openai.types.chat.chat_completion_chunk import Choice from openai.types.chat.chat_completion_content_part_image_param import ImageURL from openai.types.chat.chat_completion_content_part_input_audio_param import InputAudio from openai.types.chat.chat_completion_content_part_param import File, FileFile @@ -543,28 +544,7 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, This method may be overridden by subclasses of `OpenAIChatModel` to apply custom mappings. """ - choice = response.choices[0] - provider_details: dict[str, Any] = {} - - # Add logprobs to vendor_details if available - if choice.logprobs is not None and choice.logprobs.content: - # Convert logprobs to a serializable format - provider_details['logprobs'] = [ - { - 'token': lp.token, - 'bytes': lp.bytes, - 'logprob': lp.logprob, - 'top_logprobs': [ - {'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs - ], - } - for lp in choice.logprobs.content - ] - - raw_finish_reason = choice.finish_reason - provider_details['finish_reason'] = raw_finish_reason - - return provider_details + return _map_provider_details(response.choices[0]) def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" @@ -618,7 +598,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons return ModelResponse( parts=items, - usage=_map_usage(response, self._provider.name, self._provider.base_url, self._model_name), + usage=self._map_usage(response), model_name=response.model, timestamp=timestamp, provider_details=self._process_provider_details(response), @@ -680,6 +660,9 @@ def _streamed_response_cls(self) -> type[OpenAIStreamedResponse]: """ return OpenAIStreamedResponse + def _map_usage(self, response: chat.ChatCompletion) -> usage.RequestUsage: + return _map_usage(response, self._provider.name, self._provider.base_url, self._model_name) + def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[chat.ChatCompletionToolParam]: return [self._map_tool_definition(r) for r in model_request_parameters.tool_defs.values()] @@ -1767,7 +1750,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: for event in self._map_part_delta(choice): yield event - def _validate_response(self): + def _validate_response(self) -> AsyncIterable[ChatCompletionChunk]: """Hook that validates incoming chunks. This method may be overridden by subclasses of `OpenAIStreamedResponse` to apply custom chunk validations. @@ -1776,7 +1759,7 @@ def _validate_response(self): """ return self._response - def _map_part_delta(self, choice: Choice): + def _map_part_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]: """Hook that determines the sequence of mappings that will be called to produce events. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. @@ -1785,7 +1768,7 @@ def _map_part_delta(self, choice: Choice): self._map_thinking_delta(choice), self._map_text_delta(choice), self._map_tool_call_delta(choice) ) - def _map_thinking_delta(self, choice: Choice): + def _map_thinking_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]: """Hook that maps thinking delta content to events. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. @@ -1811,7 +1794,7 @@ def _map_thinking_delta(self, choice: Choice): provider_name=self.provider_name, ) - def _map_text_delta(self, choice: Choice): + def _map_text_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]: """Hook that maps text delta content to events. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. @@ -1831,7 +1814,7 @@ def _map_text_delta(self, choice: Choice): maybe_event.part.provider_name = self.provider_name yield maybe_event - def _map_tool_call_delta(self, choice: Choice): + def _map_tool_call_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]: """Hook that maps tool call delta content to events. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the mapping. @@ -1851,11 +1834,9 @@ def _map_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the provider details. """ - choice = chunk.choices[0] - if raw_finish_reason := choice.finish_reason: - return {'finish_reason': raw_finish_reason} + return _map_provider_details(chunk.choices[0]) - def _map_usage(self, response: ChatCompletionChunk): + def _map_usage(self, response: ChatCompletionChunk) -> usage.RequestUsage: return _map_usage(response, self._provider_name, self._provider_url, self._model_name) def _map_finish_reason( @@ -2177,7 +2158,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: UserWarning, ) - def _map_usage(self, response: responses.Response): + def _map_usage(self, response: responses.Response) -> usage.RequestUsage: return _map_usage(response, self._provider_name, self._provider_url, self._model_name) @property @@ -2237,6 +2218,32 @@ def _map_usage( ) +def _map_provider_details( + choice: chat_completion_chunk.Choice | chat_completion.Choice, +) -> dict[str, Any]: + provider_details: dict[str, Any] = {} + + # Add logprobs to vendor_details if available + if choice.logprobs is not None and choice.logprobs.content: + # Convert logprobs to a serializable format + provider_details['logprobs'] = [ + { + 'token': lp.token, + 'bytes': lp.bytes, + 'logprob': lp.logprob, + 'top_logprobs': [ + {'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs + ], + } + for lp in choice.logprobs.content + ] + + if raw_finish_reason := choice.finish_reason: + provider_details['finish_reason'] = raw_finish_reason + + return provider_details + + def _split_combined_tool_call_id(combined_id: str) -> tuple[str, str | None]: # When reasoning, the Responses API requires the `ResponseFunctionToolCall` to be returned with both the `call_id` and `id` fields. # Before our `ToolCallPart` gained the `id` field alongside `tool_call_id` field, we combined the two fields into a single string stored on `tool_call_id`. diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index bf0f4f063b..c9bb7d9a6b 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -24,7 +24,7 @@ try: from openai import APIError - from openai.types import chat + from openai.types import chat, completion_usage from openai.types.chat import chat_completion, chat_completion_chunk from .openai import OpenAIChatModel, OpenAIChatModelSettings, OpenAIStreamedResponse @@ -220,6 +220,12 @@ class WebPlugin(TypedDict, total=False): OpenRouterPlugin = WebPlugin +class OpenRouterUsageConfig(TypedDict, total=False): + """Configuration for OpenRouter usage.""" + + include: bool + + class OpenRouterModelSettings(ModelSettings, total=False): """Settings used for an OpenRouter model request.""" @@ -254,6 +260,16 @@ class OpenRouterModelSettings(ModelSettings, total=False): """ openrouter_plugins: list[OpenRouterPlugin] + """To enable plugins in the request. + + Plugins are tools that can be used to extend the functionality of the model. [See more](https://openrouter.ai/docs/features/web-search) + """ + + openrouter_usage: OpenRouterUsageConfig + """To control the usage of the model. + + The usage config object consolidates settings for enabling detailed usage information. [See more](https://openrouter.ai/docs/use-cases/usage-accounting) + """ class OpenRouterError(BaseModel): @@ -357,6 +373,30 @@ class OpenRouterChoice(chat_completion.Choice): """A wrapped chat completion message with OpenRouter specific attributes.""" +class OpenRouterCostDetails(BaseModel): + """OpenRouter specific cost details.""" + + upstream_inference_cost: int | None = None + + +class OpenRouterCompletionTokenDetails(completion_usage.CompletionTokensDetails): + """Wraps OpenAI completion token details with OpenRouter specific attributes.""" + + image_tokens: int | None = None + + +class OpenRouterUsage(completion_usage.CompletionUsage): + """Wraps OpenAI completion usage with OpenRouter specific attributes.""" + + cost: float | None = None + + cost_details: OpenRouterCostDetails | None = None + + is_byok: bool | None = None + + completion_tokens_details: OpenRouterCompletionTokenDetails | None = None # type: ignore[reportIncompatibleVariableOverride] + + class OpenRouterChatCompletion(chat.ChatCompletion): """Wraps OpenAI chat completion with OpenRouter specific attributes.""" @@ -369,6 +409,9 @@ class OpenRouterChatCompletion(chat.ChatCompletion): error: OpenRouterError | None = None """OpenRouter specific error attribute.""" + usage: OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] + """OpenRouter specific usage attribute.""" + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -389,6 +432,8 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti extra_body['preset'] = preset if transforms := model_settings.pop('openrouter_transforms', None): extra_body['transforms'] = transforms + if usage := model_settings.pop('openrouter_usage', None): + extra_body['usage'] = usage model_settings['extra_body'] = extra_body @@ -401,30 +446,40 @@ def _map_usage( provider_url: str, model: str, ) -> RequestUsage: + assert isinstance(response, OpenRouterChatCompletion) or isinstance(response, OpenRouterChatCompletionChunk) + builder = RequestUsage() + response_usage = response.usage if response_usage is None: - return RequestUsage() - - usage_data = response_usage.model_dump(exclude_none=True) - details = { - k: v - for k, v in usage_data.items() - if k not in {'prompt_tokens', 'completion_tokens', 'input_tokens', 'output_tokens', 'total_tokens'} - if isinstance(v, int) - } - response_data = dict(model=model, usage=usage_data) - - if response_usage.completion_tokens_details is not None: # pragma: lax no cover - details.update(response_usage.completion_tokens_details.model_dump(exclude_none=True)) - - return RequestUsage.extract( - response_data, - provider=provider, - provider_url=provider_url, - provider_fallback='openai', - api_flavor='chat', - details=details, - ) + return builder + + builder.input_tokens = response_usage.prompt_tokens + builder.output_tokens = response_usage.completion_tokens + + if prompt_token_details := response_usage.prompt_tokens_details: + if cached_tokens := prompt_token_details.cached_tokens: + builder.cache_read_tokens = cached_tokens + + if audio_tokens := prompt_token_details.audio_tokens: # pragma: lax no cover + builder.input_audio_tokens = audio_tokens + + if video_tokens := prompt_token_details.video_tokens: # pragma: lax no cover + builder.details['input_video_tokens'] = video_tokens + + if completion_token_details := response_usage.completion_tokens_details: + if reasoning_tokens := completion_token_details.reasoning_tokens: + builder.details['reasoning_tokens'] = reasoning_tokens + + if image_tokens := completion_token_details.image_tokens: # pragma: lax no cover + builder.details['output_image_tokens'] = image_tokens + + if (is_byok := response_usage.is_byok) is not None: + builder.details['is_byok'] = is_byok + + if cost := response_usage.cost: + builder.details['cost'] = int(cost * 1000000) # convert to microcost + + return builder class OpenRouterModel(OpenAIChatModel): @@ -524,6 +579,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess def _streamed_response_cls(self): return OpenRouterStreamedResponse + @override + def _map_usage(self, response: chat.ChatCompletion) -> RequestUsage: + return _map_usage(response, self._provider.name, self._provider.base_url, self._model_name) + @override def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] @@ -566,6 +625,9 @@ class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): choices: list[OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] """A list of chat completion chunk choices modified with OpenRouter specific attributes.""" + usage: OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] + """Usage statistics for the completion request.""" + @dataclass class OpenRouterStreamedResponse(OpenAIStreamedResponse): diff --git a/tests/models/cassettes/test_openrouter/test_openrouter_usage.yaml b/tests/models/cassettes/test_openrouter/test_openrouter_usage.yaml new file mode 100644 index 0000000000..e705d5f75c --- /dev/null +++ b/tests/models/cassettes/test_openrouter/test_openrouter_usage.yaml @@ -0,0 +1,346 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '147' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me about Venus + role: user + model: openai/gpt-5-mini + stream: false + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '15782' + 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: stop + index: 0 + logprobs: null + message: + content: |- + Brief summary + - Venus is the second planet from the Sun and is similar to Earth in size and mass, but has a hostile, runaway-greenhouse climate. It is often called Earth's "sister" or "twin" because of its comparable radius and composition, but its surface conditions are extreme. + + Key facts (numbers) + - Distance from Sun: ~0.72 AU (about 108 million km). + - Diameter: ~12,104 km (≈0.95 Earth’s diameter). + - Mass: ≈0.815 Earth masses. + - Surface gravity: ≈0.9 g. + - Orbital period (year): ≈224.7 Earth days. + - Rotation: very slow and retrograde — one sidereal day ≈243 Earth days; the solar day (noon-to-noon) ≈117 Earth days. + - Mean surface temperature: ≈460–470 °C (≈730–740 K). + - Surface pressure: ≈92 bar (about 92 times Earth’s sea‑level pressure). + + Atmosphere and climate + - Dominated by carbon dioxide (~96–97%) with nitrogen making up most of the rest and trace gases (sulfur compounds, water vapor). + - Thick cloud deck of sulfuric acid droplets, which reflect sunlight and give Venus its high albedo. + - Intense greenhouse effect traps heat; surface temperatures are hot enough to melt lead. + - Upper atmosphere exhibits "super-rotation": winds can circle the planet in a few Earth days, much faster than the planet’s rotation. + + Surface and geology + - Surface is mostly basaltic plains with many volcanoes and vast lava flows; evidence suggests extensive volcanic resurfacing in the past few hundred million years. + - Few impact craters (sparse compared with other rocky bodies), indicating a relatively young surface. + - Highlands called “tesserae” are highly deformed regions thought to be among the oldest crust. + - Venus likely lacks Earth-style plate tectonics; heat is probably released through volcanic and regional tectonic processes. + + Magnetic field and interior + - Venus has a similar size and density to Earth, implying a metallic core, but it lacks a strong global magnetic field. A very weak induced magnetosphere forms from interactions with the solar wind, likely because slow rotation prevents an Earth-like dynamo. + + Potential for life + - The surface is too hot and high‑pressure for known life. Some scientists have proposed that the temperate cloud layers (50–60 km altitude) might be more hospitable and could conceivably host microbial life, but this is speculative. + - A claimed detection of phosphine in Venus’ clouds (2020) sparked interest but remains highly debated and unconfirmed. + + Exploration history + - First successful flyby: Mariner 2 (1962). + - Soviet Venera program: multiple atmospheric probes and several landers that returned the first surface data and images (e.g., Venera 13 and 14 in 1982). + - NASA Pioneer Venus and Magellan missions mapped Venus (Magellan used radar mapping in the early 1990s). + - More recent orbiters include ESA’s Venus Express (2006–2014) and JAXA’s Akatsuki (arrived 2015), which study atmosphere and weather. + - Upcoming/planned missions: NASA’s DAVINCI+ and VERITAS, and ESA’s EnVision, aimed at studying atmosphere composition, geology, and high-resolution mapping. + + Why it matters + - Venus is a natural laboratory for studying greenhouse effects, planetary evolution, and why two similar planets (Earth and Venus) can end up so different. Understanding Venus also informs exoplanet studies and the conditions that make a planet habitable. + + If you’d like, I can: + - Compare Venus and Earth in more detail, + - Summarize major missions and their discoveries, + - Explain theories for the retrograde rotation, + - Or go deeper into atmospheric chemistry and cloud-layer habitability. Which would you prefer? + reasoning: "**Exploring Venus**\n\nThe user wants a concise yet informative overview of Venus, including its physical + characteristics and exploration history. I'll cover key points such as its orbit, rotation, and atmosphere. Important + numbers to include are: it's about 0.72 AU from the Sun, has a length of day of 243 Earth days (retrograde), a + year of 224.7 Earth days, a radius of 6052 km (0.949 times Earth's), and a mass of 0.815 Earth. \n\nThe atmosphere + is roughly 96.5% CO2, with a mean surface temperature around 465 °C (735 K). Lastly, its rotation is retrograde + and slow, moving west-to-east.**Understanding Venus's Rotation and Surface**\n\nRetrograde means that Venus rotates + in the opposite direction to most planets, specifically east to west, while Earth spins west to east. Venus has + a long rotation period of 243 Earth days, which is longer than its year. Its solar day is around 116.75 Earth + days. The surface consists of basaltic plains and highland tesserae with few impact craters due to a relatively + young surface, about 300-800 million years old, and no plate tectonics. \n\nVolcanism is widespread, and the density + is similar to Earth at 5.24 g/cm³. Venus has a weak magnetic field and cloud droplets of sulfuric acid, with atmospheric + super-rotation causing winds up to 360 km/h that circle the planet every four Earth days. The greenhouse effect + is extreme due to high CO2 levels and a lack of water.**Exploring Venus's History and Characteristics**\n\nI'll + summarize Venus exploration, starting with Mariner 2's flyby in 1962. The Venera series were significant as they + landed probes that provided the first atmospheric data and surface images from Venus, particularly Venera 13 and + 14 in 1982. Pioneer Venus followed in 1978, while the Magellan mission in the 1990s mapped the surface using radar.\n\nIn + recent years, missions like Venus Express (2005–2014) and the Akatsuki orbiter since 2015 have continued to expand + our understanding. Upcoming missions include NASA's VERITAS and DAVINCI+ and ESA's EnVision, set for launch between + 2028 and 2031. \n\nI should mention the challenges of surface operations due to harsh conditions and the possibility + of life in clouds, with some scientists hypothesizing microbial life in temperate cloud layers. The 2020 detection + of phosphine sparked controversy, leading to ongoing debates.\n\nInterestingly, Venus is often called Earth's + twin in size, but the environments are vastly different. Being an inferior planet, Venus exhibits phases like + the Moon when viewed from Earth, and its retrograde rotation may result from a giant impact or tidal interactions + with its dense atmosphere. I'll keep the summary concise while providing key numbers and facts." + reasoning_details: + - format: openai-responses-v1 + index: 0 + summary: "**Exploring Venus**\n\nThe user wants a concise yet informative overview of Venus, including its physical + characteristics and exploration history. I'll cover key points such as its orbit, rotation, and atmosphere. + Important numbers to include are: it's about 0.72 AU from the Sun, has a length of day of 243 Earth days (retrograde), + a year of 224.7 Earth days, a radius of 6052 km (0.949 times Earth's), and a mass of 0.815 Earth. \n\nThe atmosphere + is roughly 96.5% CO2, with a mean surface temperature around 465 °C (735 K). Lastly, its rotation is retrograde + and slow, moving west-to-east.**Understanding Venus's Rotation and Surface**\n\nRetrograde means that Venus + rotates in the opposite direction to most planets, specifically east to west, while Earth spins west to east. + Venus has a long rotation period of 243 Earth days, which is longer than its year. Its solar day is around 116.75 + Earth days. The surface consists of basaltic plains and highland tesserae with few impact craters due to a relatively + young surface, about 300-800 million years old, and no plate tectonics. \n\nVolcanism is widespread, and the + density is similar to Earth at 5.24 g/cm³. Venus has a weak magnetic field and cloud droplets of sulfuric acid, + with atmospheric super-rotation causing winds up to 360 km/h that circle the planet every four Earth days. The + greenhouse effect is extreme due to high CO2 levels and a lack of water.**Exploring Venus's History and Characteristics**\n\nI'll + summarize Venus exploration, starting with Mariner 2's flyby in 1962. The Venera series were significant as + they landed probes that provided the first atmospheric data and surface images from Venus, particularly Venera + 13 and 14 in 1982. Pioneer Venus followed in 1978, while the Magellan mission in the 1990s mapped the surface + using radar.\n\nIn recent years, missions like Venus Express (2005–2014) and the Akatsuki orbiter since 2015 + have continued to expand our understanding. Upcoming missions include NASA's VERITAS and DAVINCI+ and ESA's + EnVision, set for launch between 2028 and 2031. \n\nI should mention the challenges of surface operations due + to harsh conditions and the possibility of life in clouds, with some scientists hypothesizing microbial life + in temperate cloud layers. The 2020 detection of phosphine sparked controversy, leading to ongoing debates.\n\nInterestingly, + Venus is often called Earth's twin in size, but the environments are vastly different. Being an inferior planet, + Venus exhibits phases like the Moon when viewed from Earth, and its retrograde rotation may result from a giant + impact or tidal interactions with its dense atmosphere. I'll keep the summary concise while providing key numbers + and facts." + type: reasoning.summary + - data: gAAAAABpEglaDlqr6W69WezzCt54Qx62tf-AJZM0xE2p63O61dV-B1dp9CxhGAuKNLQui7lSKVAxUrWc1RQtKJ8FXKGjvIYioRrozYZhpuZAVXBc4ONZoXRaLwgfu2p-jAY22fyUxiNN26GisZDzA0AjOwwSZyo_YbLXp9oiRVEU_JV6uHkHBmSl5Pqdk0i2SaHrcGOC-5193H16HPxIalH4NNUsWfuUCgmRTrhkvz2SZqDTYk1XtPlwgWpDOkMDosVSXFkXhUC7m0b_QXNnmw2jqD0c4v4CfVb-vgkb7WcxPi019InLIZFUOkUG4bComrTdZFXlT08nOGEiNo8oIkyM1FjKuTzbWMgc91r0IZ4jDp8jSEiNdlSS-Tt8_I6_AWtIDZc0JDgFa0YEwgF2s8dq6Fppnj_BcG6tUBoR9S1YH4lVw_ztwGFnX4kkNbDLUAqWDOG5VqSv_5knlkLNk_tA26Ps4sq89nuv-6YYWOU6VHtxxiYbrTpbo_5IbgNEX2o135Fp0ajpMR2EikDa8JByJAcmejo8I7CG1i6NxDch6yLP93o1nhMjd0nw5AfQEuqM6qW71BIZcE9DTlxOCI8qaAZAIEq8AyQLhYefqtWfycBMTFzC4uhqzrwZZ6DE-jy8WM9Wr4jU4sj8MsyvyUh-lJFkwL159jeZXGw3kzKwPYgsWgtqKdgcej7jc7SZp8IxPYt3jucwG3zONL4liBUs6Lm7Fh6XUuOb0YB6_Bsxv1UdVK7_IplOofxPiPeoz2IYS1W7SF0aq54bwXMR0XAlfQX-43gKSZfBaOvs8icDH4rgslGvsV-JRL14078hl04HgnFLZUTjYSqxJSuqs-IFQfN7RVfeQDbQgaQkvv9Gup5Oom0R5r1TUcH5DnyRY7smTXcLEVWQlmoi0wYPGAgYZj747bxLCNCKT5Bmas6EmI5stIw5HHnb-m2hBdBLRfChVBjouVHanmrsimTy6gHXRBe190h6PZWF9rmL5V6ln4hrsemjXePqnYf0APgDZz3zQPLu6Gxfk3Blf6-sO_1xG7xOnnZkpwsxJG8xcJM9iX9nsvQ_lOUFHfLO2vhNimH7__Xw_XSb1_0gAEovwxRe-Q8hfpiKaLmxNW_f4i3DrxSMzA0vkzzymKj2GjYBbmzl0Q5-lN_UJrGfszszll2DuO2Lx7ItvxeoOSCA3uvuuiJttu8PBpY8RdZnCetwLzuiurVOeVhBSJXiwe2gLHFqCPc5oyO99VAXVjePUpXQHdE9vEd-PAE1GxDWYtyeiXO28xlDrb4FeMhvwr3zu7TturMs0x6R15c8vjhmFN-21jE6tBkqXhG3abLaXXwWAWT3pB1WtWlH1DYOfcR72z63eSPWaQ7TSG4WgL-r2WfxHBOB6rFntvJ71AGnRUKJQiFM74VZUx0_gvuLuNDlOmFayWeGyHn7Rpqdbpu7xqwt3dIedPXEzd0Rn8hRPbm7YShtEcDqs4O3vrw3DCAqSFxD4Ec3HklMghTJNoAijQCVvSXvWpnvmoODLPNdoAfNXjQ_lRnALCscz0OEITRPp2J8nO12thLoy33rgRxpZxjXubnu9bhjlwSvmhE4nVTkdUIgLZQ4RyiS3AOCNuFVem2X3Sf1z_tNhTWEtA0lMDCNR4u6C7cQBnb0nc5dsxBD8rJVTbUYdOcbbHXGQy6BnKfYuwBJQs5FyNlXkik4LRIhvNNJJ213rEnQ37pgAIMC3e6kKb3e3yZzaCyxa1vrZ2RvBjs7U3CR91LoUJASso8reeTsLeb9iKPUHVKxDtAiMDFttpZvF-hAorL4aknJxZKm3EtIWwLdVgIjFL_fjOU_bNSRgU-OlomQ6cZ4q3CrD3njZc5Cc63qIO9OXmHWuR4Kj_8UKqhZfCFrxzKVLx33NiePLaUBQ947tl4yDYqDPtn_lbLaZxvqwYYCns439lhuD3RfvmxneN6DND1LC1NkqrFDJBOeWdfNyLMpi-qOZMC1QJO-ngE0ddpJTum1XX9jW4b8ZBBrUdNfLPyHrWTf3aWaraBVn-QJquvWK74OLr4EZu006SWILtAQ9ZuELbvlE6wg1EyGReABwHelXaZHAb34z2OUHEI5jdISCcBqVfHo06emrZpbqr6gFqMOpHqJf2TvE3NxtxU5dEGKqN3w5wsWNvWiG1WTzds3pWJdkMcInV1dLQuR6n7vMask4eBu4Cc441L5f31bevBc6i0Tf8mTRkiFa97kXcRQ5ClGzAFF1ELaSwq3pJ5zDn-E7o5f8lMQQ2GY9NKJ5_lYT1TfDh-ebj-RBoWZuzkM-DuGt40XYEg48ATRen07rFQhym_gUl2-qHc7fmKyqCkvowLq2Z75bDd1HevMRKcnHMZAP3W4AIm-gM8AIi2G_Bshky9lx11Nbb0Q86l_LX8sCyBSv_4v7txvfXgb57MAhgv6zVGJFPMTTffYl8YqCArOzpwV-SsXM5JmZD3UqN0qC_FAnYhtotTc0DZogLmHjFSmCtu6LbCKqop3RAjDbeT10zCrYD93rHlt4NFZwmubjuUR0XbHP-YSVqm9NSIuIP2IFNtNAAI6ou1aP4yHWm3tPHPkSU6kyugDntZ5Yzk7S6MB2CHtY5_EkI0MefAklWE7METa6Nf7mBSK8cMJaWekJdwCfgJ_QQIWTU02EY1ZGXHy_FXvRrHMMear6Evi92hYiX7O0XwziP2-QU-py8wUYkAFeiM81K1EeRtHyN4_zDMYV3QAMAPkXqC9iUMi0vNL6KvLst54ZJxf06qO9OucPbGQUd8wGHLazIfEBphBr7-3hIz5pb4FIopaf1jDTL2tg36dsVK_AVCU7pNCjNUeqTTQH_TmCeghlW5D0_5Jk1Tn4FlyeQWp99PHr1Ymd6ezTl_6Y4n97dRh-KXwXKQEuLpmNDCN59LBVED1FNCy1MkPhqtFx7YVwuuD0dmL7WNsptX8-KTfdp2R2nYX7yHT_SqOXLo-tnU_p1gwj38vdy8OS2uLeXbi7y54e3QCM9dJjLQwYfgci1Trf6JDF7b7jZZ7Qx_bsT8QqdzNvfT7jSRYYvU-NydeAMsc7yp8FxGwizIO0odGu6ywbwRz3Rc8kh_XsWf2W0NNm9u_lpXvFnI3DlYBo2bzHCVjbpacpD7jTWEjPCEcCj_HuTTbeKTX7GU6HZ3QvNwSMHMBnJrV-fgTPWPb1TNxqFO5LwSqIA781CtGKho5U3kMlkgucDcMvngVF9rI3Q3l-2LDoLtnKREs3F7qvuwAJlceXpV8FunaftKNP7B7BkWSbj_Ic9Ph-R2T4wLvFh71Inqe3zGWDko6botJYL-NVuwxNe_S1L7fOZff0Ng0l0d0DkqHSRxRHx9qK6_mRJMXXdcTAxHF0Pmfqg4KBxV9kkIPQBTyHiVDxgr5t-k9MV52XtvMnhOUGIQtxy10qRm-LhRiUD5XReZwvm9tuqUCIgDkYw_lr-3BLQev3Q9vWhUAxpzMD3zxmTq4nLxlF3EnmXAozPkMpArhEfqz3H9M2vX5fN5AgBKSnaJezr5bz0oF5eZrXK8nCrPYnovRV_e9oAnPjIvRQVWmFygup-N_wDi_Dzbxt3d_amPZFi_JVJeleq6Qd05TvwG-t1EVYHA_GS2Ms0K7oJtm5V_faJcALcxt8preCVb6o4k7M3Y-WjfMe62drQS8qIlqD1jLjXG0b-KUoG917jZlMcEsN5HFnxN0gW7eq7AGJ-NiQz7aeVZGUxaiz9L66IiDGoX731q5M5gaVYffWH1o_jA33RQ2H6YTae5HOCZ5Qv22jBl5IcHT66-b9eVFc4vIu8fLxvAlH_UbzI8GSczd3OLMldZs0WOiOE8tWj6eWzzvYPjT1DvIiCOkAU56FMSZRdV8-ee_sey_mFeR5qj0vtqgD08443dFGyvqCY9yPYcauv-Tjn-7LJ-UMHEDAmcBsk8pGhS4k2Xn0g1EilMhHzKPeMC8y0K4hHdO5GdMi-ZUPutVWxJqgOSsJ6Csb1NMKKbb5XqmBg0NcKlQxFcd8CIDS0ZmQx4OlnNg_iqI_NHEWMFNdJbJvMHDNah9IHRhdSfD2oEcJxrM9yUMwmf5aDVSnjHpGTp-LJ_LcA3XyzjEAHc1k03Eg1keqphvK0PGUlEKv8t7zD0-5hfAXQItsvDC_ed055QnxFU16m0V2bhKdqsldUOT5Tax3KoFbhdE78kV6dK20TkbsKuaQ4j-FysBcQw0o0KPjR6D-Haz3N5dE9ilgAp4R-gGTcFQK2EzBUCa5nglSxVNCaWmWke7jeb4DJmBYIXd59n9jlcuHj1Vhu2tNOm6kMvt6ZlHF1FNU8mnvIk1v_XWnavmwjzhskYBMbc6R1jua2VEB53EBxuzMqqx1bnvqbZJZKDzoIgGt61B4WBZJyjfWXslCJELo4d3t4Koa0SYcEQ2IxrrgFJlHLNBHgOTrnKCVwEbe8tY_EJ7unjrHnN4NYKVo8fYRs1QasREwae-faD4Prcmfh1murWWnbuo4JPD5S38AxZDLjJjB0t_6Q1-YLF5X36uOIxIMJ0ZOM2kX_jkkMfMbfcqHXijRkwrOA7cCe1pcExJVoPJjxHGNG4l2HjOeEsWzrC5pNEWlM5jpp4nUXugawpoZ1-EY-uoFDTrhnOuCS-q6HPLd0GpCR09uVR14tXtXKAHUU6RWB2vYOutz_CQJGtYpsyeW7jU8sfkvUs6QWFc_Xr8RnRgbyQ9CgeUjhhDQHu-gRFqR2d-_b3aKy0KYlbo6ny53atw8q2vvdwj9KS4V856tUn6mT9egwIE3qvVF3F67YiT_VAX5KUVs_YLJQCJ8WrhLJ0FWHklIfFanJT2D3fZywx74lSDHPP4F6kywgGiDTeVivhBDApyWZO13AwLD1cWxyPy_u2I-7Mx_cwhJ3iZIEAFfKRoMmZmI-HOogVHFdgm9ibWqRPXpXDR5MEC_oO3iPJ1gujCIfeY00MWL-1dS_w3ZjA5v1luG6IMkpLUIRekIDY= + format: openai-responses-v1 + id: rs_0f2864c6e4889623016912094071dc81979635e33414ef8729 + index: 0 + type: reasoning.encrypted + refusal: null + role: assistant + native_finish_reason: completed + created: 1762789695 + id: gen-1762789695-8IngOktYUifJqeBs0mwc + model: openai/gpt-5-mini + object: chat.completion + provider: OpenAI + usage: + completion_tokens: 1515 + completion_tokens_details: + reasoning_tokens: 704 + prompt_tokens: 17 + total_tokens: 1532 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '171' + content-type: + - application/json + host: + - openrouter.ai + method: POST + parsed_body: + messages: + - content: Be helpful. + role: system + - content: Tell me about Mars + role: user + model: openai/gpt-5-mini + stream: false + usage: + include: true + uri: https://openrouter.ai/api/v1/chat/completions + response: + headers: + access-control-allow-origin: + - '*' + connection: + - keep-alive + content-length: + - '18631' + 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: stop + index: 0 + logprobs: null + message: + content: |- + Here’s a concise but thorough overview of Mars — the fourth planet from the Sun and Earth’s most studied neighbor. + + Quick facts + - Distance from Sun: ~1.52 AU (about 228 million km on average) + - Diameter: ~6,780 km (about 0.53 Earth’s diameter) + - Mass: ~0.11 Earth masses + - Surface gravity: ~3.71 m/s² (~0.38 g) + - Day length: ~24.6 hours + - Year: ~687 Earth days + - Axial tilt: ~25.2°, so Mars has seasons similar in character to Earth’s + - Average surface temperature: roughly −60°C (range from about −125°C to +20°C) + - Atmosphere: very thin, ≈0.6% of Earth’s surface pressure; ~95% carbon dioxide, small amounts of nitrogen and argon, trace oxygen and water vapor + + Surface, geology and major features + - Rocky planet with basaltic crust and iron-rich regolith; iron gives much of the surface its reddish color. + - Volcanoes: home to the largest volcano in the Solar System, Olympus Mons (~21–22 km high). + - Canyons: Valles Marineris is a vast canyon system ~4,000 km long and up to ~7 km deep. + - Impact craters are widespread; some regions are heavily cratered and ancient, others are younger and smoother. + - Evidence of extensive past erosion by flowing water: ancient river valleys, deltas, and layered sedimentary rocks. Many minerals (clays, sulfates) point to long-term interactions with liquid water in the past. + + Water and climate history + - Today liquid water is not stable on the surface except perhaps transiently in brines; most water exists as ice (polar caps and subsurface permafrost) and as vapor in the atmosphere. + - Polar caps: layered deposits of water ice and seasonal carbon dioxide ice. + - Strong evidence that early Mars was warmer and wetter, with lakes and possibly large-scale oceans in its first billion years. The climate transitioned to the cold, arid planet we see now. + - Active processes: seasonal CO2 frost, dust storms (sometimes global), dust devils, and recurring surface changes indicate Mars is still geologically active in some ways. + + Atmosphere and magnetic field + - Thin CO2-dominated atmosphere yields low surface pressure (~6–7 millibars) and weak greenhouse warming. + - No global magnetic field today; there are localized remnant magnetic fields in the crust. Lack of a strong magnetosphere made Mars vulnerable to atmospheric loss from the solar wind over time. + - Trace gases like methane have been detected intermittently; their source (geological, subsurface chemical, or biological) is not yet settled. + + Moons + - Two small moons: Phobos (mean radius ~11 km) and Deimos (~6 km). Likely captured asteroids or formed from debris after an impact. Phobos orbits very close to Mars and is slowly spiraling inward. + + Exploration history (highlights) + - Early flybys and orbiters: Mariner, Viking, Mariner 9 (first to map Mars), and many subsequent orbiters (Mars Global Surveyor, Mars Odyssey, Mars Express, Mars Reconnaissance Orbiter, MAVEN, etc.). + - First successful landers: Viking 1 and 2 (1976). + - Rovers: Sojourner (1997), Spirit and Opportunity (2004), Curiosity (2012), Perseverance (2021). Perseverance carries instruments for astrobiology and caches rock cores for planned sample return; it deployed the Ingenuity helicopter (first powered flight on another planet). + - Landers studying interior: Phoenix (2008) studied polar ice, InSight (2018) measured seismic activity and interior structure. + - International missions: ESA’s Mars Express and ExoMars program, India’s Mars Orbiter Mission (Mangalyaan), China’s Tianwen-1 (orbiter + lander + Zhurong rover), UAE’s Hope mission. + - Mars Sample Return is planned by NASA/ESA to retrieve Perseverance samples within the 2020s–2030s timeframe. + + Potential for life and habitability + - Current surface conditions are harsh for known Earth life (radiation, cold, dryness, low pressure), but subsurface habitats (protected from radiation, warmer, with ice/water) remain plausible refuges for microbial life. + - Ancient habitable environments are well documented in the rock record; search for past biosignatures is a major goal. + - Methane detections and seasonal changes spark interest, but sources remain uncertain. + + Human exploration and resources + - Human missions are technically plausible but challenging: long transit times, radiation exposure, life-support and entry/descent/landing of large payloads. + - In-situ resource utilization (ISRU) prospects: abundant CO2 for making oxygen/fuel, widespread water ice as a water and hydrogen source, regolith for shielding and construction. + - Several agencies and private companies have long-term plans or concepts for crewed missions (2030s–2040s timeframes are frequently discussed). + + Interesting facts and practical notes + - A Martian “solar day” (sol) is ~24 hours 39 minutes — useful for rover operations. + - Global dust storms can darken solar panels and affect surface operations for months. + - Mars’ thin atmosphere makes atmospheric entry and landing more difficult than for Earth; aerobraking helps for orbiters but landing large payloads is still hard. + - Cultural note: named after the Roman god of war; long inspired human imagination and science fiction. + + If you want, I can: + - Summarize the most important missions and their discoveries, + - Explain what living on Mars would require (habitats, food, radiation protection), + - Dive deeper into the geology or evidence for ancient water, + - Or provide a timeline of exploration and planned missions. + reasoning: |- + **Exploring Mars Overview** + + The user wants a comprehensive overview of Mars, covering physical properties, its orbit, atmosphere, geology, and surface features like Olympus Mons and Valles Marineris. I should include information on seasons, climate, and any evidence of water or past habitability. I’ll mention its moons, Phobos and Deimos, and summarize exploration history, including various missions, plus human exploration prospects. It’s essential to keep the language accessible while adding key numbers like radius (about 3390 km) and gravity (3.71 m/s²). Cultural aspects and controversies about water and potential biosignatures need to be noted too!**Describing Mars' Atmosphere and Conditions** + + Mars has an axial tilt of about 25.2 degrees, making its seasons similar to Earth's. The atmosphere is composed mostly of carbon dioxide (about 95.32%), with smaller amounts of nitrogen (2.7%), argon (1.6%), oxygen (0.13%), and carbon monoxide (0.07%). Surface pressure is roughly 0.6% of Earth’s at an average of around 6 mbar. Temperatures can range from -125°C at the poles in winter to +20°C at the equator in summer. Mars has polar ice caps, evidence of ancient water, and two moons, Phobos and Deimos, which may be captured asteroids.**Highlighting Mars' Surface Features and Exploration** + + Mars features the tallest volcano, Olympus Mons, standing around 21.9 km high, and Valles Marineris, a canyon system about 4,000 km long and up to 7 km deep. The thin atmosphere limits liquid water stability, but brines could be possible. I should mention frequent dust storms, including global ones, and the timeline of exploration missions such as Mariner flybys, Viking landers in 1976, and others like Curiosity and Perseverance, which landed in 2012 and 2021, respectively. Future plans include Mars Sample Return and potential human missions, while habitability remains uncertain, especially on the surface.**Discussing Mars and Its Exploration Needs** + + I need to address several aspects of Mars, focusing on the need for pressurized habitats due to radiation concerns from its thin atmosphere and weak magnetosphere. Resources like water ice, regolith for building, and CO2 for oxygen and fuel are essential for In-Situ Resource Utilization (ISRU). I should include basic facts, environmental elements like dust devils, seasonal differences due to orbital eccentricity, and the average distance from the Sun at about 1.52 AU. My approach will be a structured overview touching on all these topics. + reasoning_details: + - format: openai-responses-v1 + index: 0 + summary: |- + **Exploring Mars Overview** + + The user wants a comprehensive overview of Mars, covering physical properties, its orbit, atmosphere, geology, and surface features like Olympus Mons and Valles Marineris. I should include information on seasons, climate, and any evidence of water or past habitability. I’ll mention its moons, Phobos and Deimos, and summarize exploration history, including various missions, plus human exploration prospects. It’s essential to keep the language accessible while adding key numbers like radius (about 3390 km) and gravity (3.71 m/s²). Cultural aspects and controversies about water and potential biosignatures need to be noted too!**Describing Mars' Atmosphere and Conditions** + + Mars has an axial tilt of about 25.2 degrees, making its seasons similar to Earth's. The atmosphere is composed mostly of carbon dioxide (about 95.32%), with smaller amounts of nitrogen (2.7%), argon (1.6%), oxygen (0.13%), and carbon monoxide (0.07%). Surface pressure is roughly 0.6% of Earth’s at an average of around 6 mbar. Temperatures can range from -125°C at the poles in winter to +20°C at the equator in summer. Mars has polar ice caps, evidence of ancient water, and two moons, Phobos and Deimos, which may be captured asteroids.**Highlighting Mars' Surface Features and Exploration** + + Mars features the tallest volcano, Olympus Mons, standing around 21.9 km high, and Valles Marineris, a canyon system about 4,000 km long and up to 7 km deep. The thin atmosphere limits liquid water stability, but brines could be possible. I should mention frequent dust storms, including global ones, and the timeline of exploration missions such as Mariner flybys, Viking landers in 1976, and others like Curiosity and Perseverance, which landed in 2012 and 2021, respectively. Future plans include Mars Sample Return and potential human missions, while habitability remains uncertain, especially on the surface.**Discussing Mars and Its Exploration Needs** + + I need to address several aspects of Mars, focusing on the need for pressurized habitats due to radiation concerns from its thin atmosphere and weak magnetosphere. Resources like water ice, regolith for building, and CO2 for oxygen and fuel are essential for In-Situ Resource Utilization (ISRU). I should include basic facts, environmental elements like dust devils, seasonal differences due to orbital eccentricity, and the average distance from the Sun at about 1.52 AU. My approach will be a structured overview touching on all these topics. + type: reasoning.summary + - data: gAAAAABpEgl8ot_t1C45G8kTxU_Ea0ea-6M2Azibgi8FPFCsAoNtOggfC3TgXL9ALIYcezYnUEcr4RUR_F7SAPYABUNE825K3Vx_b4fKXYqVoXYmlauV6gR4tPgvSM_MUnSxKqvynqEnJb7uPPfy61nmxumItX8FOlfxvxLXsNHHx3G1-1GigM11XfG2wGEkKZd437w6vBc89gGmQho56wdQtwpbedTtvyP4KlEcPCYNADeSUi0RJEMBNFjUUAJ6u4No2KUgkBSElE8Ksn_I2nwzONf6z5oxg3LgA5O9HrnN3SSxdPImCG8UZ1ORbUTZ4LpPJRkys60Wt1ARKbx4ig_uedId0tF3TvQY_5fepg2Z4tJVdj5-7ZA_g6EZ0L8Z-bxxhw7ONnQe-AyenXTStLikDwQSrNexoNU9ckVh4T_bcHu4ugvNhVx0DqQz6MjzqfHDSS1dOiCuFk5EZt9A7oDnMUYFyZLOfhhxyYV7xQ0puOZzxeriYyU_PyqeM5cOw0kIHvNnPAqs8RXWDJzFzN8z46RpyWoS0IX62O5Ggb4ENw1yo4uoBvpJRf-Zuty846zZ0s8--gL5ECH4m-0UQnXpCaUkhoT_fiLAVnNpiKkClg0eQawTGqVeHVS4hSNzh9TYUPySa2o7e2GlJiMFS2T5bMXuWRdmWiy_XaCLrWd_b3Ev8KTQK91wvF5CWJA-0lXlKrgKAabu9kKieomwvKnXUZ0o6WT1k0SDHCbAu08jh_xrBfnOT1hL5J7_kedkfIjT2jxZmr4lhZmcUZ3aosI8HrmEdIUtfzbW8aKX1QH49DTgZuZxfZOmq_4ZGhX9QGBHj5iiq5SbGJa6lud61x5XIMpqlWZ2OJRmX0RcVl9tuJ10cK_qSOvANnHHRORYV9M2HAZqytk5Hv3As21Wxb-lQpiRPoAHO8rggWpvFjkzXJGgs6kNMiGMKf3PF85uZ0ALIBZn_ufzblUvmt6DMHb0OL2U99xt5Q1xYovx4rrNVJpm49sTQZglJcfba_Q6O_4BQOfb_IDKdti4X8xcZWfqHTlE-xxVEgioskSAVVJuWlCaRzGdcayHOFShskkp28_xFcikxUv6F8kxXdr-EiJQP9B6QZfdteyoXu2S304DfyUCD05gVDoc8GvYYmJ6pK6bvQuv33EnF47_vRi20GY87FSDRPSD2tbxLGpf0stknE2h07QOmNBCbFjojSoYsDP88QjaXFOeHDO5lQv35rDgns_-sX_lt-Arjx3QQDDtl2VO2nrxNx6cbQ675-RUMWV8qUTP931nhB9Pn2d4FPMLiU02yZvE2clGCYXFNUYutSQWK2t8A7nJITzqd2sYc2Dywdh1JBTemI5ErnWnJEbKKd3XBuoq2PP2ZrIVE2-ps-uS79Bgz1WerIj5wfm0aiRv2EK2y3fE5jLAuxtLLt0K6ZyzcTpfPvDeqi78LFS3Iz7d8c79yjHaDB277kLOiOmbOrUTuj7FsxYwtSAPRRb7Lfo9SHA2BAkaA0wFTSgiKGKARJiUtsOge7WYNn2hYg3gEmgkcCP13W636kerFQxZHDP_Ix6NqeCPGk8fYXysJnDZNlsKE9a_Su3LoHjAUcu8Jk8EzfPpx87p4pAME-iRPquA6C4PwVvZ2AtUHafcpCfxlUY29R8Tw1U_hASW3PqJU895ckZfzxoWpd1jd7kjeZt77xWYAYFBpUSKHeY9Z2wNDOI9_1i47KuyJ-ybULLKsPBooEvk8ih6Hc0_JFf4tsBq_XkSJurqd2OeH4owS2M5LFujf_vCoQyaPidH7yXiNJszHPJatvt7RhAsHHzXuObcekvC66z4myld7g6PCQFl_vMV77wwHK27StoLhaZBaJVl67C8jwIVgwOMQTyQyS-DW8Gmzwx_hL6kAxVCkPHZGKAATEKs0Id4PqjfHE0QQVpAZvhQais49JxDYXXnmsheRkWIx5I9fm4EJsA5DDSFz02Q4o5seNqrJ3GUqBTF7aVitb9XExsPqqKm6KZZdNqhzgEzn4wy_hM4wDhtiI_q_gvjkCXGvyBwHVvmhAuKCzuxVEVIeWZpH75SxUCHDoUKMYuf_BEru-F0OKNyH1bMCR_hSutgFZXfhkziv4cMevzUhf8Bo3pV8C3T0L-q7a2GiOUONojsluqa5C0imRbLG2DrYWgNm7excL-aLctoZ1S9wjgNSPTc8YI_KV7_rSpnb7hkQkTbcrZh7wl9iD1jKPHvvrB3LVBed7iu5retyeLhFxvy7YYWOMK-OzukqWKXPufJtZUbUVq1K0tYAYVqc1PgIrQmDUqyXhWAWGlaM0qJ7BAssfsvhyrGWEE0zKGxQf9aacREVBPNlsTX0pyJtnIWTzdognZBQ4nVKjIE2N7IVprhDflsSywzTviU54uFF8bf4rpCX4SxaOC8RG7vTyZYC1ZMkRdk6nJopY-n3nw3KcVnNqT_n9BAO8CJTDmX4hft--yVvpfkLIkjMTNjg9alrLTeT8aMAnCbJYwEmEFsCFbiOZeVHVqeUXkjsYhjz9d3ABi6gkU4CEIElFim9BwKM1Ml0AMHkbzaZP6whliRkV2j8ClX2pP3_LC8imeYeR_Vpcf_OGOAkORcMfJw--SQaWB4b8rpH4k9tuFHLiwOMXUvxXGHjWNf645Bfx_jJ--kBYIOQFnilfIAMaswQsYynjyO9uK6q5CVh4bPRV0_cP8v6hPNctr7ETCz6PCuaLVzYiFOify9S1kHUDgYRXLkXEunCp3U2_sDKU4T-tePoaRemoDkfmZznX7B8a_ID8ku_rV_6iAiaXABifW2jzX7by4gh0TmKliKAbERL1bFSBJWi7rVr9Y43hEnuy1aCPpG1bB15_LHNsb6faz5uzcBiUJOdNiDRLrw4OG07_Hs7UqTlR9QmqNQzHjsFaCD6h_eh_kqHI6a8887DyCaZ2nMxV2Z3lP_9BmhtEHq_yDQll1cvZP_sWdsY9Zdcr_PdUzVoQkrW0eD6A3SgjJ63HL1ppaXfxBwPLJViHpGqj9ahvYt27mf1fIbJC9qqJU4WCT5YGm_dGUNrsVSrxhWqOeRm2ocgantqFdMlSivEz91DLHvTLFMtYrPOmA6ei8uHau5HwB4T2lwWjpc4eUp4urLnYKkiHxqutocE4bHS3xkb4rNk4Yd8j9HeO25Ek-zMJB76JM3XM6L4xaGUvC8-l7FWwNhFG-bo79unc2ULPYSJOq8PR0RR94XrnV2BRn-0njRZXOS3ehyEj05Y0cf0IChdFIgx2T2gDcBhGp0IsujFY-cBcYq2Nds3tTOh_v2krGlc-XMgU1omQd3gpnHGwz-dyTJwOoUGlAQepNEMr3aL0g6LxyGKeLYFXPCuARtxjFqONX__W7g6L_SY3HUzK_3vVrMoTVVlZXSSfx1ptzeyx1c3Dj0OrP0yIQem3CFDqs-AH2eev864EveUL2-VPigNOUekA4He6baNaxIj-oe1IqrZYczKw4bu1LYCHAiD1I32_h_uyBOfQtTQkVdfC84vYWn56YRrihZrF2474cK4F8eL7olxswVgvMn3RE9n6-G4Qf8f9wkelq9uv8lUbywvPhGjqzJbsxoheqDdQnVyTezZpYi6t5rsCbXYRrdbfoHDkRvbHS_Wg0bblevsZ7lqJFfPlmq53PzlEpOfy3XLsNqVA-mX2h9s9cUMmr34BtfjhSHJYf67g9NzCoQcVEMMjhgXcKCEb69Zpx2FFTv62CGtQXXzoKGIXrWjNXYH5Np4hfknxaM9MtrhZPGd2qq66OKggsXeZUIE5kZZYJ-1qbPwCjL-_pR47FHqjNDBwSCwccYQxZ74tLpi2O4g9e4UhCmIW1v73dyh_xs4VqU6EeYd_sp-cnjh4DgIO7pRE_XyEU-RKvaiZ_7vfY_n9fvzNedahRkiz0ebdGK2DjNtX2uzfhN5-HQfNRxwwbExFCsgq9y0RoJOJnYnuFIiT6P_frlzoQdP8JBlwU7piPaVh4uAQKcEsvAcB7Uhsms5twfeqm4LXvJ1gXXhtinswl7JSt3UGBGpziPjYcsxrJQF07CuCDmp1TBUf1j0h6AvETy_ERHv_3ra7f74ZpNr3ff-G-SDG_x3maznxSbQy4VMop3-RfLH89cVtyGZzZhwb8L8mlpcJHXXlFb5Lwz8-q5avF1fXHN75NeOLjGvas7Ecjtmnup7oESwfIFrfql1IfD3vV18kBCDfZblcPmZzzT1rO8BvZqJfpynpoSYE9mmGKPiD2NsfYPWKnIinAPyot8LC9UfGsEPVLEtsvh9kGIEZ3HxlSHq3Uf-RSLDxvgMNz05-G45rohNaZEAXCFDIOM0EIZq5wHlvTuHM1w1FbY_nVnBJdxTgpMJbHEtlWzLaQHjim9pW6ZZFrm3oZ8-3XtljodSX6mc7_h5oYwXtMwNclPizEBA08mJ2RvsSTQUJWx1F2ac2MQhoEqTez_9gdlvhWTzuh_0M686Sg6wbCjxYfcVg_1kTp0vMOczNYYhyZ-VIfzZo45IlgOfKDK23Rb8kUenPNtCbYGwu4A64cN5XJEUYK4mD64La6fa4Hd7OXhlELPUhXJtSdjJ3KmrRNe6MY8hN2hwHuuqazI7uzu6IEpj3u9lGK8egCPheqGBCM65UmozPPVdku0vRpHP7xvd7ozqo7QsuPUgfdJpGQG4H64DoTYRLLB5tVXcDaA8O9o-B6fsj4PGA_bwnT-cZW48WVeswu7vXTvSsWgmhM1syvVIPT5uAVli-m9y1r5Ca423y7OAa1P6S8h6QhlygUYGE4BFs8Uslcd0cz8CdptdGF8kUvCryTm1MzFiWs9SyMOMw6vCNNKuPxl4FrdE3mEiGb-NSlLaw1DxI6Dy3ubDAYVC9U1I8jiIvbiddkSWMA40402rDhi8DfRThmBFPLOGcCM34fpKOLp7vO_3U9YLgnXVAJjNIgEOTRi6m0gIPiKMa1ScE6Qov0x4alElBxS9Syx9-GqPltMlyzvRaM1TO6VeGARvwc7qA66hMQ9FLd2exDRAlvkVM2A1rQJbWHGs-_VSoiC92_g3xJz3c3ZrUKjePJmyQ0xr2Lg-PpDSBEvMhFinb2nQSQGV15e2oyC6VpGuxl_bYbDLOEtlf1XbMhRK_iJB2K_liXQr4BtLMm6CuXugPoTFpVJ7xrxOhnNbO3ELPgB6kHAjdwnHi8xc9zE1IKwZafKLSlnkDdlRIEbscIRPS81RjL-xpqZ4KppEVUtAgP9aSyaIrohsEus4aRSDOOfpScWp91Q4UMeG7K8hTIkQEFn-uSgTm5zzOd34K-abNKdctfwlYm44srnGwOhFSmP-cW_iYo4mm3z4SNXv8kiMBzvndFN6h06CT-lxI5waEIPaBojrktVvft3hWgCD6YtYw2E9uU1dvDWBxG2qXSLInIBMzXLuLjkfyCZbdS7-252_Z7mWRnhtE4uIGmltagUV4XXF6FONdbrbTKUJgLhnt5Vol-xntlN6FOAg0IN7blJ6BliCQr0MH0PFRMRPcnX-AT2rEdO4fU8oKdLT950Wt6EA5zEwVfiLf2r1mQGeBHkty5ixE1FDxd9OQ7KYfhDmJvDCCcrgUjsusWAAb71kTIcyYdbN1RABQlKm6D4CJJZcipcf559CXgiWyHS2SWVPX_2qq9vl-SCMrEVsgai7Nkr9Kx18aiPTRacC5zaM3mIM39vYILH-POy39d1n7__ezcVoW9dXEb_g04xLRopTedEfrF4gSeM-bWCKDzGMWmzB6cAHc5YY2s8JJbP8CRK8EjDDstJr5NhVq35N80aLcwzgkOn511Acr1GOukyitSoBo05hKJsyHrQv-T56g6iNkPCD3MFRUuvvDJC_UUzomG6rrix5yNxbzro7EiSLSckTJoIJEHxaYO2AyrgcTxJhqJ21rjNTtduB4Iv7CHY8RVrjZIpJNmfqQXmU_4T4u16EWK2M5miKytzOJYNui7sr9ezhDFf7O2igxLlES1q7VBw6RBitxA9-GF83hGljEHcYl2L-o0kgjq4MrWdlWWtYm8mfAG_Iybu2luvNIPRh9O-2HvKH1FIv5ozulaUvR6zlBI8PXkLqAF9Gm_ShmIE43U4ki-16yfZwoCf9m-PGjb_o9GcAzz7usKvFPk-M8cIHoalhGXZCwxKyhTGjZnCHqcZsOVegcZMsr9cotbt9VGxTzlFz2itGv15uM80wYNhYdeQDuWMxSr88R0= + format: openai-responses-v1 + id: rs_029b1b33873fe02b0169120967d9a88197af3d9f4db7a92055 + index: 0 + type: reasoning.encrypted + refusal: null + role: assistant + native_finish_reason: completed + created: 1762789734 + id: gen-1762789734-sxYWfPfn343ZvBkw9zV9 + model: openai/gpt-5-mini + object: chat.completion + provider: OpenAI + usage: + completion_tokens: 2177 + completion_tokens_details: + image_tokens: 0 + reasoning_tokens: 960 + cost: 0.00435825 + cost_details: + upstream_inference_completions_cost: 0.004354 + upstream_inference_cost: null + upstream_inference_prompt_cost: 4.25e-06 + is_byok: false + prompt_tokens: 17 + prompt_tokens_details: + audio_tokens: 0 + cached_tokens: 0 + video_tokens: 0 + total_tokens: 2194 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index ccf138ad32..ebec3433aa 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -12,6 +12,7 @@ ModelRequest, PartEndEvent, PartStartEvent, + RunUsage, TextPart, ThinkingPart, ToolCallPart, @@ -258,6 +259,31 @@ async def test_openrouter_errors_raised(allow_model_requests: None, openrouter_a ) +async def test_openrouter_usage(allow_model_requests: None, openrouter_api_key: str) -> None: + provider = OpenRouterProvider(api_key=openrouter_api_key) + model = OpenRouterModel('openai/gpt-5-mini', provider=provider) + agent = Agent(model, instructions='Be helpful.', retries=1) + + result = await agent.run('Tell me about Venus') + + assert result.usage() == snapshot( + RunUsage(input_tokens=17, output_tokens=1515, details={'reasoning_tokens': 704}, requests=1) + ) + + settings = OpenRouterModelSettings(openrouter_usage={'include': True}) + + result = await agent.run('Tell me about Mars', model_settings=settings) + + assert result.usage() == snapshot( + RunUsage( + input_tokens=17, + output_tokens=2177, + details={'cost': 4358, 'is_byok': 0, 'reasoning_tokens': 960}, + requests=1, + ) + ) + + async def test_openrouter_validate_non_json_response(openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) model = OpenRouterModel('google/gemini-2.0-flash-exp:free', provider=provider) From 8bcb352b6f7d1a4ddb88f5671f3eec8fc0b8c5c1 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 13 Nov 2025 23:35:18 -0600 Subject: [PATCH 33/42] add explicit thinking part mapping --- .../pydantic_ai/models/openrouter.py | 175 +++++++++--------- 1 file changed, 85 insertions(+), 90 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index c9bb7d9a6b..bf84cfe05e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,8 +1,7 @@ -import json -from dataclasses import asdict, dataclass +from dataclasses import dataclass from typing import Any, Literal, cast -from pydantic import AliasChoices, BaseModel, Field, TypeAdapter +from pydantic import BaseModel from typing_extensions import TypedDict, assert_never, override from ..exceptions import ModelHTTPError, UnexpectedModelBehavior @@ -43,7 +42,7 @@ } -class OpenRouterMaxPrice(TypedDict, total=False): +class _OpenRouterMaxPrice(TypedDict, total=False): """The object specifying the maximum price you want to pay for this request. USD price per million tokens, for prompt and completion.""" prompt: int @@ -128,14 +127,14 @@ class OpenRouterMaxPrice(TypedDict, total=False): See [the OpenRouter API](https://openrouter.ai/docs/api-reference/list-available-providers) for a full list. """ -Transforms = Literal['middle-out'] +_Transforms = Literal['middle-out'] """Available messages transforms for OpenRouter models with limited token windows. Currently only supports 'middle-out', but is expected to grow in the future. """ -class OpenRouterProviderConfig(TypedDict, total=False): +class _OpenRouterProviderConfig(TypedDict, total=False): """Represents the 'Provider' object from the OpenRouter API.""" order: list[OpenRouterProviderName] @@ -165,11 +164,11 @@ class OpenRouterProviderConfig(TypedDict, total=False): sort: Literal['price', 'throughput', 'latency'] """Sort providers by price or throughput. (e.g. "price" or "throughput"). [See details](https://openrouter.ai/docs/features/provider-routing#provider-sorting)""" - max_price: OpenRouterMaxPrice + max_price: _OpenRouterMaxPrice """The maximum pricing you want to pay for this request. [See details](https://openrouter.ai/docs/features/provider-routing#max-price)""" -class OpenRouterReasoning(TypedDict, total=False): +class _OpenRouterReasoning(TypedDict, total=False): """Configuration for reasoning tokens in OpenRouter requests. Reasoning tokens allow models to show their step-by-step thinking process. @@ -190,37 +189,7 @@ class OpenRouterReasoning(TypedDict, total=False): """Whether to enable reasoning with default parameters. Default is inferred from effort or max_tokens.""" -class WebPlugin(TypedDict, total=False): - """You can incorporate relevant web search results for any model on OpenRouter by activating and customizing the web plugin. - - The web search plugin is powered by native search for Anthropic and OpenAI natively and by Exa for other models. For Exa, it uses their "auto" method (a combination of keyword search and embeddings-based web search) to find the most relevant results and augment/ground your prompt. - """ - - id: Literal['web'] - - engine: Literal['native', 'exa', 'undefined'] - """The web search plugin supports the following options for the engine parameter: - - `native`: Always uses the model provider's built-in web search capabilities - `exa`: Uses Exa's search API for web results - `undefined` (not specified): Uses native search if available for the provider, otherwise falls back to Exa - - Native search is used by default for OpenAI and Anthropic models that support it - Exa search is used for all other models or when native search is not supported. - - When you explicitly specify "engine": "native", it will always attempt to use the provider's native search, even if the model doesn't support it (which may result in an error).""" - - max_results: int - """The maximum results allowed by the web plugin.""" - - search_prompt: str - """The prompt used to attach results to your message.""" - - -OpenRouterPlugin = WebPlugin - - -class OpenRouterUsageConfig(TypedDict, total=False): +class _OpenRouterUsageConfig(TypedDict, total=False): """Configuration for OpenRouter usage.""" include: bool @@ -237,7 +206,7 @@ class OpenRouterModelSettings(ModelSettings, total=False): These models will be tried, in order, if the main model returns an error. [See details](https://openrouter.ai/docs/features/model-routing#the-models-parameter) """ - openrouter_provider: OpenRouterProviderConfig + openrouter_provider: _OpenRouterProviderConfig """OpenRouter routes requests to the best available providers for your model. By default, requests are load balanced across the top providers to maximize uptime. You can customize how your requests are routed using the provider object. [See more](https://openrouter.ai/docs/features/provider-routing)""" @@ -247,70 +216,64 @@ class OpenRouterModelSettings(ModelSettings, total=False): Create and manage presets through the OpenRouter web application to control provider routing, model selection, system prompts, and other parameters, then reference them in OpenRouter API requests. [See more](https://openrouter.ai/docs/features/presets)""" - openrouter_transforms: list[Transforms] + openrouter_transforms: list[_Transforms] """To help with prompts that exceed the maximum context size of a model. Transforms work by removing or truncating messages from the middle of the prompt, until the prompt fits within the model's context window. [See more](https://openrouter.ai/docs/features/message-transforms) """ - openrouter_reasoning: OpenRouterReasoning + openrouter_reasoning: _OpenRouterReasoning """To control the reasoning tokens in the request. The reasoning config object consolidates settings for controlling reasoning strength across different models. [See more](https://openrouter.ai/docs/use-cases/reasoning-tokens) """ - openrouter_plugins: list[OpenRouterPlugin] - """To enable plugins in the request. - - Plugins are tools that can be used to extend the functionality of the model. [See more](https://openrouter.ai/docs/features/web-search) - """ - - openrouter_usage: OpenRouterUsageConfig + openrouter_usage: _OpenRouterUsageConfig """To control the usage of the model. The usage config object consolidates settings for enabling detailed usage information. [See more](https://openrouter.ai/docs/use-cases/usage-accounting) """ -class OpenRouterError(BaseModel): +class _OpenRouterError(BaseModel): """Utility class to validate error messages from OpenRouter.""" code: int message: str -class _BaseReasoningDetail(BaseModel): +class _BaseReasoningDetail(BaseModel, frozen=True): """Common fields shared across all reasoning detail types.""" id: str | None = None format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None index: int | None + type: Literal['reasoning.text', 'reasoning.summary', 'reasoning.encrypted'] -class _ReasoningSummary(_BaseReasoningDetail): +class _ReasoningSummary(_BaseReasoningDetail, frozen=True): """Represents a high-level summary of the reasoning process.""" type: Literal['reasoning.summary'] - summary: str = Field(validation_alias=AliasChoices('summary', 'content')) + summary: str -class _ReasoningEncrypted(_BaseReasoningDetail): +class _ReasoningEncrypted(_BaseReasoningDetail, frozen=True): """Represents encrypted reasoning data.""" type: Literal['reasoning.encrypted'] - data: str = Field(validation_alias=AliasChoices('data', 'signature')) + data: str -class _ReasoningText(_BaseReasoningDetail): +class _ReasoningText(_BaseReasoningDetail, frozen=True): """Represents raw text reasoning.""" type: Literal['reasoning.text'] - text: str = Field(validation_alias=AliasChoices('text', 'content')) + text: str signature: str | None = None _OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText -_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail) def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart: @@ -329,25 +292,54 @@ def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPar content=reasoning.summary, provider_name=provider_name, ) - else: + elif isinstance(reasoning, _ReasoningEncrypted): return ThinkingPart( id=reasoning_id, content='', signature=reasoning.data, provider_name=provider_name, ) + else: + assert_never(reasoning) def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail: if thinking_part.id is None: # pragma: lax no cover raise UnexpectedModelBehavior('OpenRouter thinking part has no ID') - data = asdict(thinking_part) - data.update(json.loads(thinking_part.id)) - return _reasoning_detail_adapter.validate_python(data) + data = _BaseReasoningDetail.model_validate_json(thinking_part.id) + + if data.type == 'reasoning.text': + return _ReasoningText( + type=data.type, + id=data.id, + format=data.format, + index=data.index, + text=thinking_part.content, + signature=thinking_part.signature, + ) + elif data.type == 'reasoning.summary': + return _ReasoningSummary( + type=data.type, + id=data.id, + format=data.format, + index=data.index, + summary=thinking_part.content, + ) + elif data.type == 'reasoning.encrypted': + assert thinking_part.signature is not None + return _ReasoningEncrypted( + type=data.type, + id=data.id, + format=data.format, + index=data.index, + data=thinking_part.signature, + ) + else: + assert_never(data.type) -class OpenRouterCompletionMessage(chat.ChatCompletionMessage): +class _OpenRouterCompletionMessage(chat.ChatCompletionMessage): """Wrapped chat completion message with OpenRouter specific attributes.""" reasoning: str | None = None @@ -357,7 +349,7 @@ class OpenRouterCompletionMessage(chat.ChatCompletionMessage): """The reasoning details associated with the message, if any.""" -class OpenRouterChoice(chat_completion.Choice): +class _OpenRouterChoice(chat_completion.Choice): """Wraps OpenAI chat completion choice with OpenRouter specific attributes.""" native_finish_reason: str @@ -369,47 +361,47 @@ class OpenRouterChoice(chat_completion.Choice): Notably, removes 'function_call' and adds 'error' finish reasons. """ - message: OpenRouterCompletionMessage # type: ignore[reportIncompatibleVariableOverride] + message: _OpenRouterCompletionMessage # type: ignore[reportIncompatibleVariableOverride] """A wrapped chat completion message with OpenRouter specific attributes.""" -class OpenRouterCostDetails(BaseModel): +class _OpenRouterCostDetails(BaseModel): """OpenRouter specific cost details.""" upstream_inference_cost: int | None = None -class OpenRouterCompletionTokenDetails(completion_usage.CompletionTokensDetails): +class _OpenRouterCompletionTokenDetails(completion_usage.CompletionTokensDetails): """Wraps OpenAI completion token details with OpenRouter specific attributes.""" image_tokens: int | None = None -class OpenRouterUsage(completion_usage.CompletionUsage): +class _OpenRouterUsage(completion_usage.CompletionUsage): """Wraps OpenAI completion usage with OpenRouter specific attributes.""" cost: float | None = None - cost_details: OpenRouterCostDetails | None = None + cost_details: _OpenRouterCostDetails | None = None is_byok: bool | None = None - completion_tokens_details: OpenRouterCompletionTokenDetails | None = None # type: ignore[reportIncompatibleVariableOverride] + completion_tokens_details: _OpenRouterCompletionTokenDetails | None = None # type: ignore[reportIncompatibleVariableOverride] -class OpenRouterChatCompletion(chat.ChatCompletion): +class _OpenRouterChatCompletion(chat.ChatCompletion): """Wraps OpenAI chat completion with OpenRouter specific attributes.""" provider: str """The downstream provider that was used by OpenRouter.""" - choices: list[OpenRouterChoice] # type: ignore[reportIncompatibleVariableOverride] + choices: list[_OpenRouterChoice] # type: ignore[reportIncompatibleVariableOverride] """A list of chat completion choices modified with OpenRouter specific attributes.""" - error: OpenRouterError | None = None + error: _OpenRouterError | None = None """OpenRouter specific error attribute.""" - usage: OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] + usage: _OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] """OpenRouter specific usage attribute.""" @@ -446,7 +438,7 @@ def _map_usage( provider_url: str, model: str, ) -> RequestUsage: - assert isinstance(response, OpenRouterChatCompletion) or isinstance(response, OpenRouterChatCompletionChunk) + assert isinstance(response, _OpenRouterChatCompletion) or isinstance(response, _OpenRouterChatCompletionChunk) builder = RequestUsage() response_usage = response.usage @@ -514,8 +506,8 @@ def prepare_request( return new_settings, customized_parameters @override - def _validate_completion(self, response: chat.ChatCompletion) -> OpenRouterChatCompletion: - response = OpenRouterChatCompletion.model_validate(response.model_dump()) + def _validate_completion(self, response: chat.ChatCompletion) -> _OpenRouterChatCompletion: + response = _OpenRouterChatCompletion.model_validate(response.model_dump()) if error := response.error: raise ModelHTTPError(status_code=error.code, model_name=response.model, body=error.message) @@ -524,7 +516,7 @@ def _validate_completion(self, response: chat.ChatCompletion) -> OpenRouterChatC @override def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[ThinkingPart] | None: - assert isinstance(message, OpenRouterCompletionMessage) + assert isinstance(message, _OpenRouterCompletionMessage) if reasoning_details := message.reasoning_details: return [_from_reasoning_detail(detail) for detail in reasoning_details] @@ -533,7 +525,7 @@ def _process_thinking(self, message: chat.ChatCompletionMessage) -> list[Thinkin @override def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, Any]: - assert isinstance(response, OpenRouterChatCompletion) + assert isinstance(response, _OpenRouterChatCompletion) provider_details = super()._process_provider_details(response) @@ -553,7 +545,10 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess elif isinstance(item, ThinkingPart): if item.provider_name == self.system: reasoning_details.append(_into_reasoning_detail(item).model_dump()) - else: # pragma: no cover + elif content := item.content: # pragma: no branch + start_tag, end_tag = self.profile.thinking_tags + texts.append('\n'.join([start_tag, content, end_tag])) + else: pass elif isinstance(item, ToolCallPart): tool_calls.append(self._map_tool_call(item)) @@ -590,7 +585,7 @@ def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] return _CHAT_FINISH_REASON_MAP.get(key) -class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): +class _OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): """Wrapped chat completion message with OpenRouter specific attributes.""" reasoning: str | None = None @@ -600,7 +595,7 @@ class OpenRouterChoiceDelta(chat_completion_chunk.ChoiceDelta): """The reasoning details associated with the message, if any.""" -class OpenRouterChunkChoice(chat_completion_chunk.Choice): +class _OpenRouterChunkChoice(chat_completion_chunk.Choice): """Wraps OpenAI chat completion chunk choice with OpenRouter specific attributes.""" native_finish_reason: str | None @@ -612,20 +607,20 @@ class OpenRouterChunkChoice(chat_completion_chunk.Choice): Notably, removes 'function_call' and adds 'error' finish reasons. """ - delta: OpenRouterChoiceDelta # type: ignore[reportIncompatibleVariableOverride] + delta: _OpenRouterChoiceDelta # type: ignore[reportIncompatibleVariableOverride] """A wrapped chat completion delta with OpenRouter specific attributes.""" -class OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): +class _OpenRouterChatCompletionChunk(chat.ChatCompletionChunk): """Wraps OpenAI chat completion with OpenRouter specific attributes.""" provider: str """The downstream provider that was used by OpenRouter.""" - choices: list[OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] + choices: list[_OpenRouterChunkChoice] # type: ignore[reportIncompatibleVariableOverride] """A list of chat completion chunk choices modified with OpenRouter specific attributes.""" - usage: OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] + usage: _OpenRouterUsage | None = None # type: ignore[reportIncompatibleVariableOverride] """Usage statistics for the completion request.""" @@ -637,14 +632,14 @@ class OpenRouterStreamedResponse(OpenAIStreamedResponse): async def _validate_response(self): try: async for chunk in self._response: - yield OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) + yield _OpenRouterChatCompletionChunk.model_validate(chunk.model_dump()) except APIError as e: - error = OpenRouterError.model_validate(e.body) + error = _OpenRouterError.model_validate(e.body) raise ModelHTTPError(status_code=error.code, model_name=self._model_name, body=error.message) @override def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): - assert isinstance(choice, OpenRouterChunkChoice) + assert isinstance(choice, _OpenRouterChunkChoice) if reasoning_details := choice.delta.reasoning_details: for detail in reasoning_details: @@ -658,7 +653,7 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): @override def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: - assert isinstance(chunk, OpenRouterChatCompletionChunk) + assert isinstance(chunk, _OpenRouterChatCompletionChunk) if provider_details := super()._map_provider_details(chunk): if provider := chunk.provider: # pragma: lax no cover From acb55ddf6fb04c675cbebecb7fac7dee63c0d358 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Tue, 18 Nov 2025 13:36:01 -0600 Subject: [PATCH 34/42] Refactor provider details mapping into top-level private function --- pydantic_ai_slim/pydantic_ai/models/openai.py | 2 +- .../pydantic_ai/models/openrouter.py | 104 +++++++----------- tests/models/test_openrouter.py | 10 +- 3 files changed, 50 insertions(+), 66 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 97ad43cccd..919771d5b6 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -1853,7 +1853,7 @@ def _map_tool_call_delta(self, choice: chat_completion_chunk.Choice) -> Iterable if maybe_event is not None: yield maybe_event - def _map_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, str] | None: + def _map_provider_details(self, chunk: ChatCompletionChunk) -> dict[str, Any] | None: """Hook that generates the provider details from chunk content. This method may be overridden by subclasses of `OpenAIStreamResponse` to customize the provider details. diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index bf84cfe05e..00d0150c44 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,3 +1,6 @@ +from __future__ import annotations as _annotations + +from collections.abc import Iterable from dataclasses import dataclass from typing import Any, Literal, cast @@ -11,6 +14,7 @@ FilePart, FinishReason, ModelResponse, + ModelResponseStreamEvent, TextPart, ThinkingPart, ToolCallPart, @@ -18,7 +22,6 @@ from ..profiles import ModelProfileSpec from ..providers.openrouter import OpenRouterProvider from ..settings import ModelSettings -from ..usage import RequestUsage from . import ModelRequestParameters try: @@ -371,6 +374,12 @@ class _OpenRouterCostDetails(BaseModel): upstream_inference_cost: int | None = None +class _OpenRouterPromptTokenDetails(completion_usage.PromptTokensDetails): + """Wraps OpenAI completion token details with OpenRouter specific attributes.""" + + video_tokens: int | None = None + + class _OpenRouterCompletionTokenDetails(completion_usage.CompletionTokensDetails): """Wraps OpenAI completion token details with OpenRouter specific attributes.""" @@ -386,6 +395,8 @@ class _OpenRouterUsage(completion_usage.CompletionUsage): is_byok: bool | None = None + prompt_tokens_details: _OpenRouterPromptTokenDetails | None = None # type: ignore[reportIncompatibleVariableOverride] + completion_tokens_details: _OpenRouterCompletionTokenDetails | None = None # type: ignore[reportIncompatibleVariableOverride] @@ -405,6 +416,27 @@ class _OpenRouterChatCompletion(chat.ChatCompletion): """OpenRouter specific usage attribute.""" +def _map_openrouter_provider_details( + response: _OpenRouterChatCompletion | _OpenRouterChatCompletionChunk, +) -> dict[str, Any]: + provider_details: dict[str, Any] = {} + + provider_details['downstream_provider'] = response.provider + provider_details['native_finish_reason'] = response.choices[0].native_finish_reason + + if usage := response.usage: + if cost := usage.cost: + provider_details['cost'] = cost + + if cost_details := usage.cost_details: + provider_details['upstream_inference_cost'] = cost_details.upstream_inference_cost + + if (is_byok := usage.is_byok) is not None: + provider_details['is_byok'] = is_byok + + return provider_details + + def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSettings) -> OpenAIChatModelSettings: """Transforms a 'OpenRouterModelSettings' object into an 'OpenAIChatModelSettings' object. @@ -432,48 +464,6 @@ def _openrouter_settings_to_openai_settings(model_settings: OpenRouterModelSetti return OpenAIChatModelSettings(**model_settings) # type: ignore[reportCallIssue] -def _map_usage( - response: chat.ChatCompletion | chat.ChatCompletionChunk, - provider: str, - provider_url: str, - model: str, -) -> RequestUsage: - assert isinstance(response, _OpenRouterChatCompletion) or isinstance(response, _OpenRouterChatCompletionChunk) - builder = RequestUsage() - - response_usage = response.usage - if response_usage is None: - return builder - - builder.input_tokens = response_usage.prompt_tokens - builder.output_tokens = response_usage.completion_tokens - - if prompt_token_details := response_usage.prompt_tokens_details: - if cached_tokens := prompt_token_details.cached_tokens: - builder.cache_read_tokens = cached_tokens - - if audio_tokens := prompt_token_details.audio_tokens: # pragma: lax no cover - builder.input_audio_tokens = audio_tokens - - if video_tokens := prompt_token_details.video_tokens: # pragma: lax no cover - builder.details['input_video_tokens'] = video_tokens - - if completion_token_details := response_usage.completion_tokens_details: - if reasoning_tokens := completion_token_details.reasoning_tokens: - builder.details['reasoning_tokens'] = reasoning_tokens - - if image_tokens := completion_token_details.image_tokens: # pragma: lax no cover - builder.details['output_image_tokens'] = image_tokens - - if (is_byok := response_usage.is_byok) is not None: - builder.details['is_byok'] = is_byok - - if cost := response_usage.cost: - builder.details['cost'] = int(cost * 1000000) # convert to microcost - - return builder - - class OpenRouterModel(OpenAIChatModel): """Extends OpenAIModel to capture extra metadata for Openrouter.""" @@ -528,10 +518,7 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, assert isinstance(response, _OpenRouterChatCompletion) provider_details = super()._process_provider_details(response) - - provider_details['downstream_provider'] = response.provider - provider_details['native_finish_reason'] = response.choices[0].native_finish_reason - + provider_details.update(_map_openrouter_provider_details(response)) return provider_details @override @@ -545,7 +532,7 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess elif isinstance(item, ThinkingPart): if item.provider_name == self.system: reasoning_details.append(_into_reasoning_detail(item).model_dump()) - elif content := item.content: # pragma: no branch + elif content := item.content: # pragma: lax no cover start_tag, end_tag = self.profile.thinking_tags texts.append('\n'.join([start_tag, content, end_tag])) else: @@ -574,10 +561,6 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess def _streamed_response_cls(self): return OpenRouterStreamedResponse - @override - def _map_usage(self, response: chat.ChatCompletion) -> RequestUsage: - return _map_usage(response, self._provider.name, self._provider.base_url, self._model_name) - @override def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] @@ -638,7 +621,7 @@ async def _validate_response(self): raise ModelHTTPError(status_code=error.code, model_name=self._model_name, body=error.message) @override - def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): + def _map_thinking_delta(self, choice: chat_completion_chunk.Choice) -> Iterable[ModelResponseStreamEvent]: assert isinstance(choice, _OpenRouterChunkChoice) if reasoning_details := choice.delta.reasoning_details: @@ -650,24 +633,17 @@ def _map_thinking_delta(self, choice: chat_completion_chunk.Choice): content=thinking_part.content, provider_name=self._provider_name, ) + else: + return super()._map_thinking_delta(choice) @override - def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, str] | None: + def _map_provider_details(self, chunk: chat.ChatCompletionChunk) -> dict[str, Any] | None: assert isinstance(chunk, _OpenRouterChatCompletionChunk) if provider_details := super()._map_provider_details(chunk): - if provider := chunk.provider: # pragma: lax no cover - provider_details['downstream_provider'] = provider - - if native_finish_reason := chunk.choices[0].native_finish_reason: # pragma: lax no cover - provider_details['native_finish_reason'] = native_finish_reason - + provider_details.update(_map_openrouter_provider_details(chunk)) return provider_details - @override - def _map_usage(self, response: chat.ChatCompletionChunk): - return _map_usage(response, self._provider_name, self._provider_url, self._model_name) - @override def _map_finish_reason( # type: ignore[reportIncompatibleMethodOverride] self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'error'] diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index ebec3433aa..8b3a0e8e1e 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -10,6 +10,7 @@ ModelHTTPError, ModelMessage, ModelRequest, + ModelResponse, PartEndEvent, PartStartEvent, RunUsage, @@ -278,11 +279,18 @@ async def test_openrouter_usage(allow_model_requests: None, openrouter_api_key: RunUsage( input_tokens=17, output_tokens=2177, - details={'cost': 4358, 'is_byok': 0, 'reasoning_tokens': 960}, + details={'is_byok': 0, 'reasoning_tokens': 960, 'image_tokens': 0}, requests=1, ) ) + last_message = result.all_messages()[-1] + + assert isinstance(last_message, ModelResponse) + assert last_message.provider_details is not None + for key in ['cost', 'upstream_inference_cost', 'is_byok']: + assert key in last_message.provider_details + async def test_openrouter_validate_non_json_response(openrouter_api_key: str) -> None: provider = OpenRouterProvider(api_key=openrouter_api_key) From d41d0b681c37d5d2b13ac4cf0be9043d4217a6d1 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 19 Nov 2025 11:16:22 -0600 Subject: [PATCH 35/42] add docs --- docs/models/openai.md | 30 +---------- docs/models/openrouter.md | 51 +++++++++++++++++++ .../pydantic_ai/models/__init__.py | 5 +- .../pydantic_ai/models/openrouter.py | 5 +- .../pydantic_ai/providers/openrouter.py | 18 +++++++ 5 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 docs/models/openrouter.md diff --git a/docs/models/openai.md b/docs/models/openai.md index 6c246481bf..2eae27bd03 100644 --- a/docs/models/openai.md +++ b/docs/models/openai.md @@ -233,7 +233,7 @@ agent = Agent(model) ``` Various providers also have their own provider classes so that you don't need to specify the base URL yourself and you can use the standard `_API_KEY` environment variable to set the API key. -When a provider has its own provider class, you can use the `Agent(":")` shorthand, e.g. `Agent("deepseek:deepseek-chat")` or `Agent("openrouter:google/gemini-2.5-pro-preview")`, instead of building the `OpenAIChatModel` explicitly. Similarly, you can pass the provider name as a string to the `provider` argument on `OpenAIChatModel` instead of building instantiating the provider class explicitly. +When a provider has its own provider class, you can use the `Agent(":")` shorthand, e.g. `Agent("deepseek:deepseek-chat")` or `Agent("moonshotai:kimi-k2-0711-preview")`, instead of building the `OpenAIChatModel` explicitly. Similarly, you can pass the provider name as a string to the `provider` argument on `OpenAIChatModel` instead of building instantiating the provider class explicitly. #### Model Profile @@ -385,34 +385,6 @@ agent = Agent(model) ... ``` -### OpenRouter - -To use [OpenRouter](https://openrouter.ai), first create an API key at [openrouter.ai/keys](https://openrouter.ai/keys). - -You can set the `OPENROUTER_API_KEY` environment variable and use [`OpenRouterProvider`][pydantic_ai.providers.openrouter.OpenRouterProvider] by name: - -```python -from pydantic_ai import Agent - -agent = Agent('openrouter:anthropic/claude-3.5-sonnet') -... -``` - -Or initialise the model and provider directly: - -```python -from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openrouter import OpenRouterProvider - -model = OpenAIChatModel( - 'anthropic/claude-3.5-sonnet', - provider=OpenRouterProvider(api_key='your-openrouter-api-key'), -) -agent = Agent(model) -... -``` - ### Vercel AI Gateway To use [Vercel's AI Gateway](https://vercel.com/docs/ai-gateway), first follow the [documentation](https://vercel.com/docs/ai-gateway) instructions on obtaining an API key or OIDC token. diff --git a/docs/models/openrouter.md b/docs/models/openrouter.md new file mode 100644 index 0000000000..0ec36d5342 --- /dev/null +++ b/docs/models/openrouter.md @@ -0,0 +1,51 @@ +### OpenRouter + +## Install + +To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openai` optional group: + +```bash +pip/uv-add "pydantic-ai-slim[openai]" +``` + +## Configuration + +To use [OpenRouter](https://openrouter.ai), first create an API key at [openrouter.ai/keys](https://openrouter.ai/keys). + +You can set the `OPENROUTER_API_KEY` environment variable and use [`OpenRouterProvider`][pydantic_ai.providers.openrouter.OpenRouterProvider] by name: + +```python +from pydantic_ai import Agent + +agent = Agent('openrouter:anthropic/claude-3.5-sonnet') +... +``` + +Or initialise the model and provider directly: + +```python +from pydantic_ai import Agent +from pydantic_ai.models.openrouter import OpenRouterModel +from pydantic_ai.providers.openrouter import OpenRouterProvider + +model = OpenRouterModel( + 'anthropic/claude-3.5-sonnet', + provider=OpenRouterProvider(api_key='your-openrouter-api-key'), +) +agent = Agent(model) +... +``` + +You can set the `x_title` and `http_referer` parameters in the provider to enable [app attribution](https://openrouter.ai/docs/app-attribution): + + +```python +from pydantic_ai.providers.openrouter import OpenRouterProvider + +provider=OpenRouterProvider( + api_key='your-openrouter-api-key', + http_referer='https://your-app.com', + x_title='Your App', +), +... +``` diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index b43681b0a4..a9b3789855 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -807,7 +807,6 @@ def infer_model( # noqa: C901 'heroku', 'moonshotai', 'ollama', - 'openrouter', 'together', 'vercel', 'litellm', @@ -838,6 +837,10 @@ def infer_model( # noqa: C901 from .cohere import CohereModel return CohereModel(model_name, provider=provider) + elif model_kind == 'openrouter': + from .openrouter import OpenRouterModel + + return OpenRouterModel(model_name, provider=provider) elif model_kind == 'mistral': from .mistral import MistralModel diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 00d0150c44..14597be821 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -20,12 +20,13 @@ ToolCallPart, ) from ..profiles import ModelProfileSpec +from ..providers import Provider from ..providers.openrouter import OpenRouterProvider from ..settings import ModelSettings from . import ModelRequestParameters try: - from openai import APIError + from openai import APIError, AsyncOpenAI from openai.types import chat, completion_usage from openai.types.chat import chat_completion, chat_completion_chunk @@ -471,7 +472,7 @@ def __init__( self, model_name: str, *, - provider: OpenRouterProvider | None = None, + provider: Literal['openrouter'] | Provider[AsyncOpenAI] = 'openrouter', profile: ModelProfileSpec | None = None, settings: ModelSettings | None = None, ): diff --git a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py index 0e55229556..2ee092a99f 100644 --- a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py @@ -102,6 +102,24 @@ def __init__( openai_client: AsyncOpenAI | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: + """Configure the provider with either an API key or prebuilt client. + + Args: + api_key: OpenRouter API key. Falls back to ``OPENROUTER_API_KEY`` + when omitted and required unless ``openai_client`` is provided. + http_referer: Optional attribution header, falling back to + ``OPENROUTER_HTTP_REFERER``. + x_title: Optional attribution header, falling back to + ``OPENROUTER_X_TITLE``. + openai_client: Existing ``AsyncOpenAI`` client to reuse instead of + creating one internally. + http_client: Custom ``httpx.AsyncClient`` to pass into the + ``AsyncOpenAI`` constructor when building a client. + + Raises: + UserError: If no API key is available and no ``openai_client`` is + provided. + """ api_key = api_key or os.getenv('OPENROUTER_API_KEY') if not api_key and openai_client is None: raise UserError( From a4c429e3b76ca475953c66658e0d1502c6237354 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 19 Nov 2025 11:16:22 -0600 Subject: [PATCH 36/42] add docs --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 5f827ae71b..6cdc4a386d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - models/google.md - models/bedrock.md - models/cohere.md + - models/openrouter.md - models/groq.md - models/mistral.md - models/huggingface.md From 79fbc20e309a3cde56272363c28289e1212fb051 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 19 Nov 2025 11:16:22 -0600 Subject: [PATCH 37/42] add docs --- docs/models/openrouter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/openrouter.md b/docs/models/openrouter.md index 0ec36d5342..fa2aea74bc 100644 --- a/docs/models/openrouter.md +++ b/docs/models/openrouter.md @@ -1,4 +1,4 @@ -### OpenRouter +# OpenRouter ## Install From 1079522e51d54058fb88c745e0fc4d53679974ff Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 19 Nov 2025 14:59:32 -0600 Subject: [PATCH 38/42] refacotr _map_model_response with context --- pydantic_ai_slim/pydantic_ai/models/openai.py | 109 ++++++++++++++---- .../pydantic_ai/models/openrouter.py | 58 +++------- 2 files changed, 105 insertions(+), 62 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 919771d5b6..4ac1ee9556 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -707,42 +707,105 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete f'`{tool.__class__.__name__}` is not supported by `OpenAIChatModel`. If it should be, please file an issue.' ) + @dataclass + class _MapModelResposeContext: + """Context object for mapping a `ModelResponse` to OpenAI chat completion parameters. + + This class is designed to be subclassed to add new fields for custom logic, + collecting various parts of the model response (like text and tool calls) + to form a single assistant message. + """ + texts: list[str] = field(default_factory=list) + tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = field(default_factory=list) + + def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: + """Converts the collected texts and tool calls into a single OpenAI `ChatCompletionAssistantMessageParam`. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for how collected parts are transformed into the final message parameter. + + Returns: + An OpenAI `ChatCompletionAssistantMessageParam` object representing the assistant's response. + """ + message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') + if self.texts: + # Note: model responses from this model should only have one text item, so the following + # shouldn't merge multiple texts into one unless you switch models between runs: + message_param['content'] = '\n\n'.join(self.texts) + else: + message_param['content'] = None + if self.tool_calls: + message_param['tool_calls'] = self.tool_calls + return message_param + + def _map_response_text_part(self, ctx: _MapModelResposeContext, item: TextPart) -> None: + """Maps a `TextPart` to the response context. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling text parts. + """ + ctx.texts.append(item.content) + + def _map_response_thinking_part(self, ctx: _MapModelResposeContext, item: ThinkingPart) -> None: + """Maps a `ThinkingPart` to the response context. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling thinking parts. + """ + # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, + # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. + # If you need this changed, please file an issue. + start_tag, end_tag = self.profile.thinking_tags + ctx.texts.append('\n'.join([start_tag, item.content, end_tag])) + + def _map_response_tool_call_part(self, ctx: _MapModelResposeContext, item: ToolCallPart) -> None: + """Maps a `ToolCallPart` to the response context. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling tool call parts. + """ + ctx.tool_calls.append(self._map_tool_call(item)) + + def _map_response_builtin_part( + self, ctx: _MapModelResposeContext, item: BuiltinToolCallPart | BuiltinToolReturnPart + ) -> None: + """Maps a built-in tool call or return part to the response context. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling built-in tool parts. + """ + # OpenAI doesn't return built-in tool calls + pass + + def _map_response_file_part(self, ctx: _MapModelResposeContext, item: FilePart) -> None: + """Maps a `FilePart` to the response context. + + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling file parts. + """ + # Files generated by models are not sent back to models that don't themselves generate files. + pass + def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: """Hook that determines how `ModelResponse` is mapped into `ChatCompletionMessageParam` objects before sending. Subclasses of `OpenAIChatModel` may override this method to provide their own mapping logic. """ - texts: list[str] = [] - tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = [] + ctx = self._MapModelResposeContext() for item in message.parts: if isinstance(item, TextPart): - texts.append(item.content) + self._map_response_text_part(ctx, item) elif isinstance(item, ThinkingPart): - # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, - # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. - # If you need this changed, please file an issue. - start_tag, end_tag = self.profile.thinking_tags - texts.append('\n'.join([start_tag, item.content, end_tag])) + self._map_response_thinking_part(ctx, item) elif isinstance(item, ToolCallPart): - tool_calls.append(self._map_tool_call(item)) - # OpenAI doesn't return built-in tool calls + self._map_response_tool_call_part(ctx, item) elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover - pass + self._map_response_builtin_part(ctx, item) elif isinstance(item, FilePart): # pragma: no cover - # Files generated by models are not sent back to models that don't themselves generate files. - pass + self._map_response_file_part(ctx, item) else: assert_never(item) - message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') - if texts: - # Note: model responses from this model should only have one text item, so the following - # shouldn't merge multiple texts into one unless you switch models between runs: - message_param['content'] = '\n\n'.join(texts) - else: - message_param['content'] = None - if tool_calls: - message_param['tool_calls'] = tool_calls - return message_param + return ctx.into_message_param() def _map_finish_reason( self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 14597be821..91b0fa86a5 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,7 +1,7 @@ from __future__ import annotations as _annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Literal, cast from pydantic import BaseModel @@ -9,15 +9,9 @@ from ..exceptions import ModelHTTPError, UnexpectedModelBehavior from ..messages import ( - BuiltinToolCallPart, - BuiltinToolReturnPart, - FilePart, FinishReason, - ModelResponse, ModelResponseStreamEvent, - TextPart, ThinkingPart, - ToolCallPart, ) from ..profiles import ModelProfileSpec from ..providers import Provider @@ -522,40 +516,26 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, provider_details.update(_map_openrouter_provider_details(response)) return provider_details + @dataclass + class _MapModelResposeContext(OpenAIChatModel._MapModelResposeContext): # type: ignore[reportPrivateUsage] + reasoning_details: list[dict[str, Any]] = field(default_factory=list) + + def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: + message_param = super().into_message_param() + if self.reasoning_details: + message_param['reasoning_details'] = self.reasoning_details # type: ignore[reportGeneralTypeIssues] + return message_param + @override - def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: - texts: list[str] = [] - tool_calls: list[chat.ChatCompletionMessageFunctionToolCallParam] = [] - reasoning_details: list[dict[str, Any]] = [] - for item in message.parts: - if isinstance(item, TextPart): - texts.append(item.content) - elif isinstance(item, ThinkingPart): - if item.provider_name == self.system: - reasoning_details.append(_into_reasoning_detail(item).model_dump()) - elif content := item.content: # pragma: lax no cover - start_tag, end_tag = self.profile.thinking_tags - texts.append('\n'.join([start_tag, content, end_tag])) - else: - pass - elif isinstance(item, ToolCallPart): - tool_calls.append(self._map_tool_call(item)) - elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover - pass - elif isinstance(item, FilePart): # pragma: no cover - pass - else: - assert_never(item) - message_param = chat.ChatCompletionAssistantMessageParam(role='assistant') - if texts: - message_param['content'] = '\n\n'.join(texts) + def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResposeContext, item: ThinkingPart) -> None: + assert isinstance(ctx, self._MapModelResposeContext) + if item.provider_name == self.system: + ctx.reasoning_details.append(_into_reasoning_detail(item).model_dump()) + elif content := item.content: # pragma: lax no cover + start_tag, end_tag = self.profile.thinking_tags + ctx.texts.append('\n'.join([start_tag, content, end_tag])) else: - message_param['content'] = None - if tool_calls: - message_param['tool_calls'] = tool_calls - if reasoning_details: - message_param['reasoning_details'] = reasoning_details # type: ignore[reportGeneralTypeIssues] - return message_param + pass @property @override From 33c9e897a33771f67c186b5a1832fb6af90fe1f4 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Wed, 19 Nov 2025 15:53:52 -0600 Subject: [PATCH 39/42] fix linting --- pydantic_ai_slim/pydantic_ai/models/openai.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 4ac1ee9556..4f7d9f7799 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -715,6 +715,7 @@ class _MapModelResposeContext: collecting various parts of the model response (like text and tool calls) to form a single assistant message. """ + texts: list[str] = field(default_factory=list) tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = field(default_factory=list) From 776bcb4823c50c2c3ed3ab1b985a08fb007c35bf Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 20 Nov 2025 12:11:37 -0600 Subject: [PATCH 40/42] refactor reasoning detail conversion --- docs/models/openrouter.md | 13 +-- pydantic_ai_slim/pydantic_ai/messages.py | 6 ++ pydantic_ai_slim/pydantic_ai/models/openai.py | 14 ++-- .../pydantic_ai/models/openrouter.py | 79 +++++++------------ .../pydantic_ai/providers/openrouter.py | 20 ++--- pydantic_ai_slim/pyproject.toml | 1 + tests/models/test_openrouter.py | 12 ++- tests/providers/test_openrouter.py | 2 +- uv.lock | 6 +- 9 files changed, 70 insertions(+), 83 deletions(-) diff --git a/docs/models/openrouter.md b/docs/models/openrouter.md index fa2aea74bc..749c0497c4 100644 --- a/docs/models/openrouter.md +++ b/docs/models/openrouter.md @@ -2,10 +2,10 @@ ## Install -To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openai` optional group: +To use `OpenRouterModel`, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openrouter` optional group: ```bash -pip/uv-add "pydantic-ai-slim[openai]" +pip/uv-add "pydantic-ai-slim[openrouter]" ``` ## Configuration @@ -36,16 +36,19 @@ agent = Agent(model) ... ``` -You can set the `x_title` and `http_referer` parameters in the provider to enable [app attribution](https://openrouter.ai/docs/app-attribution): +## App Attribution +OpenRouter has an [app attribution](https://openrouter.ai/docs/app-attribution) feature to track your application in their public ranking and analytics. + +You can pass in an 'app_url' and 'app_title' when initializing the provider to enable app attribution. ```python from pydantic_ai.providers.openrouter import OpenRouterProvider provider=OpenRouterProvider( api_key='your-openrouter-api-key', - http_referer='https://your-app.com', - x_title='Your App', + app_url='https://your-app.com', + app_title='Your App', ), ... ``` diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 1f3b5cd6e5..7bd8b184c5 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -1042,6 +1042,12 @@ class ThinkingPart: part_kind: Literal['thinking'] = 'thinking' """Part type identifier, this is available on all parts as a discriminator.""" + provider_details: dict[str, Any] | None = None + """Additional provider-specific details in a serializable format. + + This allows storing selected vendor-specific data that isn't mapped to standard ThinkingPart fields. + """ + def has_content(self) -> bool: """Return `True` if the thinking content is non-empty.""" return bool(self.content) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 4f7d9f7799..196b42c293 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -708,7 +708,7 @@ def _get_web_search_options(self, model_request_parameters: ModelRequestParamete ) @dataclass - class _MapModelResposeContext: + class _MapModelResponseContext: """Context object for mapping a `ModelResponse` to OpenAI chat completion parameters. This class is designed to be subclassed to add new fields for custom logic, @@ -739,7 +739,7 @@ def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: message_param['tool_calls'] = self.tool_calls return message_param - def _map_response_text_part(self, ctx: _MapModelResposeContext, item: TextPart) -> None: + def _map_response_text_part(self, ctx: _MapModelResponseContext, item: TextPart) -> None: """Maps a `TextPart` to the response context. This method serves as a hook that can be overridden by subclasses @@ -747,7 +747,7 @@ def _map_response_text_part(self, ctx: _MapModelResposeContext, item: TextPart) """ ctx.texts.append(item.content) - def _map_response_thinking_part(self, ctx: _MapModelResposeContext, item: ThinkingPart) -> None: + def _map_response_thinking_part(self, ctx: _MapModelResponseContext, item: ThinkingPart) -> None: """Maps a `ThinkingPart` to the response context. This method serves as a hook that can be overridden by subclasses @@ -759,7 +759,7 @@ def _map_response_thinking_part(self, ctx: _MapModelResposeContext, item: Thinki start_tag, end_tag = self.profile.thinking_tags ctx.texts.append('\n'.join([start_tag, item.content, end_tag])) - def _map_response_tool_call_part(self, ctx: _MapModelResposeContext, item: ToolCallPart) -> None: + def _map_response_tool_call_part(self, ctx: _MapModelResponseContext, item: ToolCallPart) -> None: """Maps a `ToolCallPart` to the response context. This method serves as a hook that can be overridden by subclasses @@ -768,7 +768,7 @@ def _map_response_tool_call_part(self, ctx: _MapModelResposeContext, item: ToolC ctx.tool_calls.append(self._map_tool_call(item)) def _map_response_builtin_part( - self, ctx: _MapModelResposeContext, item: BuiltinToolCallPart | BuiltinToolReturnPart + self, ctx: _MapModelResponseContext, item: BuiltinToolCallPart | BuiltinToolReturnPart ) -> None: """Maps a built-in tool call or return part to the response context. @@ -778,7 +778,7 @@ def _map_response_builtin_part( # OpenAI doesn't return built-in tool calls pass - def _map_response_file_part(self, ctx: _MapModelResposeContext, item: FilePart) -> None: + def _map_response_file_part(self, ctx: _MapModelResponseContext, item: FilePart) -> None: """Maps a `FilePart` to the response context. This method serves as a hook that can be overridden by subclasses @@ -792,7 +792,7 @@ def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMess Subclasses of `OpenAIChatModel` may override this method to provide their own mapping logic. """ - ctx = self._MapModelResposeContext() + ctx = self._MapModelResponseContext() for item in message.parts: if isinstance(item, TextPart): self._map_response_text_part(ctx, item) diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index 91b0fa86a5..a098638b18 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,10 +1,10 @@ from __future__ import annotations as _annotations from collections.abc import Iterable -from dataclasses import dataclass, field -from typing import Any, Literal, cast +from dataclasses import asdict, dataclass, field +from typing import Annotated, Any, Literal, cast -from pydantic import BaseModel +from pydantic import AliasChoices, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict, assert_never, override from ..exceptions import ModelHTTPError, UnexpectedModelBehavior @@ -246,95 +246,69 @@ class _BaseReasoningDetail(BaseModel, frozen=True): id: str | None = None format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None index: int | None - type: Literal['reasoning.text', 'reasoning.summary', 'reasoning.encrypted'] class _ReasoningSummary(_BaseReasoningDetail, frozen=True): """Represents a high-level summary of the reasoning process.""" type: Literal['reasoning.summary'] - summary: str + summary: str = Field(validation_alias=AliasChoices('summary', 'content')) class _ReasoningEncrypted(_BaseReasoningDetail, frozen=True): """Represents encrypted reasoning data.""" type: Literal['reasoning.encrypted'] - data: str + data: str = Field(validation_alias=AliasChoices('data', 'signature')) class _ReasoningText(_BaseReasoningDetail, frozen=True): """Represents raw text reasoning.""" type: Literal['reasoning.text'] - text: str + text: str = Field(validation_alias=AliasChoices('text', 'content')) signature: str | None = None -_OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText +_OpenRouterReasoningDetail = Annotated[ + _ReasoningSummary | _ReasoningEncrypted | _ReasoningText, Field(discriminator='type') +] +_openrouter_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail) def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart: provider_name = 'openrouter' - reasoning_id = reasoning.model_dump_json(include={'id', 'format', 'index', 'type'}) + provider_details = reasoning.model_dump(include={'format', 'index', 'type'}) if isinstance(reasoning, _ReasoningText): return ThinkingPart( - id=reasoning_id, + id=reasoning.id, content=reasoning.text, signature=reasoning.signature, provider_name=provider_name, + provider_details=provider_details, ) elif isinstance(reasoning, _ReasoningSummary): return ThinkingPart( - id=reasoning_id, - content=reasoning.summary, - provider_name=provider_name, + id=reasoning.id, content=reasoning.summary, provider_name=provider_name, provider_details=provider_details ) elif isinstance(reasoning, _ReasoningEncrypted): return ThinkingPart( - id=reasoning_id, + id=reasoning.id, content='', signature=reasoning.data, provider_name=provider_name, + provider_details=provider_details, ) else: assert_never(reasoning) def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail: - if thinking_part.id is None: # pragma: lax no cover - raise UnexpectedModelBehavior('OpenRouter thinking part has no ID') - - data = _BaseReasoningDetail.model_validate_json(thinking_part.id) - - if data.type == 'reasoning.text': - return _ReasoningText( - type=data.type, - id=data.id, - format=data.format, - index=data.index, - text=thinking_part.content, - signature=thinking_part.signature, - ) - elif data.type == 'reasoning.summary': - return _ReasoningSummary( - type=data.type, - id=data.id, - format=data.format, - index=data.index, - summary=thinking_part.content, - ) - elif data.type == 'reasoning.encrypted': - assert thinking_part.signature is not None - return _ReasoningEncrypted( - type=data.type, - id=data.id, - format=data.format, - index=data.index, - data=thinking_part.signature, - ) - else: - assert_never(data.type) + if thinking_part.provider_details is None: # pragma: lax no cover + raise UnexpectedModelBehavior('OpenRouter thinking part has no provider_details') + thinking_part_dict = asdict(thinking_part) + thinking_part_dict.update(thinking_part_dict.pop('provider_details')) + return _openrouter_reasoning_detail_adapter.validate_python(thinking_part_dict) class _OpenRouterCompletionMessage(chat.ChatCompletionMessage): @@ -363,7 +337,8 @@ class _OpenRouterChoice(chat_completion.Choice): """A wrapped chat completion message with OpenRouter specific attributes.""" -class _OpenRouterCostDetails(BaseModel): +@dataclass +class _OpenRouterCostDetails: """OpenRouter specific cost details.""" upstream_inference_cost: int | None = None @@ -417,7 +392,7 @@ def _map_openrouter_provider_details( provider_details: dict[str, Any] = {} provider_details['downstream_provider'] = response.provider - provider_details['native_finish_reason'] = response.choices[0].native_finish_reason + provider_details['finish_reason'] = response.choices[0].native_finish_reason if usage := response.usage: if cost := usage.cost: @@ -517,7 +492,7 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, return provider_details @dataclass - class _MapModelResposeContext(OpenAIChatModel._MapModelResposeContext): # type: ignore[reportPrivateUsage] + class _MapModelResponseContext(OpenAIChatModel._MapModelResponseContext): # type: ignore[reportPrivateUsage] reasoning_details: list[dict[str, Any]] = field(default_factory=list) def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: @@ -527,8 +502,8 @@ def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: return message_param @override - def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResposeContext, item: ThinkingPart) -> None: - assert isinstance(ctx, self._MapModelResposeContext) + def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResponseContext, item: ThinkingPart) -> None: + assert isinstance(ctx, self._MapModelResponseContext) if item.provider_name == self.system: ctx.reasoning_details.append(_into_reasoning_detail(item).model_dump()) elif content := item.content: # pragma: lax no cover diff --git a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py index 2ee092a99f..70f962e047 100644 --- a/pydantic_ai_slim/pydantic_ai/providers/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/providers/openrouter.py @@ -82,10 +82,10 @@ def __init__(self, *, api_key: str) -> None: ... def __init__(self, *, api_key: str, http_client: httpx.AsyncClient) -> None: ... @overload - def __init__(self, *, api_key: str, http_referer: str, x_title: str) -> None: ... + def __init__(self, *, api_key: str, app_url: str, app_title: str) -> None: ... @overload - def __init__(self, *, api_key: str, http_referer: str, x_title: str, http_client: httpx.AsyncClient) -> None: ... + def __init__(self, *, api_key: str, app_url: str, app_title: str, http_client: httpx.AsyncClient) -> None: ... @overload def __init__(self, *, http_client: httpx.AsyncClient) -> None: ... @@ -97,8 +97,8 @@ def __init__( self, *, api_key: str | None = None, - http_referer: str | None = None, - x_title: str | None = None, + app_url: str | None = None, + app_title: str | None = None, openai_client: AsyncOpenAI | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: @@ -107,10 +107,10 @@ def __init__( Args: api_key: OpenRouter API key. Falls back to ``OPENROUTER_API_KEY`` when omitted and required unless ``openai_client`` is provided. - http_referer: Optional attribution header, falling back to - ``OPENROUTER_HTTP_REFERER``. - x_title: Optional attribution header, falling back to - ``OPENROUTER_X_TITLE``. + app_url: Optional url for app attribution. Falls back to + ``OPENROUTER_APP_URL`` when omitted. + app_title: Optional title for app attribution. Falls back to + ``OPENROUTER_APP_TITLE`` when omitted. openai_client: Existing ``AsyncOpenAI`` client to reuse instead of creating one internally. http_client: Custom ``httpx.AsyncClient`` to pass into the @@ -128,9 +128,9 @@ def __init__( ) attribution_headers: dict[str, str] = {} - if http_referer := http_referer or os.getenv('OPENROUTER_HTTP_REFERER'): + if http_referer := app_url or os.getenv('OPENROUTER_APP_URL'): attribution_headers['HTTP-Referer'] = http_referer - if x_title := x_title or os.getenv('OPENROUTER_X_TITLE'): + if x_title := app_title or os.getenv('OPENROUTER_APP_TITLE'): attribution_headers['X-Title'] = x_title if openai_client is not None: diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 6e815a4f52..7fb6c6aba1 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -73,6 +73,7 @@ vertexai = ["google-auth>=2.36.0", "requests>=2.32.2"] google = ["google-genai>=1.51.0"] anthropic = ["anthropic>=0.70.0"] groq = ["groq>=0.25.0"] +openrouter = ["openai>=2.8.0"] mistral = ["mistralai>=1.9.10"] bedrock = ["boto3>=1.40.14"] huggingface = ["huggingface-hub[inference]>=0.33.5"] diff --git a/tests/models/test_openrouter.py b/tests/models/test_openrouter.py index 8b3a0e8e1e..daeb3080d1 100644 --- a/tests/models/test_openrouter.py +++ b/tests/models/test_openrouter.py @@ -74,7 +74,7 @@ async def test_openrouter_with_native_options(allow_model_requests: None, openro ) assert response.provider_details is not None assert response.provider_details['downstream_provider'] == 'xAI' - assert response.provider_details['native_finish_reason'] == 'stop' + assert response.provider_details['finish_reason'] == 'stop' async def test_openrouter_stream_with_native_options(allow_model_requests: None, openrouter_api_key: str) -> None: @@ -95,9 +95,7 @@ async def test_openrouter_stream_with_native_options(allow_model_requests: None, _ = [chunk async for chunk in stream] - assert stream.provider_details == snapshot( - {'finish_reason': 'stop', 'downstream_provider': 'xAI', 'native_finish_reason': 'completed'} - ) + assert stream.provider_details == snapshot({'finish_reason': 'completed', 'downstream_provider': 'xAI'}) assert stream.finish_reason == snapshot('stop') @@ -113,7 +111,7 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open assert thinking_event_start.part == snapshot( ThinkingPart( content='', - id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}', + id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter', ) ) @@ -123,7 +121,7 @@ async def test_openrouter_stream_with_reasoning(allow_model_requests: None, open assert thinking_event_end.part == snapshot( ThinkingPart( content='', - id='{"id":"rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f","format":"openai-responses-v1","index":0,"type":"reasoning.encrypted"}', + id='rs_0aa4f2c435e6d1dc0169082486816c8193a029b5fc4ef1764f', provider_name='openrouter', ) ) @@ -206,7 +204,7 @@ async def test_openrouter_with_reasoning(allow_model_requests: None, openrouter_ thinking_part = response.parts[0] assert isinstance(thinking_part, ThinkingPart) - assert thinking_part.id == snapshot('{"id":null,"format":"unknown","index":0,"type":"reasoning.text"}') + assert thinking_part.id == snapshot(None) assert thinking_part.content is not None assert thinking_part.signature is None diff --git a/tests/providers/test_openrouter.py b/tests/providers/test_openrouter.py index a070b936b7..400c789fb0 100644 --- a/tests/providers/test_openrouter.py +++ b/tests/providers/test_openrouter.py @@ -45,7 +45,7 @@ def test_openrouter_provider(): def test_openrouter_provider_with_app_attribution(): - provider = OpenRouterProvider(api_key='api-key', http_referer='test.com', x_title='test') + provider = OpenRouterProvider(api_key='api-key', app_url='test.com', app_title='test') assert provider.name == 'openrouter' assert provider.base_url == 'https://openrouter.ai/api/v1' assert isinstance(provider.client, openai.AsyncOpenAI) diff --git a/uv.lock b/uv.lock index b9bb2f6a6d..7fdbb949d5 100644 --- a/uv.lock +++ b/uv.lock @@ -5612,6 +5612,9 @@ mistral = [ openai = [ { name = "openai" }, ] +openrouter = [ + { name = "openai" }, +] outlines-llamacpp = [ { name = "outlines", extra = ["llamacpp"] }, ] @@ -5679,6 +5682,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.12.3" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=1.9.10" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.107.2" }, + { name = "openai", marker = "extra == 'openrouter'", specifier = ">=2.8.0" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "outlines", marker = "extra == 'outlines-vllm-offline'", specifier = ">=1.0.0,<1.3.0" }, { name = "outlines", extras = ["llamacpp"], marker = "extra == 'outlines-llamacpp'", specifier = ">=1.0.0,<1.3.0" }, @@ -5706,7 +5710,7 @@ requires-dist = [ { name = "typing-inspection", specifier = ">=0.4.0" }, { name = "vllm", marker = "(python_full_version < '3.12' and platform_machine != 'x86_64' and extra == 'outlines-vllm-offline') or (python_full_version < '3.12' and sys_platform != 'darwin' and extra == 'outlines-vllm-offline')" }, ] -provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "dbos", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "outlines-llamacpp", "outlines-mlxlm", "outlines-sglang", "outlines-transformers", "outlines-vllm-offline", "prefect", "retries", "tavily", "temporal", "ui", "vertexai"] +provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "dbos", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "openrouter", "outlines-llamacpp", "outlines-mlxlm", "outlines-sglang", "outlines-transformers", "outlines-vllm-offline", "prefect", "retries", "tavily", "temporal", "ui", "vertexai"] [[package]] name = "pydantic-core" From 2568fda9031e478e6776b0d15ee839289a56383b Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 20 Nov 2025 17:34:25 -0600 Subject: [PATCH 41/42] Update docs/models/openrouter.md Co-authored-by: Douwe Maan --- docs/models/openrouter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/openrouter.md b/docs/models/openrouter.md index 749c0497c4..dbcf9a818d 100644 --- a/docs/models/openrouter.md +++ b/docs/models/openrouter.md @@ -40,7 +40,7 @@ agent = Agent(model) OpenRouter has an [app attribution](https://openrouter.ai/docs/app-attribution) feature to track your application in their public ranking and analytics. -You can pass in an 'app_url' and 'app_title' when initializing the provider to enable app attribution. +You can pass in an `app_url` and `app_title` when initializing the provider to enable app attribution. ```python from pydantic_ai.providers.openrouter import OpenRouterProvider From e3da0051ad1aaef3088bc2e2995a1723fa7043bb Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Thu, 20 Nov 2025 18:57:24 -0600 Subject: [PATCH 42/42] make _into_reasoning_detail more explicit --- pydantic_ai_slim/pydantic_ai/models/openai.py | 110 +++++++++--------- .../pydantic_ai/models/openrouter.py | 83 ++++++++----- 2 files changed, 111 insertions(+), 82 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 196b42c293..33a281974d 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -716,10 +716,28 @@ class _MapModelResponseContext: to form a single assistant message. """ + _model: OpenAIChatModel + texts: list[str] = field(default_factory=list) tool_calls: list[ChatCompletionMessageFunctionToolCallParam] = field(default_factory=list) - def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: + def map_assistant_message(self, message: ModelResponse) -> chat.ChatCompletionAssistantMessageParam: + for item in message.parts: + if isinstance(item, TextPart): + self._map_response_text_part(item) + elif isinstance(item, ThinkingPart): + self._map_response_thinking_part(item) + elif isinstance(item, ToolCallPart): + self._map_response_tool_call_part(item) + elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover + self._map_response_builtin_part(item) + elif isinstance(item, FilePart): # pragma: no cover + self._map_response_file_part(item) + else: + assert_never(item) + return self._into_message_param() + + def _into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: """Converts the collected texts and tool calls into a single OpenAI `ChatCompletionAssistantMessageParam`. This method serves as a hook that can be overridden by subclasses @@ -739,74 +757,58 @@ def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: message_param['tool_calls'] = self.tool_calls return message_param - def _map_response_text_part(self, ctx: _MapModelResponseContext, item: TextPart) -> None: - """Maps a `TextPart` to the response context. + def _map_response_text_part(self, item: TextPart) -> None: + """Maps a `TextPart` to the response context. - This method serves as a hook that can be overridden by subclasses - to implement custom logic for handling text parts. - """ - ctx.texts.append(item.content) + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling text parts. + """ + self.texts.append(item.content) - def _map_response_thinking_part(self, ctx: _MapModelResponseContext, item: ThinkingPart) -> None: - """Maps a `ThinkingPart` to the response context. + def _map_response_thinking_part(self, item: ThinkingPart) -> None: + """Maps a `ThinkingPart` to the response context. - This method serves as a hook that can be overridden by subclasses - to implement custom logic for handling thinking parts. - """ - # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, - # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. - # If you need this changed, please file an issue. - start_tag, end_tag = self.profile.thinking_tags - ctx.texts.append('\n'.join([start_tag, item.content, end_tag])) + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling thinking parts. + """ + # NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model, + # but we currently just send it in `` tags anyway as we don't want DeepSeek-specific checks here. + # If you need this changed, please file an issue. + start_tag, end_tag = self._model.profile.thinking_tags + self.texts.append('\n'.join([start_tag, item.content, end_tag])) - def _map_response_tool_call_part(self, ctx: _MapModelResponseContext, item: ToolCallPart) -> None: - """Maps a `ToolCallPart` to the response context. + def _map_response_tool_call_part(self, item: ToolCallPart) -> None: + """Maps a `ToolCallPart` to the response context. - This method serves as a hook that can be overridden by subclasses - to implement custom logic for handling tool call parts. - """ - ctx.tool_calls.append(self._map_tool_call(item)) + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling tool call parts. + """ + self.tool_calls.append(self._model._map_tool_call(item)) - def _map_response_builtin_part( - self, ctx: _MapModelResponseContext, item: BuiltinToolCallPart | BuiltinToolReturnPart - ) -> None: - """Maps a built-in tool call or return part to the response context. + def _map_response_builtin_part(self, item: BuiltinToolCallPart | BuiltinToolReturnPart) -> None: + """Maps a built-in tool call or return part to the response context. - This method serves as a hook that can be overridden by subclasses - to implement custom logic for handling built-in tool parts. - """ - # OpenAI doesn't return built-in tool calls - pass + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling built-in tool parts. + """ + # OpenAI doesn't return built-in tool calls + pass - def _map_response_file_part(self, ctx: _MapModelResponseContext, item: FilePart) -> None: - """Maps a `FilePart` to the response context. + def _map_response_file_part(self, item: FilePart) -> None: + """Maps a `FilePart` to the response context. - This method serves as a hook that can be overridden by subclasses - to implement custom logic for handling file parts. - """ - # Files generated by models are not sent back to models that don't themselves generate files. - pass + This method serves as a hook that can be overridden by subclasses + to implement custom logic for handling file parts. + """ + # Files generated by models are not sent back to models that don't themselves generate files. + pass def _map_model_response(self, message: ModelResponse) -> chat.ChatCompletionMessageParam: """Hook that determines how `ModelResponse` is mapped into `ChatCompletionMessageParam` objects before sending. Subclasses of `OpenAIChatModel` may override this method to provide their own mapping logic. """ - ctx = self._MapModelResponseContext() - for item in message.parts: - if isinstance(item, TextPart): - self._map_response_text_part(ctx, item) - elif isinstance(item, ThinkingPart): - self._map_response_thinking_part(ctx, item) - elif isinstance(item, ToolCallPart): - self._map_response_tool_call_part(ctx, item) - elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart): # pragma: no cover - self._map_response_builtin_part(ctx, item) - elif isinstance(item, FilePart): # pragma: no cover - self._map_response_file_part(ctx, item) - else: - assert_never(item) - return ctx.into_message_param() + return self._MapModelResponseContext(self).map_assistant_message(message) def _map_finish_reason( self, key: Literal['stop', 'length', 'tool_calls', 'content_filter', 'function_call'] diff --git a/pydantic_ai_slim/pydantic_ai/models/openrouter.py b/pydantic_ai_slim/pydantic_ai/models/openrouter.py index a098638b18..f4c755e433 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openrouter.py +++ b/pydantic_ai_slim/pydantic_ai/models/openrouter.py @@ -1,13 +1,13 @@ from __future__ import annotations as _annotations from collections.abc import Iterable -from dataclasses import asdict, dataclass, field -from typing import Annotated, Any, Literal, cast +from dataclasses import dataclass, field +from typing import Any, Literal, cast -from pydantic import AliasChoices, BaseModel, Field, TypeAdapter +from pydantic import BaseModel from typing_extensions import TypedDict, assert_never, override -from ..exceptions import ModelHTTPError, UnexpectedModelBehavior +from ..exceptions import ModelHTTPError from ..messages import ( FinishReason, ModelResponseStreamEvent, @@ -246,34 +246,32 @@ class _BaseReasoningDetail(BaseModel, frozen=True): id: str | None = None format: Literal['unknown', 'openai-responses-v1', 'anthropic-claude-v1', 'xai-responses-v1'] | None index: int | None + type: Literal['reasoning.text', 'reasoning.summary', 'reasoning.encrypted'] class _ReasoningSummary(_BaseReasoningDetail, frozen=True): """Represents a high-level summary of the reasoning process.""" type: Literal['reasoning.summary'] - summary: str = Field(validation_alias=AliasChoices('summary', 'content')) + summary: str class _ReasoningEncrypted(_BaseReasoningDetail, frozen=True): """Represents encrypted reasoning data.""" type: Literal['reasoning.encrypted'] - data: str = Field(validation_alias=AliasChoices('data', 'signature')) + data: str class _ReasoningText(_BaseReasoningDetail, frozen=True): """Represents raw text reasoning.""" type: Literal['reasoning.text'] - text: str = Field(validation_alias=AliasChoices('text', 'content')) + text: str signature: str | None = None -_OpenRouterReasoningDetail = Annotated[ - _ReasoningSummary | _ReasoningEncrypted | _ReasoningText, Field(discriminator='type') -] -_openrouter_reasoning_detail_adapter: TypeAdapter[_OpenRouterReasoningDetail] = TypeAdapter(_OpenRouterReasoningDetail) +_OpenRouterReasoningDetail = _ReasoningSummary | _ReasoningEncrypted | _ReasoningText def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPart: @@ -303,12 +301,40 @@ def _from_reasoning_detail(reasoning: _OpenRouterReasoningDetail) -> ThinkingPar assert_never(reasoning) -def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail: +def _into_reasoning_detail(thinking_part: ThinkingPart) -> _OpenRouterReasoningDetail | None: if thinking_part.provider_details is None: # pragma: lax no cover - raise UnexpectedModelBehavior('OpenRouter thinking part has no provider_details') - thinking_part_dict = asdict(thinking_part) - thinking_part_dict.update(thinking_part_dict.pop('provider_details')) - return _openrouter_reasoning_detail_adapter.validate_python(thinking_part_dict) + return None + + data = _BaseReasoningDetail.model_validate(thinking_part.provider_details) + + if data.type == 'reasoning.text': + return _ReasoningText( + type=data.type, + id=thinking_part.id, + format=data.format, + index=data.index, + text=thinking_part.content, + signature=thinking_part.signature, + ) + elif data.type == 'reasoning.summary': + return _ReasoningSummary( + type=data.type, + id=thinking_part.id, + format=data.format, + index=data.index, + summary=thinking_part.content, + ) + elif data.type == 'reasoning.encrypted': + assert thinking_part.signature is not None + return _ReasoningEncrypted( + type=data.type, + id=thinking_part.id, + format=data.format, + index=data.index, + data=thinking_part.signature, + ) + else: + assert_never(data.type) class _OpenRouterCompletionMessage(chat.ChatCompletionMessage): @@ -495,22 +521,23 @@ def _process_provider_details(self, response: chat.ChatCompletion) -> dict[str, class _MapModelResponseContext(OpenAIChatModel._MapModelResponseContext): # type: ignore[reportPrivateUsage] reasoning_details: list[dict[str, Any]] = field(default_factory=list) - def into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: - message_param = super().into_message_param() + def _into_message_param(self) -> chat.ChatCompletionAssistantMessageParam: + message_param = super()._into_message_param() if self.reasoning_details: message_param['reasoning_details'] = self.reasoning_details # type: ignore[reportGeneralTypeIssues] return message_param - @override - def _map_response_thinking_part(self, ctx: OpenAIChatModel._MapModelResponseContext, item: ThinkingPart) -> None: - assert isinstance(ctx, self._MapModelResponseContext) - if item.provider_name == self.system: - ctx.reasoning_details.append(_into_reasoning_detail(item).model_dump()) - elif content := item.content: # pragma: lax no cover - start_tag, end_tag = self.profile.thinking_tags - ctx.texts.append('\n'.join([start_tag, content, end_tag])) - else: - pass + @override + def _map_response_thinking_part(self, item: ThinkingPart) -> None: + assert isinstance(self._model, OpenRouterModel) + if item.provider_name == self._model.system: + if reasoning_detail := _into_reasoning_detail(item): # pragma: lax no cover + self.reasoning_details.append(reasoning_detail.model_dump()) + elif content := item.content: # pragma: lax no cover + start_tag, end_tag = self._model.profile.thinking_tags + self.texts.append('\n'.join([start_tag, content, end_tag])) + else: + pass @property @override