Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions swe_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions swe_af/execution/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions tests/test_model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
BuildConfig,
ExecutionConfig,
ROLE_TO_MODEL_FIELD,
_OPENROUTER_AUTO_DEFAULT_MODEL,
_default_planning_model,
_default_runtime,
resolve_runtime_models,
)
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 97 additions & 0 deletions tests/test_planner_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading