diff --git a/docs/configuration.md b/docs/configuration.md index 2f16c95..14ba4b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -130,19 +130,24 @@ fallback chain. Any text-only rung is skipped. Pool `R` is optional, and its presence is its own switch: with no `R` models the generator serves only the five base lanes; with a full ladder it also -serves `ox-only` (every role on the free pool) and `ox-led` (the free pool -leads everything high-volume; plan/slow/designer/reviewer cross to Anthropic, -and `fable` may still lead those). A half-declared R ladder is refused. A -one-model family — Ox Alpha is exactly that — declares the same id once per -tier with ascending thinking ceilings (`low→low`, then `low→high`, then -`low→max`); the tier dial then means thinking depth. `code generate init` -never scaffolds pool R: curate those entries by hand and re-confirm -`probed: true` yourself. +serves three ox lanes — `ox-only` (every role on the free pool), +`ox-led` (the free pool leads everything high-volume while plan/slow/ +designer/reviewer cross to Anthropic, and `fable` may still lead those), and +`ox-lean` (the mirror: paid providers answer for default/task/librarian while +the free pool absorbs scout/sonic/smol/tiny/commit and vision; `fable` and +fable-as-main stay available, so an elite can take the default seat). A +half-declared R ladder is refused. A one-model family — Ox Alpha is exactly +that — declares the same id once per tier with ascending thinking ceilings; +the tier dial then means thinking depth. `code generate init` never scaffolds +pool R: curate those entries by hand and re-confirm `probed: true` yourself. 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 -would claim a level the API rejects. A single-level model writes `low→low`. +`low→max` ONLY for a genuinely contiguous run — a range claims every level in +between, and requesting one the model doesn't offer sends a level the API may +reject. A model that skips levels must be written as a comma list: +claude-opus-4-6 offers `low,medium,high,max` but not `xhigh`; Ox Alpha offers +only `low,high,max`, so its rungs declare `low→low`, `low,high`, and +`low,high,max`. A single-level model writes `low→low`. ## The `ctrl+o` classifier diff --git a/generate.go b/generate.go index 67f8d59..837e4b8 100644 --- a/generate.go +++ b/generate.go @@ -412,28 +412,66 @@ var ( genExtremes = map[string]bool{"minimal": true, "max": true} ) -func lanePrimary(lane string) string { - if lane == "ox-only" || lane == "ox-led" { - return "R" - } - if lane == "gpt-only" || lane == "gpt-led" || lane == "mixed" { - return "O" - } - return "A" -} - -func lanePure(lane string) bool { - return lane == "gpt-only" || lane == "claude-only" || lane == "ox-only" +// lanePolicy is a lane's whole role-mapping, as data. primary answers for +// default/task/librarian; delib hosts plan/slow/designer/reviewer ("" = the +// primary); util hosts scout/sonic/smol/tiny/commit ("" = primary); vision is +// the image rung's pool, with visionSmart overriding it when the model dial +// sits on smart (mixed prefers Claude's tier-3 for that). pure lanes never +// cross: reviewer and advisor stay in-primary. +// +// This table is the whole generator's sense of "a lane". A new setup — a new +// pool pairing, a new emphasis — is a row here, not new branches in genCombo; +// keep it that way. The reviewer always ends up off its lead pool unless the +// lane is pure (the anti-tunnel-vision rule), and the advisor leads on the +// minimum-diversity pool (delib when set, else the crossing target). +type lanePolicy struct { + primary string + delib string + util string + vision string + visionSmart string + pure bool +} + +var genLanePolicies = map[string]lanePolicy{ + "gpt-only": {primary: "O", pure: true}, + "gpt-led": {primary: "O"}, + "mixed": {primary: "O", delib: "A", visionSmart: "A"}, + "claude-led": {primary: "A"}, + "claude-only": {primary: "A", pure: true}, + "ox-only": {primary: "R", pure: true}, + "ox-led": {primary: "R", delib: "A"}, + // The mirror of ox-led: paid providers keep everything that answers for + // the work (default, task, librarian), while the free pool absorbs the + // high-volume background and image description. + "ox-lean": {primary: "O", delib: "A", util: "R", vision: "R"}, +} + +func (p lanePolicy) pool(role string) string { + if p.pure { + return p.primary + } + if genDelib[role] && p.delib != "" { + return p.delib + } + if genCrossLed[role] { + return otherPool(p.primary) + } + if genUtil[role] && p.util != "" { + return p.util + } + return p.primary } // lanes lists the lanes this catalog serves: the five base lanes always, plus -// the ox pair only when the optional OpenRouter ladder is fully declared. This -// is the generator side of the ox on/off switch. +// the ox trio only when the optional OpenRouter ladder is fully declared. +// This is the generator side of the ox on/off switch. Order follows the dial: +// base lanes first, ox lanes appended. func (c *catalog) lanes() []string { if !c.hasOxLadder() { return genBaseLanes } - return append(append([]string{}, genBaseLanes...), "ox-only", "ox-led") + return append(append([]string{}, genBaseLanes...), "ox-only", "ox-led", "ox-lean") } type roleRoute struct { @@ -447,35 +485,16 @@ type roleRoute struct { // of generate-profiles.py's gen(), with the hard-coded model keys generalised // to pool/tier lookups. func (c *catalog) genCombo(lane, mtier, thinking string, spark, fable, fableMain bool) map[string]roleRoute { - p := lanePrimary(lane) + pol := genLanePolicies[lane] + p := pol.primary base := genTierMap[mtier] - isPure := lanePure(lane) + isPure := pol.pure extreme := genExtremes[thinking] sparkKey := c.ladder["O"][0] eliteKey := c.ladder["A"][4] rprov := func(r string) string { - if isPure { - return p - } - if lane == "mixed" { - if genDelib[r] { - return "A" - } - return "O" - } - // ox-led keeps the free pool on everything high-volume (workers, - // utility, vision) and spends the paid judgment where it pays: - // deliberative roles cross to Anthropic. The reviewer crossing below - // lands there too, which still satisfies the anti-tunnel-vision rule — - // the second eye never shares the lead's pool. - if lane == "ox-led" && genDelib[r] { - return "A" - } - if genCrossLed[r] { - return otherPool(p) - } - return p + return pol.pool(r) } out := map[string]roleRoute{} @@ -515,10 +534,12 @@ func (c *catalog) genCombo(lane, mtier, thinking string, spark, fable, fableMain // 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. Vision - // follows the model tier; mixed smart prefers Claude's smart rung. vp := p - if lane == "mixed" && mtier == "smart" { - vp = "A" + if pol.vision != "" { + vp = pol.vision + } + if mtier == "smart" && pol.visionSmart != "" { + vp = pol.visionSmart } lead := c.visionLead(vp, base) if lead == "" && !isPure { @@ -634,6 +655,9 @@ func genValid(lane string, spark, fable, fableMain bool) bool { if lane == "ox-led" && (spark || fableMain) { return false // utility already lives on the free pool; fable-as-main would defeat the lane } + if lane == "ox-lean" && spark { + return false // the drain bucket's leads are ox here; spark has nothing to drain + } if fableMain && !fable { return false // fable-as-main only exists on top of fable } diff --git a/generate_test.go b/generate_test.go index 995ede2..1d925a0 100644 --- a/generate_test.go +++ b/generate_test.go @@ -1150,7 +1150,7 @@ const oxEntries = ` speed: 27.4 ttft: 2.1 context: 1048576 - thinking: low→high + thinking: low,high oxmax: id: stealth/ox-alpha pool: R @@ -1161,7 +1161,7 @@ const oxEntries = ` speed: 27.4 ttft: 2.1 context: 1048576 - thinking: low→max + thinking: low,high,max ` func catalogWithOx(t *testing.T) *catalog { @@ -1179,7 +1179,7 @@ func TestOxLadderGatesLanes(t *testing.T) { t.Errorf("base catalog serves %d lanes, want %d: %v", len(got), len(genBaseLanes), got) } withOx := catalogWithOx(t) - want := append(append([]string{}, genBaseLanes...), "ox-only", "ox-led") + want := append(append([]string{}, genBaseLanes...), "ox-only", "ox-led", "ox-lean") if got := withOx.lanes(); strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("ox catalog serves %v, want %v", got, want) } @@ -1203,9 +1203,11 @@ func TestGenValidOxLanes(t *testing.T) { {"ox-only", true, false, false, false}, // no O drain bucket to lead with {"ox-only", false, true, false, false}, // no A elite on a pure ox lane {"ox-led", false, false, false, true}, - {"ox-led", true, false, false, false}, // utility already lives on the free pool - {"ox-led", false, true, false, true}, // fable leads the deliberative roles - {"ox-led", false, true, true, false}, // fable-as-main defeats the free worker + {"ox-led", false, true, true, false}, // fable-as-main defeats the free worker + {"ox-lean", false, false, false, true}, + {"ox-lean", true, false, false, false}, // utility is ox here; spark has nothing to drain + {"ox-lean", false, true, false, true}, // deliberative roles stay Claude; fable may lead them + {"ox-lean", false, true, true, true}, // fable-as-default is exactly what lean is for } { if got := genValid(tc.lane, tc.spark, tc.fable, tc.main_); got != tc.want { t.Errorf("genValid(%s, sp=%v, fa=%v, famain=%v) = %v, want %v", @@ -1250,6 +1252,41 @@ func TestOxLaneRoutingPolicy(t *testing.T) { } } +// ox-lean is ox-led's mirror: paid providers answer for the work, the free +// pool absorbs the background. Fable-as-main is allowed — handing the default +// seat to the elite is exactly what an operator on this lane may want. +func TestOxLeanRoutingPolicy(t *testing.T) { + c := catalogWithOx(t) + combo := c.genCombo("ox-lean", "smart", "high", false, true, true) + // fable-as-main hands only the default seat to the elite; task and + // librarian follow the lane's OpenAI primary. + for _, r := range []string{"task", "librarian"} { + if pool := c.models[combo[r].lead].Pool; pool != "O" { + t.Errorf("ox-lean %s lead pool = %s, want O", r, pool) + } + } + if id := c.models[combo["default"].lead].ID; id != "claude-fable-5" { + t.Errorf("ox-lean famain default = %s, want claude-fable-5", id) + } + for _, r := range []string{"scout", "sonic", "smol", "tiny", "commit", "vision"} { + if id := c.models[combo[r].lead].ID; id != "stealth/ox-alpha" { + t.Errorf("ox-lean %s lead = %s, want stealth/ox-alpha", r, id) + } + } + for _, r := range []string{"plan", "slow", "designer", "reviewer"} { + if pool := c.models[combo[r].lead].Pool; pool != "A" { + t.Errorf("ox-lean deliberative %s pool = %s, want A", r, pool) + } + } + // Without fable, workers stay on the OpenAI primary. + base := c.genCombo("ox-lean", "normal", "medium", false, false, false) + for _, r := range []string{"default", "task"} { + if pool := c.models[base[r].lead].Pool; pool != "O" { + t.Errorf("ox-lean %s pool = %s, want O", r, pool) + } + } +} + func TestAdvisorsIncludeOxContext(t *testing.T) { withOx := catalogWithOx(t) got := withOx.renderAdvisors() diff --git a/main.go b/main.go index d3d4c32..9fa8182 100644 --- a/main.go +++ b/main.go @@ -989,7 +989,7 @@ func facetDefs(glyphs map[string]string) []facet { return []facet{ // The ox values are trimmed away by applyCatalog unless the catalog // serves them — presence in models.yml is what makes them appear. - {"lane", []string{"gpt-only", "gpt-led", "mixed", "claude-led", "claude-only", "ox-only", "ox-led"}, glyphs["lane"]}, + {"lane", []string{"gpt-only", "gpt-led", "mixed", "claude-led", "claude-only", "ox-only", "ox-led", "ox-lean"}, glyphs["lane"]}, {"model", []string{"fast", "normal", "smart"}, glyphs["model"]}, {"thinking", []string{"minimal", "low", "medium", "high", "xhigh", "max"}, glyphs["thinking"]}, // advisor as a power/cost dial: a quick glance, a proper review, or a @@ -1226,12 +1226,18 @@ func (m model) meter(label, glyph, fill string, n int) string { // from the baked __advisors__ table. The advisor is the independent second // opinion, so it uses the opposite provider to whoever leads the session: GPT // when the lead is Claude — a Claude-led (or pure-GPT) lane, or fable-as-main -// handing the default role to Fable — and Claude otherwise. Only the pure lanes -// stay on their own provider (gpt-only keeps GPT; claude-only keeps Claude). +// handing the default role to Fable — and Claude otherwise. Pure lanes keep +// their own provider, including the ox lane: ox-only's second opinion is Ox. +// +// On the mixed ox lanes the chain carries a cross-pool net in spend order — +// the other paid pool's cheapest rung, then the free pool itself — so a dead +// quota never leaves the second eye blind. func (m model) advisorChain(level string) []string { lane := m.sel["lane"] ctx := "claude" - if lane == "gpt-only" || lane == "claude-led" { + if lane == "ox-only" { + ctx = "ox" + } else if lane == "gpt-only" || lane == "claude-led" { ctx = "gpt" } // fable-as-main puts Claude Fable in the default seat, so the second @@ -1240,7 +1246,26 @@ func (m model) advisorChain(level string) []string { if lane != "claude-only" && m.sel["fable"] == "on" && m.sel["main"] == "on" { ctx = "gpt" } - return m.advisors[level+"/"+ctx] + chain := m.advisors[level+"/"+ctx] + if lane == "ox-led" || lane == "ox-lean" { + tail := m.advisors["glance/gpt"] + if ctx == "gpt" { + tail = m.advisors["glance/claude"] + } + for _, t := range append(append([]string{}, tail...), m.advisors["glance/ox"]...) { + dup := false + for _, c := range chain { + if c == t { + dup = true + break + } + } + if !dup && t != "" { + chain = append(chain, t) + } + } + } + return chain } // roleOf returns the role name of a routing row ("● task" → "task"). @@ -1314,6 +1339,9 @@ func (m model) visibleFacets() []facet { if lane == "ox-led" && (f.key == "spark" || f.key == "main" || f.key == "fast") { continue } + if lane == "ox-lean" && (f.key == "spark") { + continue + } if f.key == "spark" && m.noSpark { continue } @@ -1334,7 +1362,7 @@ func comboID(sel map[string]string) string { if lane == "gpt-only" || lane == "ox-only" { fb = "off" } - if lane == "claude-only" || lane == "ox-only" || lane == "ox-led" { + if lane == "claude-only" || lane == "ox-only" || lane == "ox-led" || lane == "ox-lean" { sp = "off" } spid, faid := "nosp", "nofa" @@ -1437,6 +1465,8 @@ func laneColor(lane string) string { return "#1f9d5b" // deeper green — pure free pool case "ox-led": return "#5fce96" // lighter green — leans Ox Alpha + case "ox-lean": + return "#2dd4bf" // teal — paid work riding the free pool case "gpt-only": return "#3f8ef0" // deeper blue — pure pool case "gpt-led": @@ -1455,7 +1485,13 @@ func laneColor(lane string) string { // through. The catalog's pool column is authoritative; the name heuristic is // only for catalogs that predate it. func (m model) prefixed(model string) string { - if f, ok := m.facts[model]; ok && f.pool != "" { + // Routing tokens carry a thinking level ("id:level"); the catalog is + // keyed on the bare id. Qualify the full token either way. + id := model + if i := strings.IndexByte(id, ':'); i >= 0 { + id = id[:i] + } + if f, ok := m.facts[id]; ok && f.pool != "" { switch f.pool { case "O": return "openai-codex/" + model @@ -2000,8 +2036,8 @@ func (m model) listW() int { } // The ox lanes widen the lane row past this function's old 80-cell // aesthetic cap; a wider list beats clipping dial options mid-value. - if w > 104 { - w = 104 + if w > 116 { + w = 116 } return w } diff --git a/main_test.go b/main_test.go index d8fd1b4..d42fe81 100644 --- a/main_test.go +++ b/main_test.go @@ -3705,3 +3705,54 @@ func TestTrimLanesResetsVanishedLane(t *testing.T) { t.Errorf("selection left on vanished lane: %q", m.sel["lane"]) } } + +// The launch path prefixes tokens that still carry their thinking level +// ("id:level"); the facts table is keyed on the bare id. This regresses the +// bug where ox ids missed the pool lookup and fell to the two-provider name +// heuristic — openai-codex/stealth/ox-alpha is not a model omp knows. +func TestPrefixedLeveledTokens(t *testing.T) { + m := model{facts: map[string]modelFact{ + "stealth/ox-alpha": {pool: "R"}, + }} + if got := m.prefixed("stealth/ox-alpha:high"); got != "openrouter/stealth/ox-alpha:high" { + t.Fatalf("prefixed(leveled) = %q, want openrouter/stealth/ox-alpha:high", got) + } +} + +// End to end: the emitted config must qualify every reference with the +// catalog's pool, and the ox-led advisor must carry its cross-pool net +// (Claude lead, GPT glance rung, then the free pool) instead of a bare lead. +func TestGenConfigYAMLOxLed(t *testing.T) { + blocks := loadBlocks("/tmp/grid-ox.plain") + if len(blocks) == 0 { + t.Skip("regeneration fixture /tmp/grid-ox.plain absent") + } + m := model{ + generated: blocks, + advisors: parseAdvisors(blocks["__advisors__"]), + facts: parseFacts(blocks["__models__"]), + glyphs: defaultGlyphs(), + facets: facetDefs(defaultGlyphs()), + sel: map[string]string{"lane": "ox-led", "model": "smart", "thinking": "high", + "advisor": "glance", "spark": "off", "fable": "off", "main": "off", "fast": "off"}, + } + cfg := m.genConfigYAML() + if strings.Contains(cfg, "openai-codex/stealth") || strings.Contains(cfg, "anthropic/stealth") { + t.Errorf("heuristic prefix leaked onto ox ids:\n%s", cfg) + } + if !strings.Contains(cfg, "openrouter/stealth/ox-alpha:") { + t.Errorf("no pool-qualified ox reference:\n%s", cfg) + } + adv := m.applyAdvisor(m.generated[comboID(m.sel)], "glance") + advisorRow := "" + for _, r := range adv { + if roleOf(r) == "advisor" { + advisorRow = r + } + } + for _, want := range []string{"claude-haiku-4-5:low", "gpt-5.6-luna:low", "stealth/ox-alpha:low"} { + if !strings.Contains(advisorRow, want) { + t.Errorf("advisor net missing %s in %q", want, advisorRow) + } + } +} diff --git a/suggest.go b/suggest.go index 00e826e..881838f 100644 --- a/suggest.go +++ b/suggest.go @@ -103,7 +103,7 @@ func (m model) Commander() clikit.Commander { // bucket is maxed or unauthed. Runs after an applied proposal, so the generator // can't land on an impossible or unavailable combo. func (m *model) repairConstraints() { - if lane := m.sel["lane"]; lane == "claude-only" || lane == "ox-only" || lane == "ox-led" { + if lane := m.sel["lane"]; lane == "claude-only" || lane == "ox-only" || lane == "ox-led" || lane == "ox-lean" { m.sel["spark"] = "off" } if lane := m.sel["lane"]; lane == "gpt-only" || lane == "ox-only" {