diff --git a/apps/backend/core/providers/config.py b/apps/backend/core/providers/config.py index 386fe5566..de0be683d 100644 --- a/apps/backend/core/providers/config.py +++ b/apps/backend/core/providers/config.py @@ -372,6 +372,25 @@ def get_model_for(self, provider: str) -> str | None: return self.ollama_model or None return None + def coherent_session_model(self, requested_model: str | None) -> str | None: + """Return a model id coherent with this config's provider. + + The phase-model default is Claude-centric (``phase_config`` tiers + resolve to ``claude-*`` ids). When the configured provider is a + non-Claude provider and ``requested_model`` is a Claude-family id, + return the provider's OWN configured model instead, so a direct-API + session does not receive a Claude id it cannot serve (404 + model_not_found). Otherwise return ``requested_model`` unchanged. + + This is the QA-path analog of the coder fix in #337; both keep a + Claude-family phase default from leaking into a non-Claude session. + """ + if self.provider != "claude" and (requested_model or "").startswith("claude"): + provider_model = self.get_model_for(self.provider) + if provider_model: + return provider_model + return requested_model + def with_provider_model(self, provider: str, model: str) -> "ProviderConfig": """Return a copy configured to use the given provider/model pair.""" provider = provider.lower() diff --git a/apps/backend/qa/fixer.py b/apps/backend/qa/fixer.py index 84f6c1651..d32f9ddbf 100644 --- a/apps/backend/qa/fixer.py +++ b/apps/backend/qa/fixer.py @@ -212,10 +212,12 @@ def _build_qa_fixer_runtime_session( f"routed={provider_name}, env={config.provider}" ) provider = create_engine_provider(config) + # Keep a Claude-family QA phase default from 404ing a direct provider + # (the QA-path analog of #337). See ProviderConfig.coherent_session_model. session = provider.create_session( SessionConfig( name=f"qa_fixer-runtime-{fix_session}", - model=model, + model=config.coherent_session_model(model), extra={"agent_type": "qa_fixer"}, ) ) diff --git a/apps/backend/qa/reviewer.py b/apps/backend/qa/reviewer.py index 12a858eb9..04cff5eca 100644 --- a/apps/backend/qa/reviewer.py +++ b/apps/backend/qa/reviewer.py @@ -579,10 +579,12 @@ async def run_qa_reviewer_runtime_session( f"routed={provider_name}, env={config.provider}" ) provider = create_engine_provider(config) + # Keep a Claude-family QA phase default from 404ing a direct provider + # (the QA-path analog of #337). See ProviderConfig.coherent_session_model. session = provider.create_session( SessionConfig( name=f"qa_reviewer-runtime-{qa_session}", - model=model, + model=config.coherent_session_model(model), extra={"agent_type": "qa_reviewer"}, ) ) diff --git a/tests/test_provider_coherent_model.py b/tests/test_provider_coherent_model.py new file mode 100644 index 000000000..26b0e9d84 --- /dev/null +++ b/tests/test_provider_coherent_model.py @@ -0,0 +1,47 @@ +"""Tests for ProviderConfig.coherent_session_model (QA/coder model coherence).""" + +from core.providers.config import ProviderConfig + + +def test_claude_family_model_replaced_for_direct_provider(): + """A claude-family phase default must not reach a non-Claude session.""" + config = ProviderConfig(provider="openai", openai_model="gpt-5.2") + + assert ( + config.coherent_session_model("claude-sonnet-4-5-20250929") == "gpt-5.2" + ) + + +def test_explicit_provider_model_is_preserved(): + config = ProviderConfig(provider="openai", openai_model="gpt-5.2") + + assert config.coherent_session_model("gpt-4o") == "gpt-4o" + + +def test_none_model_passes_through(): + config = ProviderConfig(provider="openai", openai_model="gpt-5.2") + + assert config.coherent_session_model(None) is None + + +def test_claude_provider_keeps_claude_model(): + config = ProviderConfig( + provider="claude", claude_model="claude-sonnet-4-5-20250929" + ) + + assert ( + config.coherent_session_model("claude-sonnet-4-5-20250929") + == "claude-sonnet-4-5-20250929" + ) + + +def test_direct_provider_without_configured_model_keeps_request(): + """If the provider has no configured model, fall back to the request + rather than returning an empty string.""" + config = ProviderConfig(provider="openrouter", openrouter_model="") + + # openrouter_model empty -> get_model_for returns "" (falsy) -> keep request + assert ( + config.coherent_session_model("claude-sonnet-4-5-20250929") + == "claude-sonnet-4-5-20250929" + )