Skip to content

Add settings to Model base class #2136

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,26 +466,45 @@ PydanticAI offers a [`settings.ModelSettings`][pydantic_ai.settings.ModelSetting
This structure allows you to configure common parameters that influence the model's behavior, such as `temperature`, `max_tokens`,
`timeout`, and more.

There are two ways to apply these settings:
There are three ways to apply these settings, with a clear precedence order:

1. Passing to `run{_sync,_stream}` functions via the `model_settings` argument. This allows for fine-tuning on a per-request basis.
2. Setting during [`Agent`][pydantic_ai.agent.Agent] initialization via the `model_settings` argument. These settings will be applied by default to all subsequent run calls using said agent. However, `model_settings` provided during a specific run call will override the agent's default settings.
1. **Model-level defaults** - Set when creating a model instance via the `settings` parameter. These serve as the base defaults for that model.
2. **Agent-level defaults** - Set during [`Agent`][pydantic_ai.agent.Agent] initialization via the `model_settings` argument. These override model defaults.
3. **Run-time overrides** - Passed to `run{_sync,_stream}` functions via the `model_settings` argument. These have the highest priority and override both agent and model defaults.

**Settings Precedence**: Run-time > Agent > Model
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can drop this line as the list already explicitly states the priority.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, it may be useful to write here that the settings are merged, not replaced.


For example, if you'd like to set the `temperature` setting to `0.0` to ensure less random behavior,
you can do the following:

```py
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic_ai.settings import ModelSettings

agent = Agent('openai:gpt-4o')
# 1. Model-level defaults
model = OpenAIModel(
'gpt-4o',
settings=ModelSettings(temperature=0.8, max_tokens=500) # Base defaults
)

# 2. Agent-level defaults (override model defaults)
agent = Agent(model, model_settings=ModelSettings(temperature=0.5))

# 3. Run-time overrides (highest priority)
result_sync = agent.run_sync(
'What is the capital of Italy?', model_settings={'temperature': 0.0}
'What is the capital of Italy?',
model_settings=ModelSettings(temperature=0.0) # Final temperature: 0.0
)
print(result_sync.output)
#> Rome
```

The final request uses `temperature=0.0` (run-time), `max_tokens=500` (from model), demonstrating how settings merge with run-time taking precedence.

!!! note "Model Settings Support"
Model-level settings are supported by all concrete model implementations (OpenAI, Anthropic, Google, etc.). Wrapper models like `FallbackModel`, `WrapperModel`, and `InstrumentedModel` don't have their own settings - they use the settings of their underlying models.

### Model specific settings

If you wish to further customize model behavior, you can use a subclass of [`ModelSettings`][pydantic_ai.settings.ModelSettings], like [`GeminiModelSettings`][pydantic_ai.models.gemini.GeminiModelSettings], associated with your model of choice.
Expand Down
33 changes: 33 additions & 0 deletions docs/models/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,39 @@ The `ModelResponse` message above indicates in the `model_name` field that the o
!!! note
Each model's options should be configured individually. For example, `base_url`, `api_key`, and custom clients should be set on each model itself, not on the `FallbackModel`.

### Per-Model Settings

You can configure different `ModelSettings` for each model in a fallback chain by passing the `settings` parameter when creating each model. This is particularly useful when different providers have different optimal configurations:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
You can configure different `ModelSettings` for each model in a fallback chain by passing the `settings` parameter when creating each model. This is particularly useful when different providers have different optimal configurations:
You can configure different [`ModelSettings`][pydantic_ai.settings.ModelSettings] for each model in a fallback chain by passing the `settings` parameter when creating each model. This is particularly useful when different providers have different optimal configurations:


```python {title="fallback_model_per_settings.py"}
from pydantic_ai import Agent
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.fallback import FallbackModel
from pydantic_ai.models.openai import OpenAIModel
from pydantic_ai.settings import ModelSettings

# Configure each model with provider-specific optimal settings
openai_model = OpenAIModel(
'gpt-4o',
settings=ModelSettings(temperature=0.7, max_tokens=1000) # Higher creativity for OpenAI
)
anthropic_model = AnthropicModel(
'claude-3-5-sonnet-latest',
settings=ModelSettings(temperature=0.2, max_tokens=1000) # Lower temperature for consistency
)

fallback_model = FallbackModel(openai_model, anthropic_model)
agent = Agent(fallback_model)

result = agent.run_sync('Write a creative story about space exploration')
print(result.output)
"""
In the year 2157, Captain Maya Chen piloted her spacecraft through the vast expanse of the Andromeda Galaxy. As she discovered a planet with crystalline mountains that sang in harmony with the cosmic winds, she realized that space exploration was not just about finding new worlds, but about finding new ways to understand the universe and our place within it.
"""
```

In this example, if the OpenAI model fails, the agent will automatically fall back to the Anthropic model with its own configured settings. The `FallbackModel` itself doesn't have settings - it uses the individual settings of whichever model successfully handles the request.

In this next example, we demonstrate the exception-handling capabilities of `FallbackModel`.
If all models fail, a [`FallbackExceptionGroup`][pydantic_ai.exceptions.FallbackExceptionGroup] is raised, which
contains all the exceptions encountered during the `run` execution.
Expand Down
16 changes: 14 additions & 2 deletions pydantic_ai_slim/pydantic_ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,12 +674,24 @@ async def main():
# typecast reasonable, even though it is possible to violate it with otherwise-type-checked code.
output_validators = cast(list[_output.OutputValidator[AgentDepsT, RunOutputDataT]], self._output_validators)

# Merge model settings from any Model
if isinstance(model_used, InstrumentedModel):
# For InstrumentedModel, get settings from the wrapped model
wrapped_model_settings = getattr(model_used.wrapped, 'settings', None)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we define settings on WrapperModel (which InstrumentedModel inherits from) to return self.wrapped.settings, so we don't need to do this special-casing?

Copy link
Contributor

Choose a reason for hiding this comment

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

That may require us to rename the field on Model to _settings, so that we can define settings as a property that can be overridden on WrapperModel.

if wrapped_model_settings is not None:
model_settings = merge_model_settings(wrapped_model_settings, model_settings)
else:
# For regular models, use their settings directly
current_settings = getattr(model_used, 'settings', None)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be model_used.settings?

if current_settings is not None:
model_settings = merge_model_settings(current_settings, model_settings)

model_settings = merge_model_settings(self.model_settings, model_settings)
usage_limits = usage_limits or _usage.UsageLimits()

if isinstance(model_used, InstrumentedModel):
instrumentation_settings = model_used.settings
tracer = model_used.settings.tracer
instrumentation_settings = model_used.instrumentation_settings
tracer = model_used.instrumentation_settings.tracer
else:
instrumentation_settings = None
tracer = NoOpTracer()
Expand Down
8 changes: 8 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ class Model(ABC):

_profile: ModelProfileSpec | None = None

def __init__(self, *, settings: ModelSettings | None = None) -> None:
"""Initialize the model with optional settings.

Args:
settings: Model-specific settings that will be used as defaults for this model.
"""
self.settings: ModelSettings | None = settings

@abstractmethod
async def request(
self,
Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def __init__(
*,
provider: Literal['anthropic'] | Provider[AsyncAnthropic] = 'anthropic',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize an Anthropic model.

Expand All @@ -136,7 +137,9 @@ def __init__(
provider: The provider to use for the Anthropic API. Can be either the string 'anthropic' or an
instance of `Provider[AsyncAnthropic]`. If not provided, the other parameters will be used.
profile: The model profile to use. Defaults to a profile picked by the provider based on the model name.
settings: Default model settings for this model instance.
"""
super().__init__(settings=settings)
self._model_name = model_name

if isinstance(provider, str):
Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def __init__(
*,
provider: Literal['bedrock'] | Provider[BaseClient] = 'bedrock',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize a Bedrock model.

Expand All @@ -212,7 +213,9 @@ def __init__(
'bedrock' or an instance of `Provider[BaseClient]`. 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.
settings: Model-specific settings that will be used as defaults for this model.
"""
super().__init__(settings=settings)
self._model_name = model_name

if isinstance(provider, str):
Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/cohere.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def __init__(
*,
provider: Literal['cohere'] | Provider[AsyncClientV2] = 'cohere',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize an Cohere model.

Expand All @@ -121,7 +122,9 @@ def __init__(
'cohere' or an instance of `Provider[AsyncClientV2]`. 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.
settings: Model-specific settings that will be used as defaults for this model.
"""
super().__init__(settings=settings)
self._model_name = model_name

if isinstance(provider, str):
Expand Down
1 change: 1 addition & 0 deletions pydantic_ai_slim/pydantic_ai/models/fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
fallback_models: The names or instances of the fallback models to use upon failure.
fallback_on: A callable or tuple of exceptions that should trigger a fallback.
"""
super().__init__()
self.models = [infer_model(default_model), *[infer_model(m) for m in fallback_models]]

if isinstance(fallback_on, tuple):
Expand Down
12 changes: 11 additions & 1 deletion pydantic_ai_slim/pydantic_ai/models/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ class FunctionModel(Model):

@overload
def __init__(
self, function: FunctionDef, *, model_name: str | None = None, profile: ModelProfileSpec | None = None
self,
function: FunctionDef,
*,
model_name: str | None = None,
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
) -> None: ...

@overload
Expand All @@ -62,6 +67,7 @@ def __init__(
stream_function: StreamFunctionDef,
model_name: str | None = None,
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
) -> None: ...

@overload
Expand All @@ -72,6 +78,7 @@ def __init__(
stream_function: StreamFunctionDef,
model_name: str | None = None,
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
) -> None: ...

def __init__(
Expand All @@ -81,6 +88,7 @@ def __init__(
stream_function: StreamFunctionDef | None = None,
model_name: str | None = None,
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize a `FunctionModel`.

Expand All @@ -91,9 +99,11 @@ def __init__(
stream_function: The function to call for streamed requests.
model_name: The name of the model. If not provided, a name is generated from the function names.
profile: The model profile to use.
settings: Model-specific settings that will be used as defaults for this model.
"""
if function is None and stream_function is None:
raise TypeError('Either `function` or `stream_function` must be provided')
super().__init__(settings=settings)
self.function = function
self.stream_function = stream_function

Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def __init__(
*,
provider: Literal['google-gla', 'google-vertex'] | Provider[httpx.AsyncClient] = 'google-gla',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize a Gemini model.

Expand All @@ -142,7 +143,9 @@ def __init__(
'google-gla' or 'google-vertex' or an instance of `Provider[httpx.AsyncClient]`.
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.
settings: Default model settings for this model instance.
"""
super().__init__(settings=settings)
self._model_name = model_name
self._provider = provider

Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def __init__(
*,
provider: Literal['google-gla', 'google-vertex'] | Provider[genai.Client] = 'google-gla',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize a Gemini model.

Expand All @@ -160,7 +161,9 @@ def __init__(
'google-gla' or 'google-vertex' or an instance of `Provider[httpx.AsyncClient]`.
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.
settings: The model settings to use. Defaults to None.
"""
super().__init__(settings=settings)
self._model_name = model_name

if isinstance(provider, str):
Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/groq.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def __init__(
*,
provider: Literal['groq'] | Provider[AsyncGroq] = 'groq',
profile: ModelProfileSpec | None = None,
settings: ModelSettings | None = None,
):
"""Initialize a Groq model.

Expand All @@ -130,7 +131,9 @@ def __init__(
'groq' or an instance of `Provider[AsyncGroq]`. 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.
settings: Model-specific settings that will be used as defaults for this model.
"""
super().__init__(settings=settings)
self._model_name = model_name

if isinstance(provider, str):
Expand Down
25 changes: 14 additions & 11 deletions pydantic_ai_slim/pydantic_ai/models/instrumented.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,23 +182,26 @@ def messages_to_otel_events(self, messages: list[ModelMessage]) -> list[Event]:
GEN_AI_REQUEST_MODEL_ATTRIBUTE = 'gen_ai.request.model'


@dataclass
@dataclass(init=False)
class InstrumentedModel(WrapperModel):
"""Model which wraps another model so that requests are instrumented with OpenTelemetry.

See the [Debugging and Monitoring guide](https://ai.pydantic.dev/logfire/) for more info.
"""

settings: InstrumentationSettings
"""Configuration for instrumenting requests."""
instrumentation_settings: InstrumentationSettings
"""Instrumentation settings for this model."""

def __init__(
self,
wrapped: Model | KnownModelName,
options: InstrumentationSettings | None = None,
) -> None:
super().__init__(wrapped)
self.settings = options or InstrumentationSettings()
# Store instrumentation settings separately from model settings
Copy link
Contributor

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 need this comment or the next one, with the different field names it's clear enough what's happening

self.instrumentation_settings = options or InstrumentationSettings()
# Initialize base Model with no settings to avoid storing InstrumentationSettings there
Model.__init__(self, settings=None)
Copy link
Contributor

Choose a reason for hiding this comment

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

If we make _settings a field on Model with default value None (like _profile currently is), we can skip this init.


async def request(
self,
Expand Down Expand Up @@ -260,7 +263,7 @@ def _instrument(

record_metrics: Callable[[], None] | None = None
try:
with self.settings.tracer.start_as_current_span(span_name, attributes=attributes) as span:
with self.instrumentation_settings.tracer.start_as_current_span(span_name, attributes=attributes) as span:

def finish(response: ModelResponse):
# FallbackModel updates these span attributes.
Expand All @@ -278,12 +281,12 @@ def _record_metrics():
'gen_ai.response.model': response_model,
}
if response.usage.request_tokens: # pragma: no branch
self.settings.tokens_histogram.record(
self.instrumentation_settings.tokens_histogram.record(
response.usage.request_tokens,
{**metric_attributes, 'gen_ai.token.type': 'input'},
)
if response.usage.response_tokens: # pragma: no branch
self.settings.tokens_histogram.record(
self.instrumentation_settings.tokens_histogram.record(
response.usage.response_tokens,
{**metric_attributes, 'gen_ai.token.type': 'output'},
)
Expand All @@ -294,8 +297,8 @@ def _record_metrics():
if not span.is_recording():
return

events = self.settings.messages_to_otel_events(messages)
for event in self.settings.messages_to_otel_events([response]):
events = self.instrumentation_settings.messages_to_otel_events(messages)
for event in self.instrumentation_settings.messages_to_otel_events([response]):
events.append(
Event(
'gen_ai.choice',
Expand Down Expand Up @@ -328,9 +331,9 @@ def _record_metrics():
record_metrics()

def _emit_events(self, span: Span, events: list[Event]) -> None:
if self.settings.event_mode == 'logs':
if self.instrumentation_settings.event_mode == 'logs':
for event in events:
self.settings.event_logger.emit(event)
self.instrumentation_settings.event_logger.emit(event)
else:
attr_name = 'events'
span.set_attributes(
Expand Down
4 changes: 4 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/mcp_sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class MCPSamplingModel(Model):
[`ModelSettings`][pydantic_ai.settings.ModelSettings], so this value is used as fallback.
"""

def __post_init__(self):
"""Initialize the base Model class."""
super().__init__()
Copy link
Contributor

Choose a reason for hiding this comment

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

We can likely also remove this if _settings: ModelSettings | None = None becomes a Model field.


async def request(
self,
messages: list[ModelMessage],
Expand Down
Loading