From bf0c17d8bab9a7cef8de84c2552e1a47c47d1f45 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 22 Jul 2026 15:11:20 -0400 Subject: [PATCH 1/3] fix(planning): make auto planning-model default runtime-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planning pipeline resolved its default model from env keys alone via _default_planning_model(), ignoring the caller's resolved runtime. A caller that pinned ai_provider="codex" in an OpenRouter-only environment therefore had the codex CLI spawned with an "openrouter/..." model its OpenAI backend cannot resolve — completing in ~1s with a null message. Thread the resolved runtime into _default_planning_model() and delegate to the already-runtime-aware resolve_runtime_models() cascade for the high-tier pm role. The auto default is now gated on runtime: - codex -> a codex-native model (never openrouter/...-prefixed) - open_code -> the OpenRouter auto default (OpenRouter-only env) or the open_code base default otherwise - claude_code -> the "sonnet" historical default Explicit args and deployer env (SWE_DEFAULT_MODEL / AI_MODEL / HARNESS_MODEL, SWE_MODEL_HIGH) still win verbatim — only the auto default became runtime-aware. Omitting the runtime arg preserves the prior env-only behavior. Co-Authored-By: Claude Fable 5 --- swe_af/app.py | 7 ++++- swe_af/execution/schemas.py | 56 ++++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/swe_af/app.py b/swe_af/app.py index ad42b5a..54b6f43 100644 --- a/swe_af/app.py +++ b/swe_af/app.py @@ -1449,8 +1449,13 @@ async def plan( ``_default_runtime``). Any explicitly passed value always wins. """ # Resolve provider/model defaults from the environment (see docstring). + # The model default is resolved *for the chosen runtime* so it can never + # hand a provider-prefixed id (e.g. an ``openrouter/…`` model) to a runtime + # whose CLI can't consume it — the cross-runtime leak that caused silent + # ~1s empty completions when a caller pinned ``codex`` under OpenRouter-only + # env. Explicit ``ai_provider`` still wins; only the auto default is gated. ai_provider = ai_provider or _default_runtime() - default_model = _default_planning_model() + default_model = _default_planning_model(ai_provider) pm_model = pm_model or default_model architect_model = architect_model or default_model tech_lead_model = tech_lead_model or default_model diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index 4dd1c36..8606e49 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -17,7 +17,11 @@ model_validator, ) from swe_af.hitl.ask_user import AskUserForm -from swe_af.runtime.providers import RUNTIME_VALUES, runtime_to_harness_provider +from swe_af.runtime.providers import ( + RUNTIME_VALUES, + normalize_runtime_provider, + runtime_to_harness_provider, +) # Global default for all agent max_turns. Change this one value to adjust everywhere. DEFAULT_AGENT_MAX_TURNS: int = 150 @@ -709,32 +713,44 @@ def _tier_models_from_env() -> dict[str, str]: return tiers -def _default_planning_model() -> str: +def _default_planning_model(runtime: str | None = None) -> str: """Model for the planning reasoners (the ``plan`` pipeline) when the caller - passes no model. + passes no model, resolved for the *given runtime*. The planning reasoners take an explicit ``model`` argument rather than a - runtime ``models={}`` config, so the ``resolve_runtime_models`` cascade - doesn't apply to them. This mirrors that cascade for the planning path so an - OpenRouter-only deployment is zero-config. The planning reasoners are - high-tier roles (see ``ROLE_TO_TIER``), so ``SWE_MODEL_HIGH`` beats the - generic default-model env — the same relative precedence tier env vars have - in ``resolve_runtime_models``. Precedence, first match wins: + runtime ``models={}`` config, so the SDK never runs the + ``resolve_runtime_models`` cascade for them. This delegates to that same + cascade for the high-tier ``pm`` role so the planning path picks a model + that is valid for ``runtime`` — critically, the auto default is + runtime-gated and never leaks a provider-prefixed id (e.g. an + ``openrouter/…`` model) into a runtime whose CLI cannot consume it. That + cross-runtime leak was the root cause of silent ~1s empty completions when a + caller pinned ``codex`` in an OpenRouter-only environment. + + ``runtime`` is normalized (aliases like ``claude`` / ``opencode`` accepted). + When omitted, the runtime is resolved from the environment via + ``_default_runtime`` so callers that don't yet know the runtime keep the + historical env-only behavior. + + Precedence is inherited from ``resolve_runtime_models`` (highest first): 1. ``SWE_MODEL_HIGH`` (planning reasoners are high-tier) 2. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``) - 3. the OpenRouter default when only an OpenRouter key is present - 4. the Claude ``sonnet`` alias (historical default) + 3. the runtime's own auto/base default: + - ``codex`` → a codex-native model (never ``openrouter/…``) + - ``open_code`` → the OpenRouter auto default (OpenRouter-only + env) or the ``open_code`` base default + - ``claude_code`` → the Claude ``sonnet`` alias (historical default) + + Env / explicit values (layers 1–2) still win verbatim — the deployer owns + them — so only the auto default (layer 3) is made runtime-aware. """ - high_model = _tier_models_from_env().get("high") - if high_model: - return high_model - env_model = _default_model_from_env() - if env_model: - return env_model - if _openrouter_only_env(): - return _OPENROUTER_AUTO_DEFAULT_MODEL - return "sonnet" + resolved_runtime = normalize_runtime_provider(runtime) if runtime else _default_runtime() + return resolve_runtime_models( + runtime=resolved_runtime, + models=None, + field_names=["pm_model"], + )["pm_model"] def _legacy_hint_for_model_key(key: str) -> str: From e9e1500afba20bfb5ad0f27ede078a21a79af81c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 22 Jul 2026 15:11:32 -0400 Subject: [PATCH 2/3] fix(runtime): surface empty harness completions distinctly with provider+model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty harness completion (no parsed object, no text — the signature of a provider/model mismatch or bad auth) was collapsed into the generic "failed to produce a valid " message, indistinguishable from a genuine schema-quality failure. Two distinct root causes, one opaque error. Add EmptyHarnessCompletionError and check_empty_harness_completion(), and wire them into the PM, architect, tech-lead and sprint-planner reasoners between the fatal-error check and the parsed-None check. The empty case now raises an error naming the provider and model; the generic schema-invalid message is kept only for non-empty-but-unparseable output and also gains provider+model context. Co-Authored-By: Claude Fable 5 --- swe_af/execution/fatal_error.py | 78 +++++++++++++++++++++++++++++++++ swe_af/reasoners/pipeline.py | 43 +++++++++++++++--- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/swe_af/execution/fatal_error.py b/swe_af/execution/fatal_error.py index 1e82c38..77ac763 100644 --- a/swe_af/execution/fatal_error.py +++ b/swe_af/execution/fatal_error.py @@ -56,6 +56,36 @@ def __init__(self, message: str) -> None: self.original_message = message +class EmptyHarnessCompletionError(RuntimeError): + """Raised when a harness call completes but produces no output at all. + + Distinct from a *schema-invalid* completion (output present but unparseable + into the target schema). An empty completion — the harness returned with no + parsed object and no raw text, typically in ~1s — almost always means the + provider/model pairing is wrong: e.g. a model id prefixed for a *different* + runtime (an ``openrouter/…`` model handed to the codex CLI's OpenAI + backend, which doesn't know it) or missing/invalid auth for the selected + provider. Collapsing this into the generic "failed to produce a valid + " message makes it indistinguishable from a genuine schema-quality + failure, so this error names the provider and model explicitly to point at + the real root cause. + """ + + def __init__(self, *, role: str, provider: str, model: str, detail: str = "") -> None: + message = ( + f"{role} harness returned an empty completion " + f"(provider={provider}, model={model}) — check provider " + f"auth/model compatibility" + ) + if detail: + message = f"{message}: {detail}" + super().__init__(message) + self.role = role + self.provider = provider + self.model = model + self.original_message = detail + + def is_fatal_error(error_message: str) -> bool: """Return True if *error_message* matches a known fatal API error pattern.""" if not error_message: @@ -86,3 +116,51 @@ def check_fatal_harness_error(result) -> None: msg = getattr(result, "error_message", "") or "" if is_fatal_error(msg): raise FatalHarnessError(msg) + + +def _harness_output_text(result) -> str: + """Best-effort raw completion text from a HarnessResult-like object. + + Reads ``result`` (the raw completion string) first, falling back to the + ``text`` convenience property. Returns ``""`` when neither is populated. + """ + raw = getattr(result, "result", None) + if not raw: + raw = getattr(result, "text", None) + return raw or "" + + +def check_empty_harness_completion( + result, *, role: str, provider: str, model: str +) -> None: + """Raise ``EmptyHarnessCompletionError`` when a harness produced no output. + + Call *after* ``check_fatal_harness_error`` and *before* the caller's + ``parsed is None`` schema-quality check. This fires only for the "empty + completion" shape — neither a parsed object nor any raw text — which is the + signature of a provider/model mismatch (a model id meant for a different + runtime, or bad auth) rather than a schema-quality problem. When raw text + *is* present but couldn't be parsed, this is a no-op and the caller's + generic schema-invalid error (which should also name provider+model) takes + over. + + Parameters + ---------- + result: + A ``HarnessResult`` (or any object exposing ``parsed`` / ``result`` / + ``text`` / ``error_message``). + role: + Human label for the failing reasoner (e.g. ``"PM"``, ``"Architect"``). + provider: + The harness provider the call used (e.g. ``"codex"``, ``"opencode"``). + model: + The model id the call was made with. + """ + if getattr(result, "parsed", None): + return + if _harness_output_text(result).strip(): + return + detail = (getattr(result, "error_message", "") or "").strip() + raise EmptyHarnessCompletionError( + role=role, provider=provider, model=model, detail=detail + ) diff --git a/swe_af/reasoners/pipeline.py b/swe_af/reasoners/pipeline.py index c0d87ca..87e2860 100644 --- a/swe_af/reasoners/pipeline.py +++ b/swe_af/reasoners/pipeline.py @@ -14,7 +14,10 @@ from pydantic import BaseModel -from swe_af.execution.fatal_error import check_fatal_harness_error +from swe_af.execution.fatal_error import ( + check_empty_harness_completion, + check_fatal_harness_error, +) from swe_af.execution.schemas import DEFAULT_AGENT_MAX_TURNS from swe_af.reasoners.schemas import ( Architecture, @@ -217,6 +220,12 @@ async def _invoke_pm(prior_user_responses: list[dict] | None) -> PRD | None: cwd=repo_path, ) check_fatal_harness_error(result) + # An empty completion here (no parsed output, no text) is a + # provider/model mismatch, not a schema-quality problem — surface it + # distinctly with provider+model instead of the generic PRD message. + check_empty_harness_completion( + result, role="PM", provider=provider, model=model + ) return result.parsed initial_prior = list(prior_user_responses or []) @@ -231,7 +240,13 @@ async def _invoke_pm(prior_user_responses: list[dict] | None) -> PRD | None: ) if parsed is None: - raise RuntimeError("Product manager failed to produce a valid PRD") + # Reached only when the harness produced non-empty but unparseable + # output (empty completions are raised distinctly above). Name the + # provider+model so the failure is diagnosable. + raise RuntimeError( + f"Product manager failed to produce a valid PRD " + f"(provider={provider}, model={model})" + ) router.note("PM complete", tags=["pm", "complete"]) return parsed.model_dump() @@ -407,8 +422,14 @@ async def run_architect( cwd=repo_path, ) check_fatal_harness_error(result) + check_empty_harness_completion( + result, role="Architect", provider=provider, model=model + ) if result.parsed is None: - raise RuntimeError("Architect failed to produce a valid architecture") + raise RuntimeError( + f"Architect failed to produce a valid architecture " + f"(provider={provider}, model={model})" + ) router.note("Architect complete", tags=["architect", "complete"]) return result.parsed.model_dump() @@ -462,8 +483,14 @@ async def run_tech_lead( cwd=repo_path, ) check_fatal_harness_error(result) + check_empty_harness_completion( + result, role="Tech lead", provider=provider, model=model + ) if result.parsed is None: - raise RuntimeError("Tech lead failed to produce a valid review") + raise RuntimeError( + f"Tech lead failed to produce a valid review " + f"(provider={provider}, model={model})" + ) review = result.parsed.model_dump() review_json_path = os.path.join(base, "plan", "review.json") @@ -539,8 +566,14 @@ class SprintPlanOutput(BaseModel): cwd=repo_path, ) check_fatal_harness_error(result) + check_empty_harness_completion( + result, role="Sprint planner", provider=provider, model=model + ) if result.parsed is None: - raise RuntimeError("Sprint planner failed to produce valid issues") + raise RuntimeError( + f"Sprint planner failed to produce valid issues " + f"(provider={provider}, model={model})" + ) router.note("Sprint Planner complete", tags=["sprint_planner", "complete"]) return { From 78db53bc590dc7654be342b753d3792684380b06 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 22 Jul 2026 15:11:38 -0400 Subject: [PATCH 3/3] test(planning): cover runtime-aware defaults matrix + empty-completion error Matrix over (env: openrouter-only / anthropic / SWE_DEFAULT_MODEL) x (runtime: open_code / codex / claude_code) asserting the resolved planning default, including that codex never yields an openrouter/-prefixed id. Plus unit and reasoner-level tests that an empty harness completion raises the distinct EmptyHarnessCompletionError naming provider and model, separate from the schema-invalid message. Co-Authored-By: Claude Fable 5 --- tests/test_runtime_aware_model_default.py | 276 ++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 tests/test_runtime_aware_model_default.py diff --git a/tests/test_runtime_aware_model_default.py b/tests/test_runtime_aware_model_default.py new file mode 100644 index 0000000..28077e6 --- /dev/null +++ b/tests/test_runtime_aware_model_default.py @@ -0,0 +1,276 @@ +"""Runtime-aware planning-model defaults + distinct empty-completion errors. + +Validation contract (see PR "fix(planning): make auto model defaults +runtime-aware; surface empty harness completions distinctly"): + +- OpenRouter-only env + runtime ``codex`` → the auto default is NOT an + ``openrouter/``-prefixed id (it is a codex-native model). A caller pinning + ``codex`` in an OpenRouter-only environment must never hand the codex CLI a + model its OpenAI backend can't resolve (the silent ~1s empty-completion bug). +- OpenRouter-only env + runtime ``open_code`` → the OpenRouter auto default is + preserved (unchanged behavior). +- runtime ``claude_code`` → the ``sonnet`` historical default is preserved. +- ``SWE_DEFAULT_MODEL`` (deployer env) wins in every runtime cell. +- An empty harness completion raises a distinct error that names the provider + and the model, separate from the generic schema-invalid message. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from swe_af.execution.fatal_error import ( + EmptyHarnessCompletionError, + check_empty_harness_completion, +) +from swe_af.execution.schemas import ( + _CODEX_CHATGPT_MODEL, + _OPENROUTER_AUTO_DEFAULT_MODEL, + _default_planning_model, +) + +# open_code's non-auto base default (an explicit open_code runtime, i.e. not +# the OpenRouter-only auto-selection path). Mirrors _RUNTIME_BASE_MODELS. +_OPEN_CODE_BASE = "openrouter/minimax/minimax-m2.5" + +# Every env var that steers runtime/model selection — cleared before each test +# so results never depend on the developer's ambient shell. +_STEERING_ENV_KEYS = ( + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "SWE_DEFAULT_RUNTIME", + "SWE_DEFAULT_MODEL", + "AI_MODEL", + "HARNESS_MODEL", + "SWE_MODEL_LOW", + "SWE_MODEL_MED", + "SWE_MODEL_HIGH", + "SWE_CODEX_AUTH_MODE", +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in _STEERING_ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def _run(coro): + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# Matrix: (env) x (runtime) -> resolved planning-model default +# --------------------------------------------------------------------------- + +# Env presets applied via monkeypatch.setenv. "swe_default_model" also sets an +# OpenRouter key to prove the explicit env value wins over the auto default. +_ENV_PRESETS: dict[str, dict[str, str]] = { + "openrouter_only": {"OPENROUTER_API_KEY": "sk-or-test"}, + "anthropic": {"ANTHROPIC_API_KEY": "sk-ant-test"}, + "swe_default_model": { + "OPENROUTER_API_KEY": "sk-or-test", + "SWE_DEFAULT_MODEL": "explicit/model-x", + }, +} + +# (env_preset, runtime) -> expected resolved default. +# Under "swe_default_model" the deployer env wins verbatim for every runtime. +# Under "openrouter_only"/"anthropic" the runtime's own auto/base default is +# used — critically never an openrouter/ id for codex. +_MATRIX: list[tuple[str, str, str]] = [ + ("openrouter_only", "open_code", _OPENROUTER_AUTO_DEFAULT_MODEL), + ("openrouter_only", "codex", _CODEX_CHATGPT_MODEL), + ("openrouter_only", "claude_code", "sonnet"), + ("anthropic", "open_code", _OPEN_CODE_BASE), + ("anthropic", "codex", _CODEX_CHATGPT_MODEL), + ("anthropic", "claude_code", "sonnet"), + ("swe_default_model", "open_code", "explicit/model-x"), + ("swe_default_model", "codex", "explicit/model-x"), + ("swe_default_model", "claude_code", "explicit/model-x"), +] + + +@pytest.mark.parametrize( + ("env_preset", "runtime", "expected"), + _MATRIX, + ids=[f"{e}-{r}" for e, r, _ in _MATRIX], +) +def test_default_planning_model_matrix( + monkeypatch: pytest.MonkeyPatch, + env_preset: str, + runtime: str, + expected: str, +) -> None: + """The resolved planning-model default is correct for each env x runtime.""" + for key, value in _ENV_PRESETS[env_preset].items(): + monkeypatch.setenv(key, value) + + assert _default_planning_model(runtime) == expected + + +@pytest.mark.parametrize("env_preset", ["openrouter_only", "anthropic"]) +def test_codex_default_is_never_openrouter_prefixed( + monkeypatch: pytest.MonkeyPatch, env_preset: str +) -> None: + """Contract core: pinning codex never yields an openrouter/ model as the + auto default, regardless of which provider key happens to be present.""" + for key, value in _ENV_PRESETS[env_preset].items(): + monkeypatch.setenv(key, value) + + resolved = _default_planning_model("codex") + assert not resolved.startswith("openrouter/"), ( + f"codex runtime leaked a cross-runtime model id: {resolved!r}" + ) + + +def test_runtime_aliases_are_normalized(monkeypatch: pytest.MonkeyPatch) -> None: + """Alias runtime spellings resolve the same as their canonical form.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + # "opencode" -> open_code, "claude"/"claude-code" -> claude_code + assert _default_planning_model("opencode") == _OPENROUTER_AUTO_DEFAULT_MODEL + assert _default_planning_model("claude") == "sonnet" + assert _default_planning_model("claude-code") == "sonnet" + + +def test_omitted_runtime_falls_back_to_env_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No runtime arg → runtime is resolved from env (historical behavior).""" + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + # OpenRouter-only env auto-selects open_code, so the auto default applies. + assert _default_planning_model() == _OPENROUTER_AUTO_DEFAULT_MODEL + + +# --------------------------------------------------------------------------- +# Empty-completion detection: distinct from schema-invalid +# --------------------------------------------------------------------------- + + +def _fake_result(*, parsed=None, result="", error_message=None): + """A HarnessResult-like stand-in (parsed/result/error_message + is_error).""" + return SimpleNamespace( + parsed=parsed, + result=result, + error_message=error_message, + is_error=False, + ) + + +class TestCheckEmptyHarnessCompletion: + def test_empty_completion_raises_naming_provider_and_model(self) -> None: + result = _fake_result(parsed=None, result="") + with pytest.raises(EmptyHarnessCompletionError) as exc: + check_empty_harness_completion( + result, + role="PM", + provider="codex", + model="openrouter/deepseek/deepseek-v4-flash", + ) + message = str(exc.value) + assert "PM" in message + assert "provider=codex" in message + assert "model=openrouter/deepseek/deepseek-v4-flash" in message + assert "empty completion" in message + + def test_is_runtime_error_subclass(self) -> None: + err = EmptyHarnessCompletionError(role="PM", provider="codex", model="m") + assert isinstance(err, RuntimeError) + # Exposes structured fields for programmatic handling. + assert err.provider == "codex" + assert err.model == "m" + assert err.role == "PM" + + def test_error_message_detail_is_appended(self) -> None: + result = _fake_result(parsed=None, result="", error_message="backend refused") + with pytest.raises(EmptyHarnessCompletionError, match="backend refused"): + check_empty_harness_completion( + result, role="Architect", provider="opencode", model="m" + ) + + def test_unparseable_but_nonempty_output_is_noop(self) -> None: + # Text present but not schema-parseable → NOT an empty completion; the + # caller's generic schema-invalid path handles it. + result = _fake_result(parsed=None, result="this is not valid json") + check_empty_harness_completion( + result, role="PM", provider="codex", model="m" + ) # must not raise + + def test_valid_parsed_output_is_noop(self) -> None: + result = _fake_result(parsed=object(), result="") + check_empty_harness_completion( + result, role="PM", provider="codex", model="m" + ) # must not raise + + def test_reads_text_property_when_result_absent(self) -> None: + # Object exposing only `.text` (HarnessResult's convenience property). + with_text = SimpleNamespace(parsed=None, text="present", is_error=False) + check_empty_harness_completion( + with_text, role="PM", provider="codex", model="m" + ) # non-empty text → no raise + + def test_message_is_distinct_from_generic_schema_invalid(self) -> None: + """The empty-completion message must be distinguishable from the generic + 'failed to produce a valid ...' schema-quality message.""" + result = _fake_result(parsed=None, result="") + with pytest.raises(EmptyHarnessCompletionError) as exc: + check_empty_harness_completion( + result, role="PM", provider="codex", model="m" + ) + message = str(exc.value) + generic = "Product manager failed to produce a valid PRD" + assert generic not in message + assert "empty completion" in message + + +# --------------------------------------------------------------------------- +# Reasoner wiring: run_product_manager surfaces the distinct error end-to-end +# --------------------------------------------------------------------------- + + +def test_run_product_manager_empty_completion_surfaces_provider_and_model( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An empty harness completion inside the PM reasoner raises the distinct + error naming the pinned provider+model — not the generic PRD message. + + Reproduces the incident shape: ai_provider='codex' with an openrouter/ + model id leaked in, harness completes in ~1s with a null message. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + import swe_af.reasoners.pipeline as pipeline + + # HAX disabled so run_with_ask_user calls the reasoner once (no pause loop). + monkeypatch.delenv("HAX_API_KEY", raising=False) + + empty = SimpleNamespace( + parsed=None, result="", text="", error_message=None, is_error=False + ) + mock_router = MagicMock() + mock_router.harness = AsyncMock(return_value=empty) + mock_router.note = MagicMock() + mock_router.agentfield_server = "http://localhost:9999" + + real_pm = getattr(pipeline.run_product_manager, "_original_func", pipeline.run_product_manager) + + with patch.object(pipeline, "router", mock_router): + with pytest.raises(EmptyHarnessCompletionError) as exc: + _run( + real_pm( + goal="Build a thing", + repo_path=str(tmp_path), + artifacts_dir=".artifacts", + model="openrouter/deepseek/deepseek-v4-flash", + ai_provider="codex", + ) + ) + + message = str(exc.value) + assert "provider=codex" in message + assert "model=openrouter/deepseek/deepseek-v4-flash" in message + assert "Product manager failed to produce a valid PRD" not in message