-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add OpenRouterModel as OpenAIChatModel subclass #3089
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ajac-zero Muchas gracias Anibal!
return new_settings, customized_parameters | ||
|
||
def _process_response(self, response: ChatCompletion | str) -> ModelResponse: | ||
model_response = super()._process_response(response=response) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we've actually fixed #2844 yet. I'd expect us to need to modify that field before calling super()._process_response
which would raise the validation error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to the OpenRouter docs, a response with the error
finish reason will always have a response.error
field, so it should get caught by the response.error
checker below.
Following this logic, a response with this finish reason but no response.error
is probably unintended and should raise an UnexpectedModelBehavior
error. We could change this behavior to simply rewrite the error
finish reason to stop
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, checking _verify_response_is_not_error
first makes it work.
|
||
provider_details: dict[str, str] = {} | ||
|
||
if openrouter_provider := getattr(response, 'provider', None): # pragma: lax no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there more interesting data on the OpenRouter response we could store?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should do this while streaming as well!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So far, I've added native_finish_reason
and reasoning_details
, we could add annotations
as well if you think it belongs there (since I think it is equivalent to the OpenAI annotations)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Annotations/citations we'll get to in #3126. As discussed above, I think we should either fully parse https://github.com/pydantic/pydantic-ai/issues/3126
or omit it for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Undertstood, I'd rather omit it for now so we can later use 3126 as a reference.
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: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also implement this so we can support all thinking models on OpenRouter and fully close #2999?
pydantic-ai/pydantic_ai_slim/pydantic_ai/models/openai.py
Lines 555 to 557 in 9b1913e
# 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. |
I'm guessing the Google one you tried works because of this, which also works without a new model class:
pydantic-ai/pydantic_ai_slim/pydantic_ai/models/openai.py
Lines 549 to 553 in 9b1913e
# The `reasoning` field is only present in gpt-oss via Ollama and OpenRouter. | |
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api | |
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens | |
if reasoning := getattr(choice.message, 'reasoning', None): | |
items.append(ThinkingPart(id='reasoning', content=reasoning, provider_name=self.system)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have been trying to replicate the bug on the issue without success 🤔.
According to the OpenRouter docs: "Reasoning tokens will appear in the reasoning field of each message". So the logic that is already in OpenAIChatModel should be enough, no?
We could use reasoning_details
but the content is the same, with some supplementary info. I've added reasoning_details
to the provider_details
object in the meantime.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ajac-zero Among the supplementary info in reasoning_details
is the signature
, which Anthropic requires to be sent back, at least when used with AnthropicModel and BedrockModel, and presumably also when going through OpenRouter. You can check those classes for how we parse those signatures, store them on ThinkingPart
, and send them back.
I'd be OK with doing that in a follow-up PR if necessary, but it'd be good to fully support OpenRouter when we launch this new model class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the pointers! I've added a conditional that adds the signature to the thinking part if it exists, and a wrapper around _map_messages
to pass back the reasoning_details
following this example.
Currently, the signature conditional assumes the first part will be a thinking part if reasoning_details
is not None, because OpenAIChatModel._process_response
takes care of that, but maybe adding a type check here would be better in case the OpenAI model logic changes in the future?
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
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: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ajac-zero Among the supplementary info in reasoning_details
is the signature
, which Anthropic requires to be sent back, at least when used with AnthropicModel and BedrockModel, and presumably also when going through OpenRouter. You can check those classes for how we parse those signatures, store them on ThinkingPart
, and send them back.
I'd be OK with doing that in a follow-up PR if necessary, but it'd be good to fully support OpenRouter when we launch this new model class.
return new_settings, customized_parameters | ||
|
||
def _process_response(self, response: ChatCompletion | str) -> ModelResponse: | ||
model_response = super()._process_response(response=response) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, checking _verify_response_is_not_error
first makes it work.
|
||
provider_details: dict[str, str] = {} | ||
|
||
if openrouter_provider := getattr(response, 'provider', None): # pragma: lax no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Annotations/citations we'll get to in #3126. As discussed above, I think we should either fully parse https://github.com/pydantic/pydantic-ai/issues/3126
or omit it for now.
Hi! This pull request takes a shot at implementing a dedicated
OpenRouterModel
model. Closes #2936.The differentiator for this PR is that this implementation minimizes code duplication as much as possible by delegating the main logic to
OpenAIChatModel
, such that the new model class serves as a convenience layer for OpenRouter specific features.The main thinking behind this solution is that as long as the OpenRouter API is still fully accessible via the
openai
package, it would be inefficient to reimplement the internal logic using this same package again. We can instead use hooks to achieve the requested features.I would like to get some thoughts on this implementation before starting to update the docs.
Addressed issues
Provider metadata can now be accessed via the 'downstream_provider' key in ModelMessage.provider_details:
The new
OpenRouterModelSettings
allows for the reasoning parameter by OpenRouter, the thinking can then be accessed as aThinkingPart
in the model response:error
response from OpenRouter as exception instead of validation failure #2323. Closes OpenRouter uses non-compatible finish reason #2844These are dependent on some downstream logic from OpenRouter or their own downstream providers (that a response of type 'error' will have a >= 400 status code), but for most cases I would say it works as one would expect:
OpenRouterModel
#1870 (comment)Add some additional type support to set the provider routing options from OpenRouter: