From fb669cf66f6aecfa03c4b08539958b31101e4cc9 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Sat, 13 Jun 2026 09:15:43 +0400 Subject: [PATCH] fix(qa): use the provider's own model for direct-API QA sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-build finding (2026-06-13, spec 901): with QA roles routed to a direct provider (qa_reviewer/qa_fixer on openai via the promotion gate, now that #317 dropped the opt-in), the QA runtime session was built with the QA phase-model DEFAULT — which phase_config resolves to a claude-* id — and OpenAI 404'd on claude-sonnet-4-5-20250929. This is the QA-path analog of the coder fix in #337: once routing correctly sends QA to the direct provider, the Claude-centric phase default leaks straight into the session. New ProviderConfig.coherent_session_model() returns the provider's own configured model when the requested model is a Claude-family id and the provider is non-Claude; otherwise it passes the request through unchanged. Both QA runtime shims (run_qa_reviewer_runtime_session, _build_qa_fixer_runtime_session) now route their model through it before create_session. The qa_fixer recovery loop already uses get_fallback_model() (#316 parity), so once the initial model is the provider's, the fallback chain (gpt-5.2 -> gpt-5 -> ...) stays coherent. Tests: 5 helper cases (claude->provider, explicit preserved, None, claude-provider unchanged, no-configured-model keeps request) + existing QA runtime / factory suites. 85 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Oleg Miagkov --- apps/backend/core/providers/config.py | 19 +++++++++++ apps/backend/qa/fixer.py | 4 ++- apps/backend/qa/reviewer.py | 4 ++- tests/test_provider_coherent_model.py | 47 +++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/test_provider_coherent_model.py 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" + )