Move tool-forcing and thinking-default profile flags to `ModelProfile` so they apply on every provider route

Merged
DO
1mo ago
+680-405

Why we make these changes

Whether a model accepts a forced tool_choice was spread over six provider-prefixed profile keys (openai_supports_tool_choice_required, grok_supports_tool_choice_required, anthropic_supports_forced_tool_choice, openai_/openrouter_supports_forced_tool_choice_with_thinking, openai_reasoning_enabled_by_default). Each adapter only read its own prefix, so a model fact didn't reach the other routes that serve it. For example, Claude Opus 5.5 and Fable 5.1 reject forcing with a 400 on the Anthropic API, but OpenRouter, Vercel, GitHub Copilot, Heroku, LiteLLM and Snowflake kept sending a forced choice (#8675). Each adapter also had its own copy of the "explicit forcing raises, inferred forcing falls back" logic, and they had drifted apart.

This moves these facts to ModelProfile, so they follow the model onto every route, and decides forcing through one shared helper. It is the groundwork for the Anthropic output-mode fix for #8772 / #8640, which follows in a separate PR on top of this one.

New public surface

  • ModelProfile.supports_forced_tool_choice, ModelProfile.supports_forced_tool_choice_with_thinking, ModelProfile.thinking_enabled_by_default
  • The six old keys still work: they are translated on merge with a PydanticAIDeprecationWarning, including when returned from a callable profile=. The same fix applies to the older tool_additions and deferred_tools_require_tool_search keys, which a callable profile= couldn't set before when the resolved profile already had a value.

User-visible behavior

  • Claude Opus 5.5 / Sonnet 5.5 / Fable 5.1 / Mythos 5.1 on OpenAI-compatible routes: an inferred forced choice (for example, from an output tool) falls back to 'auto' instead of a 400. An explicit tool_choice='required' or list of tools raises a UserError.
  • OpenRouter: "is thinking on" now also counts openai_reasoning_effort and models that think by default (#7831).
  • OpenAI-compatible models that can't force: ToolOrOutput now falls back to 'auto' like any other inferred forcing, where it used to raise. This matches Anthropic and Bedrock.
  • xAI profiles with forcing disabled: an explicit 'required' or list of tools now raises, like every other provider, where it used to fall back silently. No built-in Grok profile disables forcing.
  • The forcing error messages share one format: tool_choice=... is not supported by model '...'. <reason and remedy>.

Verification

  • tests/profiles/test_legacy_keys.py: legacy key translation, including the callable profile= form
  • tests/profiles/test_resolution_matrix.py::test_forced_tool_choice_support_follows_the_model_on_every_route
  • tests/models/test_openrouter.py::test_openrouter_forced_tool_choice_on_anthropic_model_that_rejects_it (fails on main) and ::test_openrouter_forced_tool_choice_follows_default_thinking
  • tests/models/test_tool_choice_unit.py: xAI thinking-state, explicit-raise and tool_choice=[] cases, plus the updated shared-helper cases

What changes for existing users

Only the routes and cases listed above change; custom profiles using the old keys keep working, with a deprecation warning.

Supersedes #8676, which maps the Anthropic flag onto OpenRouter only. Follow-up: Vercel's Anthropic profile lookup doesn't normalise dotted ids such as anthropic/claude-opus-5.5, which predates this PR.

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • Any permitted compatibility impact has a label, warning, migration, release note, and exact API-check waiver.
  • PR title is fit for the release changelog.

šŸ¤– Generated with Claude Code