diff --git a/.env.example b/.env.example index 78dae3e4..dc25a579 100644 --- a/.env.example +++ b/.env.example @@ -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: @@ -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) --- diff --git a/Dockerfile b/Dockerfile index 639efcae..f5b3b70c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 5acdff63..407bdf8d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/go/Dockerfile b/go/Dockerfile index 0c9d13f2..d91b89c2 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -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 diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 222949bb..5be64f89 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -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. diff --git a/go/internal/config/fastconfig.go b/go/internal/config/fastconfig.go index 0d26ee4d..4ba2e048 100644 --- a/go/internal/config/fastconfig.go +++ b/go/internal/config/fastconfig.go @@ -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[""]. An unknown key yields the verbatim // "Unknown role key" error. func FastResolveModels(config *FastBuildConfig) (map[string]string, error) { @@ -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 } diff --git a/go/internal/config/resolve.go b/go/internal/config/resolve.go index a1ef0eec..bbcbcba0 100644 --- a/go/internal/config/resolve.go +++ b/go/internal/config/resolve.go @@ -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 } @@ -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() { @@ -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 } diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index 532c056b..bf4483c2 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -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 @@ -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 @@ -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 @@ -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``) @@ -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 diff --git a/swe_af/fast/schemas.py b/swe_af/fast/schemas.py index 8f0f1b6c..d87c7043 100644 --- a/swe_af/fast/schemas.py +++ b/swe_af/fast/schemas.py @@ -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[""]`` — overrides a specific role (pm, coder, verifier, git) @@ -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} diff --git a/tests/fast/test_schemas.py b/tests/fast/test_schemas.py index 1c7c5a15..9805dc05 100644 --- a/tests/fast/test_schemas.py +++ b/tests/fast/test_schemas.py @@ -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"}) diff --git a/tests/test_model_config.py b/tests/test_model_config.py index a7a38967..92e5c5c5 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -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. @@ -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()