diff --git a/swe_af/app.py b/swe_af/app.py index af855a3..c49f8a2 100644 --- a/swe_af/app.py +++ b/swe_af/app.py @@ -43,6 +43,8 @@ def __init__(self, message: str, *, result=None, error_details=None) -> None: RepoPRResult, WorkspaceManifest, WorkspaceRepo, + _default_planning_model, + _default_runtime, _derive_repo_name as _repo_name_from_url, ) @@ -1415,19 +1417,35 @@ async def plan( artifacts_dir: str = ".artifacts", additional_context: str = "", max_review_iterations: int = 2, - pm_model: str = "sonnet", - architect_model: str = "sonnet", - tech_lead_model: str = "sonnet", - sprint_planner_model: str = "sonnet", - issue_writer_model: str = "sonnet", + pm_model: str | None = None, + architect_model: str | None = None, + tech_lead_model: str | None = None, + sprint_planner_model: str | None = None, + issue_writer_model: str | None = None, permission_mode: str = "", - ai_provider: str = "claude", + ai_provider: str | None = None, workspace_manifest: dict | None = None, ) -> dict: """Run the full planning pipeline. Orchestrates: product_manager → architect ↔ tech_lead → sprint_planner → issue_writers + + ``ai_provider`` and the per-role ``*_model`` arguments default to ``None`` and + are resolved from the environment so an OpenRouter-only deployment needs zero + config: with only an ``OPENROUTER_API_KEY`` present, the pipeline runs on the + ``open_code`` runtime with the default OpenRouter model instead of Claude + (mirroring ``build``/``execute``, which already auto-select via + ``_default_runtime``). Any explicitly passed value always wins. """ + # Resolve provider/model defaults from the environment (see docstring). + ai_provider = ai_provider or _default_runtime() + default_model = _default_planning_model() + pm_model = pm_model or default_model + architect_model = architect_model or default_model + tech_lead_model = tech_lead_model or default_model + sprint_planner_model = sprint_planner_model or default_model + issue_writer_model = issue_writer_model or default_model + app.note("Pipeline starting", tags=["pipeline", "start"]) # 1. PM scopes the goal into a PRD diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index 5208a4d..ba4c5c3 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -631,6 +631,27 @@ def _default_model_from_env() -> str | None: return None +def _default_planning_model() -> str: + """Model for the planning reasoners (the ``plan`` pipeline) when the caller + passes no model. + + 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. Precedence, first match wins: + + 1. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``) + 2. the OpenRouter default when only an OpenRouter key is present + 3. the Claude ``sonnet`` alias (historical default) + """ + env_model = _default_model_from_env() + if env_model: + return env_model + if _openrouter_only_env(): + return _OPENROUTER_AUTO_DEFAULT_MODEL + return "sonnet" + + def _legacy_hint_for_model_key(key: str) -> str: if key in _LEGACY_GROUP_EQUIVALENTS: return _LEGACY_GROUP_EQUIVALENTS[key] diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 2cd7eb1..3fcd92f 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -12,6 +12,8 @@ BuildConfig, ExecutionConfig, ROLE_TO_MODEL_FIELD, + _OPENROUTER_AUTO_DEFAULT_MODEL, + _default_planning_model, _default_runtime, resolve_runtime_models, ) @@ -44,6 +46,30 @@ def _provider_env(**overrides: str): os.environ[k] = saved[k] +class TestDefaultPlanningModel(unittest.TestCase): + """`_default_planning_model` picks the planning-reasoner model default.""" + + def test_claude_env_defaults_to_sonnet(self) -> None: + with _provider_env(ANTHROPIC_API_KEY="sk-ant"): + self.assertEqual(_default_planning_model(), "sonnet") + + def test_no_provider_env_defaults_to_sonnet(self) -> None: + with _provider_env(): + self.assertEqual(_default_planning_model(), "sonnet") + + def test_openrouter_only_defaults_to_openrouter_model(self) -> None: + with _provider_env(OPENROUTER_API_KEY="sk-or"): + self.assertEqual(_default_planning_model(), _OPENROUTER_AUTO_DEFAULT_MODEL) + + def test_swe_default_model_wins_over_openrouter_auto(self) -> None: + with _provider_env(OPENROUTER_API_KEY="sk-or", SWE_DEFAULT_MODEL="openrouter/qwen/qwen3-max"): + self.assertEqual(_default_planning_model(), "openrouter/qwen/qwen3-max") + + def test_ai_model_cascade_applies(self) -> None: + with _provider_env(ANTHROPIC_API_KEY="sk-ant", AI_MODEL="opus"): + self.assertEqual(_default_planning_model(), "opus") + + class TestResolveRuntimeModels(unittest.TestCase): def test_claude_code_defaults(self) -> None: resolved = resolve_runtime_models(runtime="claude_code", models=None) diff --git a/tests/test_planner_pipeline.py b/tests/test_planner_pipeline.py index af04cfb..94bd156 100644 --- a/tests/test_planner_pipeline.py +++ b/tests/test_planner_pipeline.py @@ -336,3 +336,100 @@ async def test_plan_returns_dict_with_levels(mock_agent_ai, tmp_path): # Both in level 0 (same parallel level) assert "issue-alpha" in levels[0] assert "issue-beta" in levels[0] + + +# --------------------------------------------------------------------------- +# Provider/model default resolution (OpenRouter-only zero-config) +# --------------------------------------------------------------------------- + +_PROVIDER_ENV_KEYS = ( + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "SWE_DEFAULT_RUNTIME", + "SWE_DEFAULT_MODEL", + "AI_MODEL", + "HARNESS_MODEL", +) + + +async def _run_plan_defaults(tmp_path: str, **kwargs) -> None: + """Invoke plan() letting ai_provider/*_model default (omit them so the + env-resolution under test runs). A full happy-path mock lets it complete.""" + import swe_af.app as _app_module + + real_fn = getattr(_app_module.plan, "_original_func", _app_module.plan) + await real_fn(goal="Build a test app", repo_path=str(tmp_path), **kwargs) + + +def _happy_path_side_effect() -> list: + return [ + _make_prd_dict(), + _make_architecture_dict(), + _make_review_approved_dict(), + _make_sprint_result_dict(), + _make_issue_writer_result_dict(), + ] + + +@pytest.mark.asyncio +async def test_plan_openrouter_only_defaults_to_open_code(mock_agent_ai, tmp_path, monkeypatch): + """Only an OpenRouter key present → plan() runs on open_code with the default + OpenRouter model, with no ai_provider/model args passed.""" + for k in _PROVIDER_ENV_KEYS: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + + mock_agent_ai.side_effect = _happy_path_side_effect() + await _run_plan_defaults(str(tmp_path)) + + pm_call = mock_agent_ai.call_args_list[0] + assert pm_call.args[0].endswith("run_product_manager") + assert pm_call.kwargs["ai_provider"] == "open_code" + assert pm_call.kwargs["model"] == "openrouter/deepseek/deepseek-v4-flash" + + +@pytest.mark.asyncio +async def test_plan_claude_env_keeps_sonnet_defaults(mock_agent_ai, tmp_path, monkeypatch): + """An Anthropic key present → historical claude_code/sonnet defaults are kept + (no behavior change for Claude users).""" + for k in _PROVIDER_ENV_KEYS: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + mock_agent_ai.side_effect = _happy_path_side_effect() + await _run_plan_defaults(str(tmp_path)) + + pm_call = mock_agent_ai.call_args_list[0] + assert pm_call.kwargs["ai_provider"] == "claude_code" + assert pm_call.kwargs["model"] == "sonnet" + + +@pytest.mark.asyncio +async def test_plan_explicit_args_override_env(mock_agent_ai, tmp_path, monkeypatch): + """Explicit ai_provider/model always win over the env-resolved defaults.""" + for k in _PROVIDER_ENV_KEYS: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") # would otherwise force open_code + + mock_agent_ai.side_effect = _happy_path_side_effect() + await _run_plan_defaults(str(tmp_path), ai_provider="codex", pm_model="gpt-5") + + pm_call = mock_agent_ai.call_args_list[0] + assert pm_call.kwargs["ai_provider"] == "codex" + assert pm_call.kwargs["model"] == "gpt-5" + + +@pytest.mark.asyncio +async def test_plan_swe_default_model_overrides_openrouter_auto(mock_agent_ai, tmp_path, monkeypatch): + """SWE_DEFAULT_MODEL sets the planning model even on the OpenRouter path.""" + for k in _PROVIDER_ENV_KEYS: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen3-max") + + mock_agent_ai.side_effect = _happy_path_side_effect() + await _run_plan_defaults(str(tmp_path)) + + pm_call = mock_agent_ai.call_args_list[0] + assert pm_call.kwargs["ai_provider"] == "open_code" + assert pm_call.kwargs["model"] == "openrouter/qwen/qwen3-max"