From 227e873c786efd84d2d545ce58cd08f9dd9e7192 Mon Sep 17 00:00:00 2001 From: Anibal Angulo Date: Fri, 3 Oct 2025 18:40:42 -0600 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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()