Skip to content

Commit 039726a

Browse files
AbirAbbasclaude
andauthored
feat(models): SWE_MODEL_LOW/MED/HIGH per-tier model env vars (#100)
* feat(models): SWE_MODEL_LOW/MED/HIGH per-tier model env vars Classify the 17 agent roles into three capability tiers (high = planning reasoning, med = coding/review/QA, low = mechanical) and let each tier be pointed at a model with one env var, instead of enumerating 17 role keys. Tier vars slot between the SWE_DEFAULT_MODEL cascade and caller config in resolve_runtime_models; SWE_MODEL_HIGH also becomes the default for the plan pipeline, whose reasoners are high-tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(env): document SWE_MODEL_LOW/MED/HIGH tier vars and #variant suffix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(go): SWE_MODEL_LOW/MED/HIGH tier env vars — port of the Python tier layer RoleToTier + tierModelsFromEnv in internal/config, applied at the same precedence point as swe_af/execution/schemas.py (after the default-model env cascade, before models config); DefaultPlanningModel consults SWE_MODEL_HIGH first. Tests mirror tests/test_model_tiers.py 1:1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a2a24d3 commit 039726a

8 files changed

Lines changed: 579 additions & 12 deletions

File tree

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@ GH_TOKEN=ghp_...
124124
# baked-in defaults.
125125
# SWE_DEFAULT_MODEL=openrouter/minimax/minimax-m2.6
126126

127+
# Per-tier models. Each of the 17 agent roles belongs to one of three tiers:
128+
# high = planning-heavy reasoning (pm, architect, tech_lead, replan)
129+
# med = coding / review / QA (coder, qa, code_reviewer, sprint_planner,
130+
# retry_advisor, issue_writer, issue_advisor, verifier, merger,
131+
# integration_tester, ci_fixer)
132+
# low = mechanical transformation (qa_synthesizer, git)
133+
# Set any subset; a tier var applies to every role in its tier. Precedence:
134+
# beats SWE_DEFAULT_MODEL / AI_MODEL / HARNESS_MODEL, loses to caller config
135+
# (`models.default` and `models.<role>`). SWE_MODEL_HIGH also becomes the
136+
# default for the standalone `plan` pipeline (its reasoners are high-tier).
137+
# A model id may carry an optional "#variant" reasoning-effort suffix
138+
# (e.g. openrouter/z-ai/glm-5.2#high), passed through to opencode (--variant)
139+
# and codex (model_reasoning_effort).
140+
# SWE_MODEL_HIGH=openrouter/z-ai/glm-5.2
141+
# SWE_MODEL_MED=openrouter/deepseek/deepseek-v4-pro
142+
# SWE_MODEL_LOW=openrouter/deepseek/deepseek-v4-flash
143+
127144
# Runtime/model selection is configured via API request config (V2):
128145
# {
129146
# "runtime": "claude_code" | "open_code" | "codex",

go/internal/config/config_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ var providerEnvKeys = []string{
1414
"SWE_DEFAULT_MODEL",
1515
"AI_MODEL",
1616
"HARNESS_MODEL",
17+
"SWE_MODEL_LOW",
18+
"SWE_MODEL_MED",
19+
"SWE_MODEL_HIGH",
1720
"SWE_CODEX_AUTH_MODE",
1821
"OPENAI_API_KEY",
1922
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package config
2+
3+
import "testing"
4+
5+
// Ports tests/test_model_tiers.py — the per-tier model env vars
6+
// (SWE_MODEL_LOW / _MED / _HIGH).
7+
//
8+
// Validation contract:
9+
// - No tier envs set → resolution is unchanged for every runtime.
10+
// - A tier var applies to exactly the roles in its tier (see RoleToTier).
11+
// - Tier vars beat the SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL cascade,
12+
// and lose to caller config (models["default"], models["<role>"]).
13+
// - SWE_MODEL_HIGH also wins the DefaultPlanningModel cascade (the planning
14+
// reasoners are high-tier roles).
15+
16+
// highTierFields ports _HIGH_FIELDS.
17+
var highTierFields = map[string]bool{
18+
"pm_model": true,
19+
"architect_model": true,
20+
"tech_lead_model": true,
21+
"replan_model": true,
22+
}
23+
24+
// openCodeBaseModel ports _OPEN_CODE_BASE.
25+
const openCodeBaseModel = "openrouter/minimax/minimax-m2.5"
26+
27+
// TestModelTiers_NoTierEnvsUnchanged ports TestNoTierEnvsUnchanged: no tier
28+
// envs set → resolution unchanged for all runtimes.
29+
func TestModelTiers_NoTierEnvsUnchanged(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
runtime string
33+
env map[string]string
34+
want func(field string) string
35+
}{
36+
{"claude_code base defaults", "claude_code", nil, func(field string) string {
37+
if field == "qa_synthesizer_model" {
38+
return "haiku"
39+
}
40+
return "sonnet"
41+
}},
42+
{"open_code base defaults", "open_code", nil,
43+
func(string) string { return openCodeBaseModel }},
44+
{"codex base defaults", "codex", map[string]string{"SWE_CODEX_AUTH_MODE": "api_key"},
45+
func(string) string { return "gpt-5.3-codex" }},
46+
}
47+
for _, tc := range tests {
48+
t.Run(tc.name, func(t *testing.T) {
49+
clearProviderEnv(t)
50+
for k, v := range tc.env {
51+
t.Setenv(k, v)
52+
}
53+
got := mustResolve(t, tc.runtime, nil)
54+
for _, field := range AllModelFields {
55+
if want := tc.want(field); got[field] != want {
56+
t.Errorf("field %s = %q, want %q", field, got[field], want)
57+
}
58+
}
59+
})
60+
}
61+
}
62+
63+
// TestModelTiers_TierEnvApplication ports TestTierEnvApplication: a tier var
64+
// applies to exactly the roles in its tier.
65+
func TestModelTiers_TierEnvApplication(t *testing.T) {
66+
t.Run("high only changes exactly the high-tier fields", func(t *testing.T) {
67+
clearProviderEnv(t)
68+
t.Setenv("SWE_MODEL_HIGH", "openrouter/z-ai/glm-5.2")
69+
got := mustResolve(t, "open_code", nil)
70+
for _, field := range AllModelFields {
71+
want := openCodeBaseModel
72+
if highTierFields[field] {
73+
want = "openrouter/z-ai/glm-5.2"
74+
}
75+
if got[field] != want {
76+
t.Errorf("field %s = %q, want %q", field, got[field], want)
77+
}
78+
}
79+
})
80+
81+
t.Run("all three tiers resolve every field by role tier", func(t *testing.T) {
82+
clearProviderEnv(t)
83+
tierModels := map[string]string{
84+
"high": "openrouter/z-ai/glm-5.2",
85+
"med": "openrouter/deepseek/deepseek-v4-pro",
86+
"low": "openrouter/deepseek/deepseek-v4-flash",
87+
}
88+
for tier, model := range tierModels {
89+
t.Setenv(tierModelEnvVars[tier], model)
90+
}
91+
got := mustResolve(t, "open_code", nil)
92+
for role, field := range RoleToModelField {
93+
if want := tierModels[RoleToTier[role]]; got[field] != want {
94+
t.Errorf("role %s (%s) = %q, want %q", role, field, got[field], want)
95+
}
96+
}
97+
})
98+
99+
t.Run("empty tier value treated as unset", func(t *testing.T) {
100+
clearProviderEnv(t)
101+
t.Setenv("SWE_MODEL_HIGH", " ")
102+
got := mustResolve(t, "open_code", nil)
103+
for _, field := range AllModelFields {
104+
if got[field] != openCodeBaseModel {
105+
t.Errorf("field %s = %q, want %q", field, got[field], openCodeBaseModel)
106+
}
107+
}
108+
})
109+
}
110+
111+
// TestModelTiers_Precedence ports TestTierPrecedence: tier vars beat the
112+
// default-model env cascade and lose to caller config.
113+
func TestModelTiers_Precedence(t *testing.T) {
114+
t.Run("models.default beats all tier vars", func(t *testing.T) {
115+
clearProviderEnv(t)
116+
t.Setenv("SWE_MODEL_HIGH", "tier-high")
117+
t.Setenv("SWE_MODEL_MED", "tier-med")
118+
t.Setenv("SWE_MODEL_LOW", "tier-low")
119+
got := mustResolve(t, "open_code", map[string]string{"default": "caller-default"})
120+
for _, field := range AllModelFields {
121+
if got[field] != "caller-default" {
122+
t.Errorf("field %s = %q, want caller-default", field, got[field])
123+
}
124+
}
125+
})
126+
127+
t.Run("models.<role> beats everything for that role only", func(t *testing.T) {
128+
clearProviderEnv(t)
129+
t.Setenv("SWE_MODEL_MED", "tier-med")
130+
got := mustResolve(t, "open_code", map[string]string{"coder": "caller-coder"})
131+
if got["coder_model"] != "caller-coder" {
132+
t.Errorf("coder_model = %q, want caller-coder", got["coder_model"])
133+
}
134+
// Other med-tier roles still pick up the tier env value.
135+
if got["qa_model"] != "tier-med" {
136+
t.Errorf("qa_model = %q, want tier-med", got["qa_model"])
137+
}
138+
})
139+
140+
t.Run("tier var beats default env cascade for its roles", func(t *testing.T) {
141+
clearProviderEnv(t)
142+
t.Setenv("SWE_DEFAULT_MODEL", "env-default")
143+
t.Setenv("AI_MODEL", "env-ai-model")
144+
t.Setenv("SWE_MODEL_HIGH", "tier-high")
145+
got := mustResolve(t, "open_code", nil)
146+
for _, field := range AllModelFields {
147+
// Unset tiers still get the cascade winner (SWE_DEFAULT_MODEL).
148+
want := "env-default"
149+
if highTierFields[field] {
150+
want = "tier-high"
151+
}
152+
if got[field] != want {
153+
t.Errorf("field %s = %q, want %q", field, got[field], want)
154+
}
155+
}
156+
})
157+
}
158+
159+
// TestDefaultPlanningModel_HighTier ports TestDefaultPlanningModelHighTier:
160+
// SWE_MODEL_HIGH wins the planning-model cascade; without it the prior
161+
// behavior is unchanged.
162+
func TestDefaultPlanningModel_HighTier(t *testing.T) {
163+
tests := []struct {
164+
name string
165+
env map[string]string
166+
want string
167+
}{
168+
{"high tier var wins over default-model env",
169+
map[string]string{"SWE_DEFAULT_MODEL": "env-default", "SWE_MODEL_HIGH": "tier-high"}, "tier-high"},
170+
{"unset high tier falls back to env cascade",
171+
map[string]string{"SWE_DEFAULT_MODEL": "env-default"}, "env-default"},
172+
{"whitespace high tier treated as unset",
173+
map[string]string{"SWE_MODEL_HIGH": " "}, "sonnet"},
174+
{"no env at all defaults to sonnet", nil, "sonnet"},
175+
}
176+
for _, tc := range tests {
177+
t.Run(tc.name, func(t *testing.T) {
178+
clearProviderEnv(t)
179+
for k, v := range tc.env {
180+
t.Setenv(k, v)
181+
}
182+
if got := DefaultPlanningModel(); got != tc.want {
183+
t.Fatalf("DefaultPlanningModel() = %q, want %q", got, tc.want)
184+
}
185+
})
186+
}
187+
}
188+
189+
// TestModelTiers_MappingCompleteness ports TestTierMappingCompleteness: every
190+
// role has a tier, and every tier is a known tier.
191+
func TestModelTiers_MappingCompleteness(t *testing.T) {
192+
t.Run("every role has a tier", func(t *testing.T) {
193+
for role := range RoleToModelField {
194+
if _, ok := RoleToTier[role]; !ok {
195+
t.Errorf("role %q missing from RoleToTier", role)
196+
}
197+
}
198+
for role := range RoleToTier {
199+
if _, ok := RoleToModelField[role]; !ok {
200+
t.Errorf("RoleToTier has unknown role %q", role)
201+
}
202+
}
203+
})
204+
205+
t.Run("every tier value is known", func(t *testing.T) {
206+
known := make(map[string]bool, len(modelTiers))
207+
for _, tier := range modelTiers {
208+
known[tier] = true
209+
}
210+
for role, tier := range RoleToTier {
211+
if !known[tier] {
212+
t.Errorf("role %q has unknown tier %q", role, tier)
213+
}
214+
}
215+
if len(tierModelEnvVars) != len(modelTiers) {
216+
t.Errorf("tierModelEnvVars has %d tiers, want %d", len(tierModelEnvVars), len(modelTiers))
217+
}
218+
for _, tier := range modelTiers {
219+
if _, ok := tierModelEnvVars[tier]; !ok {
220+
t.Errorf("tier %q missing from tierModelEnvVars", tier)
221+
}
222+
}
223+
})
224+
}

go/internal/config/resolve.go

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ var allowedModelKeys = func() map[string]struct{} {
8888
return m
8989
}()
9090

91+
// modelTiers ports MODEL_TIERS (ordered).
92+
var modelTiers = []string{"low", "med", "high"}
93+
94+
// RoleToTier ports ROLE_TO_TIER: capability tier per role. "high" =
95+
// planning-heavy reasoning, "med" = coding/review/QA work, "low" = mechanical
96+
// transformation. Each tier can be pointed at a model via its env var (see
97+
// tierModelEnvVars).
98+
var RoleToTier = map[string]string{
99+
"pm": "high",
100+
"architect": "high",
101+
"tech_lead": "high",
102+
"replan": "high",
103+
"sprint_planner": "med",
104+
"coder": "med",
105+
"qa": "med",
106+
"code_reviewer": "med",
107+
"retry_advisor": "med",
108+
"issue_writer": "med",
109+
"issue_advisor": "med",
110+
"verifier": "med",
111+
"merger": "med",
112+
"integration_tester": "med",
113+
"ci_fixer": "med",
114+
"qa_synthesizer": "low",
115+
"git": "low",
116+
}
117+
118+
// tierModelEnvVars ports TIER_MODEL_ENV_VARS (tier → env var).
119+
var tierModelEnvVars = map[string]string{
120+
"low": "SWE_MODEL_LOW",
121+
"med": "SWE_MODEL_MED",
122+
"high": "SWE_MODEL_HIGH",
123+
}
124+
91125
// ---------------------------------------------------------------------------
92126
// Model default strings
93127
// ---------------------------------------------------------------------------
@@ -207,9 +241,30 @@ func defaultModelFromEnv() string {
207241
return ""
208242
}
209243

210-
// DefaultPlanningModel ports _default_planning_model: env cascade, then the
211-
// OpenRouter default when only an OpenRouter key is present, else "sonnet".
244+
// tierModelsFromEnv ports _tier_models_from_env: tier → model id for each
245+
// SWE_MODEL_<TIER> env var that is set. Lets the deployer point each role
246+
// class at a different model without enumerating every role (see RoleToTier).
247+
// Only tiers whose env var is non-empty (stripped) appear in the result; unset
248+
// tiers fall through to the lower precedence layers in ResolveRuntimeModels.
249+
func tierModelsFromEnv() map[string]string {
250+
tiers := make(map[string]string, len(tierModelEnvVars))
251+
for tier, v := range tierModelEnvVars {
252+
if value := envStripped(v); value != "" {
253+
tiers[tier] = value
254+
}
255+
}
256+
return tiers
257+
}
258+
259+
// DefaultPlanningModel ports _default_planning_model: SWE_MODEL_HIGH first
260+
// (the planning reasoners are high-tier roles, see RoleToTier — the same
261+
// relative precedence tier env vars have in ResolveRuntimeModels), then the
262+
// env cascade, then the OpenRouter default when only an OpenRouter key is
263+
// present, else "sonnet".
212264
func DefaultPlanningModel() string {
265+
if highModel := tierModelsFromEnv()["high"]; highModel != "" {
266+
return highModel
267+
}
213268
if envModel := defaultModelFromEnv(); envModel != "" {
214269
return envModel
215270
}
@@ -263,8 +318,10 @@ func sortedAllowedModelKeys() []string {
263318
}
264319

265320
// ResolveRuntimeModels ports resolve_runtime_models. Resolution order (lowest →
266-
// highest precedence): runtime base defaults → env cascade → models["default"]
267-
// → models["<role>"]. fieldNames nil defaults to AllModelFields.
321+
// highest precedence): runtime base defaults → env cascade → tier env vars
322+
// (SWE_MODEL_LOW / SWE_MODEL_MED / SWE_MODEL_HIGH, each applying to the roles
323+
// in its tier, see RoleToTier) → models["default"] → models["<role>"].
324+
// fieldNames nil defaults to AllModelFields.
268325
func ResolveRuntimeModels(runtime string, models map[string]string, fieldNames []string) (map[string]string, error) {
269326
if fieldNames == nil {
270327
fieldNames = AllModelFields
@@ -306,6 +363,15 @@ func ResolveRuntimeModels(runtime string, models map[string]string, fieldNames [
306363
}
307364
}
308365

366+
if tierModels := tierModelsFromEnv(); len(tierModels) > 0 {
367+
for _, field := range fieldNames {
368+
tier := RoleToTier[modelFieldToRole[field]]
369+
if model, ok := tierModels[tier]; ok {
370+
resolved[field] = model
371+
}
372+
}
373+
}
374+
309375
if defaultModel, ok := flatModels["default"]; ok && defaultModel != "" {
310376
for _, field := range fieldNames {
311377
resolved[field] = defaultModel

go/internal/orch/plan_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@ func TestPlanWritesArtifactsAtExactPaths(t *testing.T) {
421421

422422
func TestPlanOpenRouterOnlyDefaults(t *testing.T) {
423423
for _, k := range []string{"ANTHROPIC_API_KEY", "SWE_DEFAULT_RUNTIME",
424-
"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL"} {
424+
"SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL",
425+
"SWE_MODEL_LOW", "SWE_MODEL_MED", "SWE_MODEL_HIGH"} {
425426
t.Setenv(k, "")
426427
}
427428
t.Setenv("OPENROUTER_API_KEY", "sk-or-test")

0 commit comments

Comments
 (0)