From 889d081b09f5582810c4bf1e2594ede1ea38213b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 26 Jul 2026 15:02:58 +0000 Subject: [PATCH] fix(generate): route vision by model tier --- docs/configuration.md | 5 +++++ generate.go | 26 +++++++++++++++++++------- generate_test.go | 34 +++++++++++++++++++++++++++++----- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 956ee9d..7cf37c7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,6 +113,11 @@ Each entry under `models:`: | `thinking` | the levels the model really offers (see below) | | `image` | omitted for image-capable models, which is most of them. `init` writes `image: false` only for a model omp reports as text-only, and the `vision` role then avoids it | +The `vision` lead follows the model dial: `fast`, `normal`, and `smart` select +tiers 1, 2, and 3 respectively. Mixed routing keeps GPT for fast and normal, +then prefers Claude's tier-3 model for smart, with the GPT tier-3 model in its +fallback chain. Any text-only rung is skipped. + The thinking scale is `minimal · low · medium · high · xhigh · max`. Write `low→max` for a contiguous run, or a comma list when the model skips a level: claude-opus-4-6 offers `low,medium,high,max` but not `xhigh`, and a range there diff --git a/generate.go b/generate.go index d67d512..c561737 100644 --- a/generate.go +++ b/generate.go @@ -307,10 +307,17 @@ func (c *catalog) buildChain(lead string, isPure bool) []string { return dedup([]string{sib, cr, c.sibDown(cr)}, lead) } -// visionLead is the cheapest rung on a pool that accepts image input. The -// codex spark variants are text-only, so the cheapest rung is not always it. -func (c *catalog) visionLead(pool string) string { - for t := 1; t <= 3; t++ { +// visionLead returns the requested capability rung when it accepts images. +// If that rung is text-only, prefer a more capable rung before stepping down; +// the model dial must still influence vision without ever routing images to a +// model that cannot consume them. +func (c *catalog) visionLead(pool string, tier int) string { + for t := tier; t <= 3; t++ { + if k := c.ladder[pool][t]; k != "" && c.models[k].multimodal() { + return k + } + } + for t := tier - 1; t >= 1; t-- { if k := c.ladder[pool][t]; k != "" && c.models[k].multimodal() { return k } @@ -438,10 +445,15 @@ func (c *catalog) genCombo(lane, mtier, thinking string, spark, fable, fableMain if r == "vision" { // omp falls back @vision → @default → active model when it needs an // image described, and describeForTextModels is on by default — so - // this rung must be a model that actually accepts images. - lead := c.visionLead(p) + // this rung must be a model that actually accepts images. Vision + // follows the model tier; mixed smart prefers Claude's smart rung. + vp := p + if lane == "mixed" && mtier == "smart" { + vp = "A" + } + lead := c.visionLead(vp, base) if lead == "" && !isPure { - lead = c.visionLead(otherPool(p)) + lead = c.visionLead(otherPool(vp), base) } if lead == "" { out[r] = roleRoute{} diff --git a/generate_test.go b/generate_test.go index edb1acb..80d1b8c 100644 --- a/generate_test.go +++ b/generate_test.go @@ -157,7 +157,7 @@ const goldenMixedSmart = `mixed_smart_medium_sp_fa mixed · smart · medium · ● scout gpt-5.6-terra:medium → gpt-5.6-luna:medium ● sonic gpt-5.6-terra:medium → gpt-5.6-luna:medium advisor claude-sonnet-5:high → claude-haiku-4-5:low → gpt-5.6-terra:low → gpt-5.6-luna:low - vision gpt-5.6-luna:low → claude-haiku-4-5:low + vision claude-opus-5:low → claude-sonnet-5:low → gpt-5.6-sol:low → gpt-5.6-terra:low smol gpt-5.6-terra:low tiny gpt-5.3-codex-spark:low → gpt-5.6-terra:low commit gpt-5.3-codex-spark:low → gpt-5.6-luna:low @@ -175,7 +175,7 @@ const goldenClaudeMax = `claude-only_normal_max_nosp_famain claude-only · norm ● scout claude-sonnet-5:max → claude-haiku-4-5:xhigh ● sonic claude-sonnet-5:max → claude-haiku-4-5:xhigh advisor claude-haiku-4-5:xhigh - vision claude-haiku-4-5:xhigh + vision claude-sonnet-5:max → claude-haiku-4-5:xhigh smol claude-sonnet-5:max tiny claude-haiku-4-5:xhigh commit claude-haiku-4-5:xhigh @@ -197,7 +197,7 @@ const goldenClaudeSmart = `claude-only_smart_medium_nosp_nofa claude-only · sm ● scout claude-sonnet-5:medium → claude-haiku-4-5:medium ● sonic claude-sonnet-5:medium → claude-haiku-4-5:medium advisor claude-sonnet-5:high → claude-haiku-4-5:low - vision claude-haiku-4-5:low + vision claude-opus-5:low → claude-sonnet-5:low → claude-haiku-4-5:low smol claude-sonnet-5:low tiny claude-sonnet-5:low commit claude-haiku-4-5:minimal @@ -370,8 +370,8 @@ func TestVisionSkipsTextOnlyModels(t *testing.T) { if err != nil { t.Fatalf("loadCatalog: %v", err) } - if lead := c.visionLead("O"); lead == "" || c.models[lead].ID != "gpt-5.6-terra" { - t.Errorf("visionLead(O) = %q, want the cheapest image-capable rung (terra)", lead) + if lead := c.visionLead("O", 1); lead == "" || c.models[lead].ID != "gpt-5.6-terra" { + t.Errorf("visionLead(O, 1) = %q, want the next image-capable rung (terra)", lead) } block := c.renderCombo("gpt-only", "fast", "medium", false, false, false) for _, l := range strings.Split(block, "\n") { @@ -381,6 +381,30 @@ func TestVisionSkipsTextOnlyModels(t *testing.T) { } } +func TestVisionFollowsModelTier(t *testing.T) { + c := fixtureCatalog(t) + for _, tc := range []struct { + lane, tier, want string + }{ + {"gpt-only", "fast", "gpt-5.6-luna"}, + {"gpt-only", "normal", "gpt-5.6-terra"}, + {"gpt-only", "smart", "gpt-5.6-sol"}, + {"claude-only", "fast", "claude-haiku-4-5"}, + {"claude-only", "normal", "claude-sonnet-5"}, + {"claude-only", "smart", "claude-opus-5"}, + {"mixed", "fast", "gpt-5.6-luna"}, + {"mixed", "normal", "gpt-5.6-terra"}, + {"mixed", "smart", "claude-opus-5"}, + } { + t.Run(tc.lane+"/"+tc.tier, func(t *testing.T) { + route := c.genCombo(tc.lane, tc.tier, "medium", false, false, false)["vision"] + if got := c.models[route.lead].ID; got != tc.want { + t.Errorf("vision lead = %q, want %q", got, tc.want) + } + }) + } +} + func TestCatalogWithoutOptionalTiers(t *testing.T) { trimmed := "" skip := false