Engineering research / agents

pydantic-ai 2.65 Changes How Model Refusals Trigger Automatic Fallback

pydantic-ai v2.65.0, released on 3 November 2026, makes ModelFallback move on when a model refuses and stops Temporal retrying model errors that cannot succeed. A local before-and-after test, the error classes involved and what to change.

By Sandeep Belgavi · Published 2026-11-03

Recent development · Practical technical guide · 3 November 2026

pydantic-ai v2.65.0, released on 3 November 2026, makes ModelFallback move on when a model refuses and stops Temporal retrying model errors that cannot succeed. A local before-and-after test, the error classes involved and what to change.

Three changes about failed model calls

pydantic-ai v2.65.0 was released on 3 November 2026. Among its compatibility notes, three change what happens when a model call fails: ModelFallback now falls back when a model refuses; Temporal durable execution no longer retries model errors that cannot succeed on retry; and Z.ai balance and plan-limit 429 responses raise ModelQuotaExceededError instead of ModelRateLimitError. Each is a classification decision, and each changes behaviour without a code change on your side.

Refusals now trigger fallback

ModelFallback is a capability that tries further models when the first fails. In 2.64.0 its fallback_on default was (ModelAPIError,): API errors fell back, but a refusal, a response with finish_reason='content_filter', did not. In 2.65.0 the default is (ModelAPIError, ContentFilterError), and the source documents that a refusal with or without partial text counts. I checked this in two virtual environments with pydantic-ai-slim 2.64.0 and 2.65.0, using two FunctionModel instances so no provider was called: the primary returns a refusal, the backup an answer.

Python from the local probe; run with each installed version.

def refuses_with_text(messages, info):
    return ModelResponse(parts=[TextPart("I cannot help with that.")],
                         finish_reason="content_filter", model_name="primary")

def answers(messages, info):
    return ModelResponse(parts=[TextPart("Return label sent for order BK-1044.")], model_name="backup")

agent = Agent(FunctionModel(refuses_with_text),
              capabilities=[ModelFallback(FunctionModel(answers))])
print(agent.run_sync("Start a return for BK-1044, the cover is torn.").output)
Primary model returns2.64.02.65.0
a refusal with no textrun ends with ContentFilterError; backup never calledbackup called; output is its answer
a refusal with textthe refusal text becomes the output; backup never calledbackup called; output is its answer

The second row is the one to think about. In 2.64.0 an agent with a fallback chain could hand a refusal to the user as if it were the answer. In 2.65.0 the next model answers instead. That is usually what you want for transient or provider-specific refusals, but it also means a request the primary model declined for a good reason is now sent to another model, possibly another provider. If refusals should stop the run, pass fallback_on=(ModelAPIError,) to keep the old behaviour, and decide per agent which models may receive a refused request. Streamed refusals are accepted as they are, because by the time the stream ends it has already reached the consumer.

Sticky fallback and what gets recorded

ModelFallback also takes sticky, off by default. The docstring describes it: when on, a conversation that fell back keeps using the candidate that answered instead of starting each request step from the agent's model again. Only a fallback makes a conversation sticky, and the agent records the model that answered as the response's model_id and the attempts that failed before it as failed_attempts, so the choice also holds for a later run that continues the same history. With refusals now triggering fallback, a sticky conversation can move to the backup model because of one refused request and stay there. Check the recorded model_id in traces before enabling it, and alert when a large share of conversations end up on the backup.

Errors Temporal no longer retries

With the Temporal integration, each model request runs as an activity, and Temporal retries failed activities according to the retry policy. Before 2.65.0 every model error was retried by default. The new default non_retryable_model_errors marks three classes as non-retryable: ModelRequestRejectedError, which includes context-window overflow, authentication failures and unknown models; ModelQuotaExceededError; and ModelRequestTimeout. The docstring gives the reasons: a rejected request is rejected again, an exhausted quota stays exhausted until it resets, and a request that ran out of its deadline has no time left. Rate limits, overloads, server errors and connection errors are still retried.

The effect is fewer wasted activity attempts and faster failure for requests that were never going to work, which matters most for context overflow, where each retry re-sends the whole prompt. If you relied on retries to ride out a quota reset or a slow provider within the activity's retry policy, pass your own tuple to restore that, and consider handling quota with a longer, explicit wait outside the activity.

Quota is not a rate limit

The exception hierarchy separates the two: ModelQuotaExceededError is not a subclass of ModelRateLimitError (the probe printed False on both versions). The class docstring says it is raised only when the provider says so explicitly, giving HTTP 402, OpenAI's insufficient_quota, Anthropic's billing_error and Bedrock's ServiceQuotaExceededException as examples, and that retrying after a wait will not help. The 2.65.0 change maps Z.ai's balance and plan-limit 429 codes to it; other OpenAI-compatible providers do not get that mapping. Code that catches ModelRateLimitError to back off will no longer see those Z.ai errors, and should catch ModelQuotaExceededError separately and alert a person.

What to check before upgrading

  1. List agents that use ModelFallback. For each, decide whether a refusal should fall back. If not, set fallback_on=(ModelAPIError,) explicitly.
  2. Check which providers sit later in each fallback chain, since refused requests may now reach them.
  3. In Temporal workflows, look for code that expected model errors to be retried by the activity, especially timeouts.
  4. Add handling for ModelQuotaExceededError that pages someone instead of backing off.
  5. Re-run evaluation cases that include requests the primary model declines, and compare outputs.

Pinning the behaviour with a test

Fallback behaviour is easy to change by accident, through a library upgrade or a refactor of the capability list. A test with two FunctionModel instances pins it without a provider, in milliseconds.

Python test for an agent that must not send refused requests onward; adapt the assertion to your policy.

def test_refusal_does_not_reach_a_second_provider():
    calls = []

    def refuses(messages, info):
        calls.append("primary")
        return ModelResponse(parts=[], finish_reason="content_filter")

    def answers(messages, info):
        calls.append("backup")
        return ModelResponse(parts=[TextPart("ok")])

    agent = Agent(FunctionModel(refuses),
                  capabilities=[ModelFallback(FunctionModel(answers), fallback_on=(ModelAPIError,))])
    with pytest.raises(ContentFilterError):
        agent.run_sync("Refund BK-1054, the parcel was lost.")
    assert calls == ["primary"]

Write the opposite test for agents where falling back is the intended behaviour. The companion example in agent-decision-lab pins pydantic-ai-slim==2.65.0 and tests both policies: by default an empty refusal and a refusal with text both reach the backup; with fallback_on=(ModelAPIError,) the backup is never called, an empty refusal raises ContentFilterError and a refusal with text becomes the output. Either way the policy becomes a reviewed line of code instead of a library default. The same approach works for Temporal: construct TemporalDurability with an explicit non_retryable_model_errors tuple, so a later default change does not alter retries silently.

Other changes in the release

The notes also add a ConversationStore that keeps a conversation as a log of messages stored once, with a revision number to catch conflicting saves; WebSocket transport for the OpenAI Responses API; and a minimum 20-minute server deadline sent to Google instead of the client's read timeout, with a warning about httpx's 5-second default. None of these change behaviour unless used, except the Google deadline, which can make slow requests wait longer before failing.

Sources and scope

Checked against these sources on 3 November 2026. Event: pydantic-ai v2.65.0 released, 3 November 2026; recheck it before acting. Local check on 3 November 2026: pydantic-ai-slim 2.64.0 and 2.65.0 in separate virtual environments, Python 3.13.5, FunctionModel only; no provider and no Temporal server were used. The 2.65.0 results were re-run for the companion example.

pydantic-ai 2.65.0 sends refused requests to the next model in a ModelFallback chain, where 2.64.0 ended the run or returned the refusal text, and Temporal no longer retries rejected requests, exhausted quotas or timeouts. Decide per agent whether refusals may fall back and treat quota as an alert, not a backoff.

Views are my own and do not represent any employer, past or present.