Skip to content

Commit 0c64fe7

Browse files
AbirAbbasclaude
andauthored
fix: Docker deploys run the promised OpenRouter default (HARNESS_MODEL scoped to open_code) (#142)
* fix(docker): bake the promised OpenRouter default model, not kimi-k2.6 The images set ENV HARNESS_MODEL=openrouter/moonshotai/kimi-k2.6 as an OpenCode small_model fallback, but the model-resolution env cascade reads HARNESS_MODEL — so every OpenRouter-only Docker/Railway deployment silently ran kimi instead of the documented auto default openrouter/deepseek/deepseek-v4-flash-0731. Point the baked value at the auto default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(models): consult HARNESS_MODEL only on the open_code runtime HARNESS_MODEL is an OpenCode-ecosystem variable: it feeds OpenCode's small_model via config interpolation, and the Docker image bakes a default value precisely so that interpolation always resolves. Having it in the model cascade for every runtime pushed that baked openrouter/… id into the claude_code and codex CLIs on Docker deployments, breaking both unless the deployer also set SWE_DEFAULT_MODEL (the README documented the codex failure instead of fixing it). Scope the cascade's HARNESS_MODEL step to open_code in both the Python and Go nodes (main path, fast path, planning default). SWE_DEFAULT_MODEL and AI_MODEL keep steering every runtime. Adds contract tests on both sides and a drift guard pinning the Dockerfiles' baked value to _OPENROUTER_AUTO_DEFAULT_MODEL; updates README / .env.example accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f9aec21 commit 0c64fe7

11 files changed

Lines changed: 194 additions & 22 deletions

File tree

.env.example

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@
4747
# SWE_DEFAULT_RUNTIME=open_code
4848
# SWE_DEFAULT_MODEL=minimax-global-openai/MiniMax-M3
4949
# HARNESS_MODEL feeds OpenCode's small_model (SWE_DEFAULT_MODEL does not);
50-
# without it, small-model calls hit the baked OpenRouter default and need
51-
# OPENROUTER_API_KEY.
50+
# without it, small-model calls hit the baked OpenRouter default
51+
# (openrouter/deepseek/deepseek-v4-flash-0731) and need OPENROUTER_API_KEY.
52+
# HARNESS_MODEL only steers the open_code runtime.
5253
# HARNESS_MODEL=minimax-global-openai/MiniMax-M3
5354
# China endpoint: SWE_DEFAULT_MODEL=minimax-cn-openai/MiniMax-M3
5455
# Anthropic-compatible endpoint:
@@ -81,9 +82,10 @@
8182
# The coding loop passes the qa_synthesizer role model (default "haiku"), which
8283
# WithModel overrides per call, so AI_MODEL is only the fallback default.
8384
# WARNING: AI_MODEL is ALSO the second step of the role-model cascade
84-
# (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL), so setting it here
85-
# repoints every agent role too. To pick a model for the roles, set
86-
# SWE_DEFAULT_MODEL instead and leave this one unset.
85+
# (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL; HARNESS_MODEL counts only
86+
# on the open_code runtime), so setting it here repoints every agent role
87+
# too. To pick a model for the roles, set SWE_DEFAULT_MODEL instead and
88+
# leave this one unset.
8789
# AI_MODEL=anthropic/claude-haiku-4.5
8890

8991
# --- Optional: Web search (open runtime) ---

Dockerfile

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

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ For the Anthropic-compatible Claude path, set `ANTHROPIC_AUTH_TOKEN`, set `ANTHR
403403

404404
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`.
405405

406-
> **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.
406+
> 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.
407407
408408
> 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.
409409

go/Dockerfile

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

go/internal/config/config_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,65 @@ func TestResolveRuntimeModels_EnvCascade(t *testing.T) {
200200
}
201201
}
202202

203+
func TestResolveRuntimeModels_HarnessModelScopedToOpenCode(t *testing.T) {
204+
clearProviderEnv(t)
205+
// The Docker image bakes HARNESS_MODEL for OpenCode's small_model
206+
// interpolation; it must steer open_code only. claude_code and codex keep
207+
// their runtime defaults instead of receiving an openrouter/… id their
208+
// CLIs cannot consume.
209+
t.Setenv("HARNESS_MODEL", "openrouter/deepseek/deepseek-v4-flash-0731")
210+
211+
got := mustResolve(t, "open_code", nil)
212+
if got["pm_model"] != "openrouter/deepseek/deepseek-v4-flash-0731" {
213+
t.Errorf("open_code honors HARNESS_MODEL = %q", got["pm_model"])
214+
}
215+
216+
got = mustResolve(t, "claude_code", nil)
217+
if got["pm_model"] != "sonnet" {
218+
t.Errorf("claude_code ignores HARNESS_MODEL = %q", got["pm_model"])
219+
}
220+
if got["qa_synthesizer_model"] != "haiku" {
221+
t.Errorf("claude_code qa_synthesizer base = %q", got["qa_synthesizer_model"])
222+
}
223+
224+
t.Setenv("SWE_CODEX_AUTH_MODE", "api_key")
225+
got = mustResolve(t, "codex", nil)
226+
if got["pm_model"] != "gpt-5.3-codex" {
227+
t.Errorf("codex ignores HARNESS_MODEL = %q", got["pm_model"])
228+
}
229+
230+
// Deployer-intent vars are NOT runtime-scoped: AI_MODEL still wins on
231+
// claude_code.
232+
t.Setenv("AI_MODEL", "claude-opus-5")
233+
got = mustResolve(t, "claude_code", nil)
234+
if got["pm_model"] != "claude-opus-5" {
235+
t.Errorf("claude_code honors AI_MODEL = %q", got["pm_model"])
236+
}
237+
}
238+
239+
func TestFastResolveModels_HarnessModelScopedToOpenCode(t *testing.T) {
240+
clearProviderEnv(t)
241+
t.Setenv("HARNESS_MODEL", "openrouter/qwen/qwen-3-coder")
242+
243+
openCfg := &FastBuildConfig{Runtime: "open_code"}
244+
got, err := FastResolveModels(openCfg)
245+
if err != nil {
246+
t.Fatalf("FastResolveModels(open_code): %v", err)
247+
}
248+
if got["pm_model"] != "openrouter/qwen/qwen-3-coder" {
249+
t.Errorf("fast open_code honors HARNESS_MODEL = %q", got["pm_model"])
250+
}
251+
252+
claudeCfg := &FastBuildConfig{Runtime: "claude_code"}
253+
got, err = FastResolveModels(claudeCfg)
254+
if err != nil {
255+
t.Fatalf("FastResolveModels(claude_code): %v", err)
256+
}
257+
if got["pm_model"] != "haiku" {
258+
t.Errorf("fast claude_code ignores HARNESS_MODEL = %q", got["pm_model"])
259+
}
260+
}
261+
203262
func TestResolveRuntimeModels_EnvCascadeOrder(t *testing.T) {
204263
clearProviderEnv(t)
205264
// AI_MODEL used when SWE_DEFAULT_MODEL unset.

go/internal/config/fastconfig.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ func LoadFastBuildConfig(raw map[string]any) (*FastBuildConfig, error) {
126126

127127
// FastResolveModels ports fast_resolve_models — resolves the four role model
128128
// strings. Resolution order (last wins): runtime default → env cascade
129-
// (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, same as the main path) →
129+
// (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, the latter only on
130+
// open_code — same as the main path) →
130131
// models["default"] → models["<role>"]. An unknown key yields the verbatim
131132
// "Unknown role key" error.
132133
func FastResolveModels(config *FastBuildConfig) (map[string]string, error) {
@@ -140,7 +141,7 @@ func FastResolveModels(config *FastBuildConfig) (map[string]string, error) {
140141
// Deployer env cascade: lets the same variable that selects a model for
141142
// the main node select it for fast builds too. Caller-supplied models
142143
// (below) still win.
143-
if envModel := defaultModelFromEnv(); envModel != "" {
144+
if envModel := defaultModelFromEnv(config.Runtime); envModel != "" {
144145
for _, role := range fastRoles {
145146
resolved[role] = envModel
146147
}

go/internal/config/resolve.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,17 @@ var defaultModelEnvVars = []string{"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MOD
236236

237237
// defaultModelFromEnv ports _default_model_from_env: first non-empty (stripped)
238238
// of SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, else "" (meaning None).
239-
func defaultModelFromEnv() string {
239+
//
240+
// HARNESS_MODEL is an OpenCode-ecosystem variable — it also feeds OpenCode's
241+
// small_model via config interpolation, and the Docker image bakes a default
242+
// value precisely so that interpolation always has one — so it is consulted
243+
// only for the open_code runtime. Letting it steer claude_code / codex pushed
244+
// the image's baked openrouter/… id into CLIs that cannot consume it.
245+
func defaultModelFromEnv(runtime string) string {
240246
for _, v := range defaultModelEnvVars {
247+
if v == "HARNESS_MODEL" && runtime != "open_code" {
248+
continue
249+
}
241250
if value := envStripped(v); value != "" {
242251
return value
243252
}
@@ -269,7 +278,7 @@ func DefaultPlanningModel() string {
269278
if highModel := tierModelsFromEnv()["high"]; highModel != "" {
270279
return highModel
271280
}
272-
if envModel := defaultModelFromEnv(); envModel != "" {
281+
if envModel := defaultModelFromEnv(DefaultRuntime()); envModel != "" {
273282
return envModel
274283
}
275284
if openRouterOnlyEnv() {
@@ -376,7 +385,7 @@ func ResolveRuntimeModels(runtime string, models map[string]string, fieldNames [
376385
resolved[field] = base[field]
377386
}
378387

379-
if envDefault := defaultModelFromEnv(); envDefault != "" {
388+
if envDefault := defaultModelFromEnv(runtime); envDefault != "" {
380389
for _, field := range fieldNames {
381390
resolved[field] = envDefault
382391
}

swe_af/execution/schemas.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -706,8 +706,8 @@ def _default_runtime() -> Literal["claude_code", "open_code", "codex"]:
706706
)
707707

708708

709-
def _default_model_from_env() -> str | None:
710-
"""Pick a single model id from deployer env vars.
709+
def _default_model_from_env(runtime: str) -> str | None:
710+
"""Pick a single model id from deployer env vars, for ``runtime``.
711711
712712
Cascades through the well-known env-var names this stack uses for model
713713
selection so the same Railway / docker-compose variable that points
@@ -716,11 +716,21 @@ def _default_model_from_env() -> str | None:
716716
717717
SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL
718718
719+
``HARNESS_MODEL`` is an OpenCode-ecosystem variable — it also feeds
720+
OpenCode's ``small_model`` via config interpolation, and the Docker image
721+
bakes a default value precisely so that interpolation always has one — so
722+
it only participates in the cascade for the ``open_code`` runtime. Letting
723+
it steer ``claude_code`` / ``codex`` pushed the image's baked
724+
``openrouter/…`` id into CLIs that cannot consume it, breaking every
725+
non-OpenCode Docker deployment that didn't also set ``SWE_DEFAULT_MODEL``.
726+
719727
Caller-supplied ``models={"default": …}`` and per-role overrides still
720728
beat the env value (see ``resolve_runtime_models`` precedence). All
721729
unset / empty → ``None``, which means "use the runtime base defaults".
722730
"""
723731
for var in _DEFAULT_MODEL_ENV_VARS:
732+
if var == "HARNESS_MODEL" and runtime != "open_code":
733+
continue
724734
value = os.getenv(var, "").strip()
725735
if value:
726736
return value
@@ -780,7 +790,8 @@ def _default_planning_model(runtime: str | None = None) -> str:
780790
Precedence is inherited from ``resolve_runtime_models`` (highest first):
781791
782792
1. ``SWE_MODEL_HIGH`` (planning reasoners are high-tier)
783-
2. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``)
793+
2. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` →
794+
``HARNESS_MODEL``, the latter only on ``open_code``)
784795
3. the runtime's own auto/base default:
785796
- ``codex`` → a codex-native model (never ``openrouter/…``)
786797
- ``open_code`` → the OpenRouter auto default (OpenRouter-only
@@ -869,7 +880,9 @@ def resolve_runtime_models(
869880
Resolution order (lowest → highest precedence):
870881
1. runtime base defaults (``_RUNTIME_BASE_MODELS[runtime]``)
871882
2. env-var cascade: ``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` →
872-
``HARNESS_MODEL`` (first non-empty wins, applies to all roles)
883+
``HARNESS_MODEL`` (first non-empty wins, applies to all roles;
884+
``HARNESS_MODEL`` is consulted only on the ``open_code`` runtime —
885+
see ``_default_model_from_env``)
873886
3. tier env vars: ``SWE_MODEL_LOW`` / ``SWE_MODEL_MED`` /
874887
``SWE_MODEL_HIGH``, each applying to the roles in its tier
875888
(see ``ROLE_TO_TIER``)
@@ -898,7 +911,7 @@ def resolve_runtime_models(
898911
base = {field: _OPENROUTER_AUTO_DEFAULT_MODEL for field in base}
899912
resolved: dict[str, str] = {field: base[field] for field in field_names}
900913

901-
env_default = _default_model_from_env()
914+
env_default = _default_model_from_env(runtime)
902915
if env_default:
903916
for field in field_names:
904917
resolved[field] = env_default

swe_af/fast/schemas.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]:
160160
Resolution order (last wins):
161161
1. Runtime default (haiku or the shared open_code default, per runtime)
162162
2. Env cascade: ``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``
163+
(``HARNESS_MODEL`` only on the ``open_code`` runtime)
163164
3. ``models["default"]`` — overrides all roles
164165
4. ``models["<role>"]`` — overrides a specific role (pm, coder, verifier, git)
165166
@@ -178,12 +179,13 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]:
178179

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

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

186-
env_model = _default_model_from_env()
188+
env_model = _default_model_from_env(config.runtime)
187189
if env_model:
188190
resolved = {role: env_model for role in _FAST_ROLES}
189191

tests/fast/test_schemas.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ def test_env_cascade_applies_to_fast_roles(self, monkeypatch) -> None:
148148
for role in _ALL_FOUR_ROLES:
149149
assert resolved[role] == "openrouter/qwen/qwen-3-coder"
150150

151+
def test_harness_model_scoped_to_open_code_in_fast(self, monkeypatch) -> None:
152+
# The image-baked HARNESS_MODEL steers open_code fast builds but never
153+
# claude_code ones (same scoping as the main path).
154+
for var in ("SWE_DEFAULT_MODEL", "AI_MODEL"):
155+
monkeypatch.delenv(var, raising=False)
156+
monkeypatch.setenv("HARNESS_MODEL", "openrouter/qwen/qwen-3-coder")
157+
open_resolved = fast_resolve_models(FastBuildConfig(runtime="open_code"))
158+
claude_resolved = fast_resolve_models(FastBuildConfig(runtime="claude_code"))
159+
for role in _ALL_FOUR_ROLES:
160+
assert open_resolved[role] == "openrouter/qwen/qwen-3-coder"
161+
assert claude_resolved[role] == "haiku" # _CLAUDE_CODE_DEFAULT
162+
151163
def test_config_models_beat_env_cascade(self, monkeypatch) -> None:
152164
monkeypatch.setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder")
153165
cfg = FastBuildConfig(runtime="open_code", models={"default": "openrouter/z-ai/glm-5"})

0 commit comments

Comments
 (0)