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
12 changes: 7 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@
# SWE_DEFAULT_RUNTIME=open_code
# SWE_DEFAULT_MODEL=minimax-global-openai/MiniMax-M3
# HARNESS_MODEL feeds OpenCode's small_model (SWE_DEFAULT_MODEL does not);
# without it, small-model calls hit the baked OpenRouter default and need
# OPENROUTER_API_KEY.
# without it, small-model calls hit the baked OpenRouter default
# (openrouter/deepseek/deepseek-v4-flash-0731) and need OPENROUTER_API_KEY.
# HARNESS_MODEL only steers the open_code runtime.
# HARNESS_MODEL=minimax-global-openai/MiniMax-M3
# China endpoint: SWE_DEFAULT_MODEL=minimax-cn-openai/MiniMax-M3
# Anthropic-compatible endpoint:
Expand Down Expand Up @@ -81,9 +82,10 @@
# The coding loop passes the qa_synthesizer role model (default "haiku"), which
# WithModel overrides per call, so AI_MODEL is only the fallback default.
# WARNING: AI_MODEL is ALSO the second step of the role-model cascade
# (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL), so setting it here
# repoints every agent role too. To pick a model for the roles, set
# SWE_DEFAULT_MODEL instead and leave this one unset.
# (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL; HARNESS_MODEL counts only
# on the open_code runtime), so setting it here repoints every agent role
# too. To pick a model for the roles, set SWE_DEFAULT_MODEL instead and
# leave this one unset.
# AI_MODEL=anthropic/claude-haiku-4.5

# --- Optional: Web search (open runtime) ---
Expand Down
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ ENV PATH="/root/.opencode/bin:${PATH}"
# Default HARNESS_MODEL inside the image so a fresh container with no
# env override has *some* value to interpolate. Railway / docker-compose
# overrides win because their env injects after the image's ENV.
ENV HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6
#
# The value MUST match _OPENROUTER_AUTO_DEFAULT_MODEL (swe_af/execution/
# schemas.py) — it is what an OpenRouter-only deploy actually runs, since the
# model-resolution cascade reads HARNESS_MODEL for the open_code runtime (and
# ONLY for open_code: claude_code / codex deployments resolve their own
# runtime defaults and never see this variable).
ENV HARNESS_MODEL=openrouter/deepseek/deepseek-v4-flash-0731
RUN mkdir -p /root/.config/opencode
COPY opencode.json /root/.config/opencode/opencode.json

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ For the Anthropic-compatible Claude path, set `ANTHROPIC_AUTH_TOKEN`, set `ANTHR

For Codex with ChatGPT subscription auth, install the Codex CLI on the host, run `codex login`, leave `OPENAI_API_KEY` unset for this process, and set `SWE_CODEX_AUTH_MODE=chatgpt` or `auto`. For OpenAI API-platform billing, set `SWE_CODEX_AUTH_MODE=api_key` and `OPENAI_API_KEY`.

> **Codex deployments using the Docker image must set `SWE_DEFAULT_MODEL=gpt-5.3-codex` on the environment** (or pass `models: {"default": "gpt-5.3-codex"}` in every build's `config`). The image bakes `HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6` as an OpenCode fallback, and SWE-AF's model-resolution env cascade reads `HARNESS_MODEL` — so without `SWE_DEFAULT_MODEL` set, the Codex CLI receives an OpenRouter model id it can't handle and the Product Manager reasoner fails in ~13s. Setting `SWE_DEFAULT_MODEL` makes the cascade pin every role to the Codex model.
> The Docker image bakes `HARNESS_MODEL=openrouter/deepseek/deepseek-v4-flash-0731` so OpenCode's `small_model` config interpolation always has a value. `HARNESS_MODEL` only affects the `open_code` runtime — `claude_code` and `codex` deployments resolve their own runtime defaults (codex picks its model by auth mode) and can override per role via `SWE_DEFAULT_MODEL` / `models` as usual.

> Codex CLI's `workspace-write` sandbox uses bubblewrap (`bwrap`) and needs Linux user namespaces enabled on the host. Most production Linux hosts and managed container runtimes (Railway, etc.) allow this by default, but local Docker on WSL2 or hardened environments may refuse with `bwrap: No permissions to create a new namespace`. If the verifier reports that error, the coder ran but couldn't write files — enable user namespaces on the host before relying on the codex runtime there.

Expand Down
8 changes: 7 additions & 1 deletion go/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ ENV PATH="/root/.opencode/bin:${PATH}"
# so it must honor the same env var the rest of the stack uses. Default the
# value inside the image so a fresh container has *some* value to interpolate;
# Railway / docker-compose overrides win because their env injects afterward.
ENV HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6
#
# The value MUST match openRouterAutoDefaultModel (internal/config) — it is
# what an OpenRouter-only deploy actually runs, since the model-resolution
# cascade reads HARNESS_MODEL for the open_code runtime (and ONLY for
# open_code: claude_code / codex deployments resolve their own runtime
# defaults and never see this variable).
ENV HARNESS_MODEL=openrouter/deepseek/deepseek-v4-flash-0731
RUN mkdir -p /root/.config/opencode
COPY opencode.json /root/.config/opencode/opencode.json

Expand Down
59 changes: 59 additions & 0 deletions go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,65 @@ func TestResolveRuntimeModels_EnvCascade(t *testing.T) {
}
}

func TestResolveRuntimeModels_HarnessModelScopedToOpenCode(t *testing.T) {
clearProviderEnv(t)
// The Docker image bakes HARNESS_MODEL for OpenCode's small_model
// interpolation; it must steer open_code only. claude_code and codex keep
// their runtime defaults instead of receiving an openrouter/… id their
// CLIs cannot consume.
t.Setenv("HARNESS_MODEL", "openrouter/deepseek/deepseek-v4-flash-0731")

got := mustResolve(t, "open_code", nil)
if got["pm_model"] != "openrouter/deepseek/deepseek-v4-flash-0731" {
t.Errorf("open_code honors HARNESS_MODEL = %q", got["pm_model"])
}

got = mustResolve(t, "claude_code", nil)
if got["pm_model"] != "sonnet" {
t.Errorf("claude_code ignores HARNESS_MODEL = %q", got["pm_model"])
}
if got["qa_synthesizer_model"] != "haiku" {
t.Errorf("claude_code qa_synthesizer base = %q", got["qa_synthesizer_model"])
}

t.Setenv("SWE_CODEX_AUTH_MODE", "api_key")
got = mustResolve(t, "codex", nil)
if got["pm_model"] != "gpt-5.3-codex" {
t.Errorf("codex ignores HARNESS_MODEL = %q", got["pm_model"])
}

// Deployer-intent vars are NOT runtime-scoped: AI_MODEL still wins on
// claude_code.
t.Setenv("AI_MODEL", "claude-opus-5")
got = mustResolve(t, "claude_code", nil)
if got["pm_model"] != "claude-opus-5" {
t.Errorf("claude_code honors AI_MODEL = %q", got["pm_model"])
}
}

func TestFastResolveModels_HarnessModelScopedToOpenCode(t *testing.T) {
clearProviderEnv(t)
t.Setenv("HARNESS_MODEL", "openrouter/qwen/qwen-3-coder")

openCfg := &FastBuildConfig{Runtime: "open_code"}
got, err := FastResolveModels(openCfg)
if err != nil {
t.Fatalf("FastResolveModels(open_code): %v", err)
}
if got["pm_model"] != "openrouter/qwen/qwen-3-coder" {
t.Errorf("fast open_code honors HARNESS_MODEL = %q", got["pm_model"])
}

claudeCfg := &FastBuildConfig{Runtime: "claude_code"}
got, err = FastResolveModels(claudeCfg)
if err != nil {
t.Fatalf("FastResolveModels(claude_code): %v", err)
}
if got["pm_model"] != "haiku" {
t.Errorf("fast claude_code ignores HARNESS_MODEL = %q", got["pm_model"])
}
}

func TestResolveRuntimeModels_EnvCascadeOrder(t *testing.T) {
clearProviderEnv(t)
// AI_MODEL used when SWE_DEFAULT_MODEL unset.
Expand Down
5 changes: 3 additions & 2 deletions go/internal/config/fastconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ func LoadFastBuildConfig(raw map[string]any) (*FastBuildConfig, error) {

// FastResolveModels ports fast_resolve_models — resolves the four role model
// strings. Resolution order (last wins): runtime default → env cascade
// (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, same as the main path) →
// (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, the latter only on
// open_code — same as the main path) →
// models["default"] → models["<role>"]. An unknown key yields the verbatim
// "Unknown role key" error.
func FastResolveModels(config *FastBuildConfig) (map[string]string, error) {
Expand All @@ -140,7 +141,7 @@ func FastResolveModels(config *FastBuildConfig) (map[string]string, error) {
// Deployer env cascade: lets the same variable that selects a model for
// the main node select it for fast builds too. Caller-supplied models
// (below) still win.
if envModel := defaultModelFromEnv(); envModel != "" {
if envModel := defaultModelFromEnv(config.Runtime); envModel != "" {
for _, role := range fastRoles {
resolved[role] = envModel
}
Expand Down
15 changes: 12 additions & 3 deletions go/internal/config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,17 @@ var defaultModelEnvVars = []string{"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MOD

// defaultModelFromEnv ports _default_model_from_env: first non-empty (stripped)
// of SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, else "" (meaning None).
func defaultModelFromEnv() string {
//
// HARNESS_MODEL is an OpenCode-ecosystem variable — it also feeds OpenCode's
// small_model via config interpolation, and the Docker image bakes a default
// value precisely so that interpolation always has one — so it is consulted
// only for the open_code runtime. Letting it steer claude_code / codex pushed
// the image's baked openrouter/… id into CLIs that cannot consume it.
func defaultModelFromEnv(runtime string) string {
for _, v := range defaultModelEnvVars {
if v == "HARNESS_MODEL" && runtime != "open_code" {
continue
}
if value := envStripped(v); value != "" {
return value
}
Expand Down Expand Up @@ -269,7 +278,7 @@ func DefaultPlanningModel() string {
if highModel := tierModelsFromEnv()["high"]; highModel != "" {
return highModel
}
if envModel := defaultModelFromEnv(); envModel != "" {
if envModel := defaultModelFromEnv(DefaultRuntime()); envModel != "" {
return envModel
}
if openRouterOnlyEnv() {
Expand Down Expand Up @@ -376,7 +385,7 @@ func ResolveRuntimeModels(runtime string, models map[string]string, fieldNames [
resolved[field] = base[field]
}

if envDefault := defaultModelFromEnv(); envDefault != "" {
if envDefault := defaultModelFromEnv(runtime); envDefault != "" {
for _, field := range fieldNames {
resolved[field] = envDefault
}
Expand Down
23 changes: 18 additions & 5 deletions swe_af/execution/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,8 +706,8 @@ def _default_runtime() -> Literal["claude_code", "open_code", "codex"]:
)


def _default_model_from_env() -> str | None:
"""Pick a single model id from deployer env vars.
def _default_model_from_env(runtime: str) -> str | None:
"""Pick a single model id from deployer env vars, for ``runtime``.

Cascades through the well-known env-var names this stack uses for model
selection so the same Railway / docker-compose variable that points
Expand All @@ -716,11 +716,21 @@ def _default_model_from_env() -> str | None:

SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL

``HARNESS_MODEL`` is an OpenCode-ecosystem variable — it also feeds
OpenCode's ``small_model`` via config interpolation, and the Docker image
bakes a default value precisely so that interpolation always has one — so
it only participates in the cascade for the ``open_code`` runtime. Letting
it steer ``claude_code`` / ``codex`` pushed the image's baked
``openrouter/…`` id into CLIs that cannot consume it, breaking every
non-OpenCode Docker deployment that didn't also set ``SWE_DEFAULT_MODEL``.

Caller-supplied ``models={"default": …}`` and per-role overrides still
beat the env value (see ``resolve_runtime_models`` precedence). All
unset / empty → ``None``, which means "use the runtime base defaults".
"""
for var in _DEFAULT_MODEL_ENV_VARS:
if var == "HARNESS_MODEL" and runtime != "open_code":
continue
value = os.getenv(var, "").strip()
if value:
return value
Expand Down Expand Up @@ -780,7 +790,8 @@ def _default_planning_model(runtime: str | None = None) -> str:
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``)
2. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` →
``HARNESS_MODEL``, the latter only on ``open_code``)
3. the runtime's own auto/base default:
- ``codex`` → a codex-native model (never ``openrouter/…``)
- ``open_code`` → the OpenRouter auto default (OpenRouter-only
Expand Down Expand Up @@ -869,7 +880,9 @@ def resolve_runtime_models(
Resolution order (lowest → highest precedence):
1. runtime base defaults (``_RUNTIME_BASE_MODELS[runtime]``)
2. env-var cascade: ``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` →
``HARNESS_MODEL`` (first non-empty wins, applies to all roles)
``HARNESS_MODEL`` (first non-empty wins, applies to all roles;
``HARNESS_MODEL`` is consulted only on the ``open_code`` runtime —
see ``_default_model_from_env``)
3. tier env vars: ``SWE_MODEL_LOW`` / ``SWE_MODEL_MED`` /
``SWE_MODEL_HIGH``, each applying to the roles in its tier
(see ``ROLE_TO_TIER``)
Expand Down Expand Up @@ -898,7 +911,7 @@ def resolve_runtime_models(
base = {field: _OPENROUTER_AUTO_DEFAULT_MODEL for field in base}
resolved: dict[str, str] = {field: base[field] for field in field_names}

env_default = _default_model_from_env()
env_default = _default_model_from_env(runtime)
if env_default:
for field in field_names:
resolved[field] = env_default
Expand Down
10 changes: 6 additions & 4 deletions swe_af/fast/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]:
Resolution order (last wins):
1. Runtime default (haiku or the shared open_code default, per runtime)
2. Env cascade: ``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``
(``HARNESS_MODEL`` only on the ``open_code`` runtime)
3. ``models["default"]`` — overrides all roles
4. ``models["<role>"]`` — overrides a specific role (pm, coder, verifier, git)

Expand All @@ -178,12 +179,13 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]:

resolved: dict[str, str] = {role: runtime_default for role in _FAST_ROLES}

# Deployer env cascade (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL), same
# as the main path — lets the variable that selects a model for the main
# node select it for fast builds too. Caller-supplied models still win.
# Deployer env cascade (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, the
# latter only on open_code), same as the main path — lets the variable that
# selects a model for the main node select it for fast builds too.
# Caller-supplied models still win.
from swe_af.execution.schemas import _default_model_from_env # noqa: PLC0415

env_model = _default_model_from_env()
env_model = _default_model_from_env(config.runtime)
if env_model:
resolved = {role: env_model for role in _FAST_ROLES}

Expand Down
12 changes: 12 additions & 0 deletions tests/fast/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,18 @@ def test_env_cascade_applies_to_fast_roles(self, monkeypatch) -> None:
for role in _ALL_FOUR_ROLES:
assert resolved[role] == "openrouter/qwen/qwen-3-coder"

def test_harness_model_scoped_to_open_code_in_fast(self, monkeypatch) -> None:
# The image-baked HARNESS_MODEL steers open_code fast builds but never
# claude_code ones (same scoping as the main path).
for var in ("SWE_DEFAULT_MODEL", "AI_MODEL"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("HARNESS_MODEL", "openrouter/qwen/qwen-3-coder")
open_resolved = fast_resolve_models(FastBuildConfig(runtime="open_code"))
claude_resolved = fast_resolve_models(FastBuildConfig(runtime="claude_code"))
for role in _ALL_FOUR_ROLES:
assert open_resolved[role] == "openrouter/qwen/qwen-3-coder"
assert claude_resolved[role] == "haiku" # _CLAUDE_CODE_DEFAULT

def test_config_models_beat_env_cascade(self, monkeypatch) -> None:
monkeypatch.setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder")
cfg = FastBuildConfig(runtime="open_code", models={"default": "openrouter/z-ai/glm-5"})
Expand Down
62 changes: 62 additions & 0 deletions tests/test_model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,42 @@ def test_harness_model_env_used_when_others_unset(self) -> None:
resolved[field], "openrouter/moonshotai/kimi-k2.6"
)

def test_harness_model_ignored_on_claude_code(self) -> None:
# HARNESS_MODEL is OpenCode-scoped: the Docker image bakes a default
# value for OpenCode's small_model interpolation, so honoring it on
# claude_code would silently push an openrouter/… id into the Claude
# CLI on every Docker deployment.
cascade_vars = {"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL"}
env = {k: v for k, v in os.environ.items() if k not in cascade_vars}
env["HARNESS_MODEL"] = "openrouter/deepseek/deepseek-v4-flash-0731"
with mock.patch.dict(os.environ, env, clear=True):
resolved = resolve_runtime_models(runtime="claude_code", models=None)
self.assertEqual(resolved["pm_model"], "sonnet")
self.assertEqual(resolved["qa_synthesizer_model"], "haiku")

def test_harness_model_ignored_on_codex(self) -> None:
# Same scoping for codex: it resolves its auth-mode default instead of
# the baked OpenCode fallback (pre-fix this failed the PM in ~13s).
cascade_vars = {"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL"}
env = {k: v for k, v in os.environ.items() if k not in cascade_vars}
env["HARNESS_MODEL"] = "openrouter/deepseek/deepseek-v4-flash-0731"
env["SWE_CODEX_AUTH_MODE"] = "api_key"
with mock.patch.dict(os.environ, env, clear=True):
resolved = resolve_runtime_models(runtime="codex", models=None)
for field in ALL_MODEL_FIELDS:
self.assertEqual(resolved[field], "gpt-5.3-codex")

def test_swe_default_model_still_applies_on_claude_code(self) -> None:
# Only HARNESS_MODEL is runtime-scoped — the deployer-intent vars
# (SWE_DEFAULT_MODEL, AI_MODEL) keep steering every runtime.
cascade_vars = {"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL"}
env = {k: v for k, v in os.environ.items() if k not in cascade_vars}
env["AI_MODEL"] = "claude-opus-5"
with mock.patch.dict(os.environ, env, clear=True):
resolved = resolve_runtime_models(runtime="claude_code", models=None)
for field in ALL_MODEL_FIELDS:
self.assertEqual(resolved[field], "claude-opus-5")

def test_swe_default_model_beats_ai_model_in_cascade(self) -> None:
# When both are set, the SWE-specific name wins so deployers can
# override the global AI_MODEL just for this service.
Expand Down Expand Up @@ -564,5 +600,31 @@ def test_unknown_runtime_provider_raises(self) -> None:
normalize_runtime_provider("bad_runtime")


class TestDockerfileHarnessModelDefault(unittest.TestCase):
"""Guard: the HARNESS_MODEL the images bake must be the OpenRouter auto
default the code and README promise. Docker layer caching plus the env
cascade means a drifted value silently becomes what every OpenRouter-only
deployment actually runs (that is exactly how kimi-k2.6 shipped)."""

def _dockerfile_value(self, relpath: str) -> str:
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(root, relpath), encoding="utf-8") as fp:
for line in fp:
if line.startswith("ENV HARNESS_MODEL="):
return line.strip().split("=", 1)[1]
raise AssertionError(f"no ENV HARNESS_MODEL line in {relpath}")

def test_python_image_matches_auto_default(self) -> None:
self.assertEqual(
self._dockerfile_value("Dockerfile"), _OPENROUTER_AUTO_DEFAULT_MODEL
)

def test_go_image_matches_auto_default(self) -> None:
self.assertEqual(
self._dockerfile_value(os.path.join("go", "Dockerfile")),
_OPENROUTER_AUTO_DEFAULT_MODEL,
)


if __name__ == "__main__":
unittest.main()
Loading