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
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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{}
Expand Down
34 changes: 29 additions & 5 deletions generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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") {
Expand All @@ -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
Expand Down
Loading