diff --git a/docs/configuration.md b/docs/configuration.md index 7e50297..2f16c95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ Each entry under `models:`: | Field | Meaning | |---|---| | `id` | the model id omp routes to | -| `pool` | `O` (OpenAI/Codex) or `A` (Anthropic) | +| `pool` | `O` (OpenAI/Codex), `A` (Anthropic), or `R` (OpenRouter — optional, see below) | | `tier` | `1` cheap · `2` regular · `3` smart — the per-pool fallback ladder. `0` (a fast idle-bucket model the `spark` toggle drains) and `4` (a scarce elite the `fable` toggle leads with) are optional | | `bucket` | the quota window this model draws from (`claude-main`, `claude-fable`, `codex-main`, `codex-spark`). The TUI prefers it over guessing from the model family | | `cost_in` / `cost_out` | dollars per 1M tokens; drives the cost meter | @@ -126,10 +126,23 @@ 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. +### Pool R and the ox lanes + +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. + 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. +would claim a level the API rejects. A single-level model writes `low→low`. ## The `ctrl+o` classifier diff --git a/generate.go b/generate.go index 8c5148a..67f8d59 100644 --- a/generate.go +++ b/generate.go @@ -11,6 +11,11 @@ package main // - pools O (OpenAI/Codex) and A (Anthropic) must each fill tiers 1..3 — // the per-pool fallback ladder (cheap, regular, smart). code assumes both // providers are present; generation fails loudly otherwise. +// - pool R (OpenRouter) is optional: a free/aggregator lane. When any R model +// is declared, its tiers 1..3 must all be filled (a one-model family +// declares the same id three times with ascending thinking ceilings); when +// none is, the ox lanes are simply not generated and the TUI never offers +// them. That presence is the whole on/off switch — no dial of its own. // - tier 0 (an idle-bucket speed model, "spark") and tier 4 (a scarce elite, // "fable") are optional; without them the corresponding facet combos are // simply not generated and the TUI hides the dial. @@ -110,15 +115,15 @@ func loadCatalogBytes(raw []byte, path string) (*catalog, error) { if probedNode == nil || probedNode.Value != "true" { return nil, fmt.Errorf("%s: missing `probed: true` — these models were never verified as callable by your account. Re-run `code generate init --refresh`, which probes every model, or set `probed: true` yourself once you have confirmed each one", path) } - c := &catalog{models: map[string]catModel{}, levels: map[string][]int{}, ladder: map[string][5]string{"O": {}, "A": {}}} + c := &catalog{models: map[string]catModel{}, levels: map[string][]int{}, ladder: map[string][5]string{"O": {}, "A": {}, "R": {}}} for i := 0; i+1 < len(modelsNode.Content); i += 2 { key := modelsNode.Content[i].Value var m catModel if err := modelsNode.Content[i+1].Decode(&m); err != nil { return nil, fmt.Errorf("%s: model %q: %w", path, key, err) } - if m.Pool != "O" && m.Pool != "A" { - return nil, fmt.Errorf("%s: model %q: pool must be O or A, got %q", path, key, m.Pool) + if m.Pool != "O" && m.Pool != "A" && m.Pool != "R" { + return nil, fmt.Errorf("%s: model %q: pool must be O, A, or R (optional OpenRouter), got %q", path, key, m.Pool) } if m.Tier < 0 || m.Tier > 4 { return nil, fmt.Errorf("%s: model %q: tier must be 0..4, got %d", path, key, m.Tier) @@ -144,6 +149,13 @@ func loadCatalogBytes(raw []byte, path string) (*catalog, error) { } } } + // Pool R is all-or-nothing: a half-declared ox ladder would generate lanes + // whose fallback rungs silently vanish. One-model families declare the same + // id at every tier with ascending thinking ceilings — that repetition is + // the encoding, not a mistake. + if c.hasOxLadderPart() && !c.hasOxLadder() { + return nil, fmt.Errorf("%s: pool R must fill tiers 1..3 when present — declare the model once per tier with ascending thinking ceilings, or remove the pool entirely", path) + } if err := c.checkLadder(path); err != nil { return nil, err } @@ -159,7 +171,9 @@ func loadCatalogBytes(raw []byte, path string) (*catalog, error) { // less. Only tiers 1..3 are the capability ladder — tier 0 is a bucket-drain // lead and tier 4 an elite lead, both deliberately off it. func (c *catalog) checkLadder(path string) error { - for _, pool := range []string{"O", "A"} { + // R joins only when declared; an absent pool has no rungs to compare and + // the empty-ladder guard below skips it. + for _, pool := range []string{"O", "A", "R"} { for lo := 1; lo <= 3; lo++ { for hi := lo + 1; hi <= 3; hi++ { a, b := c.ladder[pool][lo], c.ladder[pool][hi] @@ -176,6 +190,29 @@ func (c *catalog) checkLadder(path string) error { return nil } +// hasOxLadder reports whether the optional OpenRouter pool is fully declared. +// The ox lanes exist exactly when this is true — catalog presence is their +// on/off switch. +func (c *catalog) hasOxLadder() bool { + for t := 1; t <= 3; t++ { + if c.ladder["R"][t] == "" { + return false + } + } + return true +} + +// hasOxLadderPart reports whether any R model is declared at all. The loader +// pairs it with hasOxLadder to reject half-declared pools. +func (c *catalog) hasOxLadderPart() bool { + for t := 0; t <= 4; t++ { + if c.ladder["R"][t] != "" { + return true + } + } + return false +} + // regression reports why rung hi is worse than the cheaper rung lo, or "" when // it isn't. A pricier model with more context and more thinking headroom is the // ladder working; a pricier model with less of either is a stale pick. @@ -244,8 +281,12 @@ func (c *catalog) clampTh(key, level string) string { return thScale[best] } +// otherPool is the crossing target for roles that must leave their lead pool: +// the reviewer's independent second eye, the advisor's minimum diversity. O +// and A cross to each other; R crosses to A — the strongest judgment pool, +// which is what every crossing on an ox lane is for. func otherPool(p string) string { - if p == "O" { + if p == "O" || p == "R" { return "A" } return "O" @@ -363,22 +404,37 @@ var ( // step more thinking than smol at the same rung. "scout": {"low": "low", "medium": "medium", "high": "medium", "xhigh": "medium"}, } - genTierMap = map[string]int{"fast": 1, "normal": 2, "smart": 3} - genBump = map[string]string{"minimal": "low", "low": "medium", "medium": "high", "high": "xhigh", "xhigh": "xhigh"} - genLanes = []string{"gpt-only", "gpt-led", "mixed", "claude-led", "claude-only"} - genMTiers = []string{"fast", "normal", "smart"} - genThinking = []string{"minimal", "low", "medium", "high", "xhigh", "max"} - genExtremes = map[string]bool{"minimal": true, "max": true} + genTierMap = map[string]int{"fast": 1, "normal": 2, "smart": 3} + genBump = map[string]string{"minimal": "low", "low": "medium", "medium": "high", "high": "xhigh", "xhigh": "xhigh"} + genBaseLanes = []string{"gpt-only", "gpt-led", "mixed", "claude-led", "claude-only"} + genMTiers = []string{"fast", "normal", "smart"} + genThinking = []string{"minimal", "low", "medium", "high", "xhigh", "max"} + 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" } +func lanePure(lane string) bool { + return lane == "gpt-only" || lane == "claude-only" || lane == "ox-only" +} + +// 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. +func (c *catalog) lanes() []string { + if !c.hasOxLadder() { + return genBaseLanes + } + return append(append([]string{}, genBaseLanes...), "ox-only", "ox-led") +} type roleRoute struct { lead string // short key; "" = role omitted (advisor off) @@ -408,6 +464,14 @@ func (c *catalog) genCombo(lane, mtier, thinking string, spark, fable, fableMain } 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) } @@ -564,6 +628,12 @@ func genValid(lane string, spark, fable, fableMain bool) bool { if lane == "claude-only" && spark { return false // no spark on pure Claude } + if lane == "ox-only" && (spark || fable) { + return false // a pure ox lane has no O drain bucket or A elite to lead with + } + if lane == "ox-led" && (spark || fableMain) { + return false // utility already lives on the free pool; fable-as-main would defeat the lane + } if fableMain && !fable { return false // fable-as-main only exists on top of fable } @@ -603,8 +673,14 @@ func (c *catalog) renderCombo(lane, mtier, thinking string, spark, fable, fableM } model := fmt.Sprintf("%s:%s", c.models[rt.lead].ID, c.clampTh(rt.lead, rt.level)) row := fmt.Sprintf(" %s %-10s %-24s", marker, r, model) + prev := model for i, m := range rt.chain { - row += fmt.Sprintf(" → %s:%s", c.models[m].ID, c.clampTh(m, rt.chLvl[i])) + tok := fmt.Sprintf("%s:%s", c.models[m].ID, c.clampTh(m, rt.chLvl[i])) + if tok == prev { + continue // sibling rungs of a one-model family can clamp to the same level + } + prev = tok + row += " → " + tok } lines = append(lines, strings.TrimRight(row, " ")) } @@ -614,9 +690,13 @@ func (c *catalog) renderCombo(lane, mtier, thinking string, spark, fable, fableM // renderModelFacts emits the per-model table the TUI's meters read. The bucket // is a trailing optional column: the consumer falls back to guessing from the -// model family when a catalog omits it, so old catalogs keep working. +// model family when a catalog omits it. The pool column after it is the +// authoritative provider prefix for launched configs, replacing that same +// name heuristic wherever present — this renderer always writes it, but both +// columns are optional on the parsing side, so catalogs and binaries of mixed +// age keep working together. func (c *catalog) renderModelFacts() string { - lines := []string{"__models__ model facts (id in out speed ttft bucket — $/1M in·out, tok/s, s)"} + lines := []string{"__models__ model facts (id in out speed ttft [bucket] [pool] — $/1M in·out, tok/s, s)"} for _, k := range c.keys { m := c.models[k] row := fmt.Sprintf(" %s %s %s %s %s", @@ -624,6 +704,7 @@ func (c *catalog) renderModelFacts() string { if m.Bucket != "" { row += " " + m.Bucket } + row += " " + m.Pool lines = append(lines, row) } lines = append(lines, "") @@ -656,7 +737,11 @@ func (c *catalog) renderAdvisors() string { {"audit", []rung{{3, "high"}, {2, "high"}, {1, "low"}}}, } lines := []string{"__advisors__ advisor dial (level context → chain)"} - for _, ctx := range []struct{ name, pool string }{{"gpt", "O"}, {"claude", "A"}} { + advisorContexts := []struct{ name, pool string }{{"gpt", "O"}, {"claude", "A"}} + if c.hasOxLadder() { + advisorContexts = append(advisorContexts, struct{ name, pool string }{"ox", "R"}) + } + for _, ctx := range advisorContexts { for _, d := range dial { var parts []string for _, rg := range d.chain { @@ -688,7 +773,7 @@ func (c *catalog) renderCatalog() string { b.WriteString(c.renderModelFacts() + "\n") hasSpark := c.ladder["O"][0] != "" hasElite := c.ladder["A"][4] != "" - for _, lane := range genLanes { + for _, lane := range c.lanes() { for _, mtier := range genMTiers { for _, thinking := range genThinking { for _, spark := range []bool{true, false} { diff --git a/generate_test.go b/generate_test.go index 331d389..995ede2 100644 --- a/generate_test.go +++ b/generate_test.go @@ -133,16 +133,17 @@ const goldenAdvisors = `__advisors__ advisor dial (level context → chain) audit claude claude-opus-5:high → claude-sonnet-5:high → claude-haiku-4-5:low ` -// goldenFacts pins the trailing bucket column the TUI's quota meter reads. -const goldenFacts = `__models__ model facts (id in out speed ttft bucket — $/1M in·out, tok/s, s) - gpt-5.6-luna 1 6 52.3 1.18 codex-main - gpt-5.6-terra 2.5 15 51.8 1.74 codex-main - gpt-5.6-sol 5 30 31.5 4.59 codex-main - gpt-5.3-codex-spark 1.75 14 286.7 5.56 codex-spark - claude-haiku-4-5 1 5 48.9 1.7 claude-main - claude-sonnet-5 2 10 35.2 3.84 claude-main - claude-opus-5 5 25 46.6 1.77 claude-main - claude-fable-5 10 50 54 6.9 claude-fable +// goldenFacts pins the trailing bucket and pool columns the TUI's quota meter +// and provider prefixing read. +const goldenFacts = `__models__ model facts (id in out speed ttft [bucket] [pool] — $/1M in·out, tok/s, s) + gpt-5.6-luna 1 6 52.3 1.18 codex-main O + gpt-5.6-terra 2.5 15 51.8 1.74 codex-main O + gpt-5.6-sol 5 30 31.5 4.59 codex-main O + gpt-5.3-codex-spark 1.75 14 286.7 5.56 codex-spark O + claude-haiku-4-5 1 5 48.9 1.7 claude-main A + claude-sonnet-5 2 10 35.2 3.84 claude-main A + claude-opus-5 5 25 46.6 1.77 claude-main A + claude-fable-5 10 50 54 6.9 claude-fable A ` const goldenMixedSmart = `mixed_smart_medium_sp_fa mixed · smart · medium · spark · fable @@ -217,15 +218,15 @@ func TestGoldenModelFacts(t *testing.T) { } } -// A catalog that declares no buckets keeps the old five-column rows, so the -// consumer's fallback path stays exercised. +// A catalog that declares no buckets keeps the old five-column rows plus the +// pool column, so the consumer's fallback paths stay exercised. func TestModelFactsWithoutBuckets(t *testing.T) { c, err := catalogFrom(t, strings.ReplaceAll(fixtureYML, " bucket: codex-main\n", "")) if err != nil { t.Fatalf("loadCatalog: %v", err) } - if !strings.Contains(c.renderModelFacts(), " gpt-5.6-luna 1 6 52.3 1.18\n") { - t.Errorf("bucketless model row should stop after ttft:\n%s", c.renderModelFacts()) + if !strings.Contains(c.renderModelFacts(), " gpt-5.6-luna 1 6 52.3 1.18 O\n") { + t.Errorf("bucketless model row should stop after ttft + pool:\n%s", c.renderModelFacts()) } } @@ -271,7 +272,16 @@ func TestRenderCatalogStructure(t *testing.T) { } } // The TUI's comboID must find a block for every dial state its facets - // allow (lane-suppressed spark/fable included). + // allow — after applyCatalog trims the lane dial to the lanes this + // catalog serves (an ox-less catalog never offers ox lanes). + servedLanes := map[string]bool{} + for _, l := range strings.Split(out, "\n") { + if l != "" && l[0] != ' ' && !strings.HasPrefix(l, "__") { + if i := strings.IndexByte(l, '_'); i >= 0 { + servedLanes[l[:i]] = true + } + } + } facets := facetDefs(defaultGlyphs()) sel := map[string]string{} var walk func(i int) @@ -288,6 +298,9 @@ func TestRenderCatalogStructure(t *testing.T) { return } for _, v := range facets[i].values { + if facets[i].key == "lane" && !servedLanes[v] { + continue + } sel[facets[i].key] = v walk(i + 1) } @@ -1109,3 +1122,142 @@ func TestGenerateInitRefusesUnresolvedProbe(t *testing.T) { t.Errorf("no models file may be written from an unresolved probe (stat err: %v)", err) } } + +// ── pool R (OpenRouter) ─────────────────────────────────────────────────────── + +// oxEntries declares a one-model family the only way the loader accepts: once +// per tier, with ascending thinking ceilings. Same id everywhere — the tiers +// ARE the thinking variations. +const oxEntries = ` + oxfast: + id: stealth/ox-alpha + pool: R + tier: 1 + bucket: openrouter-free + cost_in: 0 + cost_out: 0 + speed: 27.4 + ttft: 2.1 + context: 1048576 + thinking: low→low + ox: + id: stealth/ox-alpha + pool: R + tier: 2 + bucket: openrouter-free + cost_in: 0 + cost_out: 0 + speed: 27.4 + ttft: 2.1 + context: 1048576 + thinking: low→high + oxmax: + id: stealth/ox-alpha + pool: R + tier: 3 + bucket: openrouter-free + cost_in: 0 + cost_out: 0 + speed: 27.4 + ttft: 2.1 + context: 1048576 + thinking: low→max +` + +func catalogWithOx(t *testing.T) *catalog { + t.Helper() + c, err := catalogFrom(t, fixtureYML+oxEntries) + if err != nil { + t.Fatalf("loadCatalog with ox ladder: %v", err) + } + return c +} + +func TestOxLadderGatesLanes(t *testing.T) { + base := fixtureCatalog(t) + if got := base.lanes(); len(got) != len(genBaseLanes) { + 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") + if got := withOx.lanes(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("ox catalog serves %v, want %v", got, want) + } +} + +func TestOxLadderAllOrNothing(t *testing.T) { + // Drop the tier-3 entry: a two-of-three ladder must be refused outright. + partial := strings.Split(oxEntries, " oxmax:")[0] + if _, err := catalogFrom(t, fixtureYML+partial); err == nil || !strings.Contains(err.Error(), "pool R must fill tiers 1..3") { + t.Errorf("partial ox ladder accepted (err: %v)", err) + } +} + +func TestGenValidOxLanes(t *testing.T) { + for _, tc := range []struct { + lane string + spark, fable, main_ bool + want bool + }{ + {"ox-only", false, false, false, true}, + {"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 + } { + 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", + tc.lane, tc.spark, tc.fable, tc.main_, got, tc.want) + } + } +} + +// The ox lanes route by policy, not price: everything high-volume stays on the +// free pool; deliberative work crosses to Anthropic; the reviewer never shares +// its lead's provider. +func TestOxLaneRoutingPolicy(t *testing.T) { + c := catalogWithOx(t) + combo := c.genCombo("ox-led", "smart", "high", false, true, false) + for _, r := range []string{"default", "task", "scout", "sonic", "smol", "tiny", "commit", "vision"} { + if id := c.models[combo[r].lead].ID; id != "stealth/ox-alpha" { + t.Errorf("ox-led %s lead = %s, want stealth/ox-alpha", r, id) + } + } + for _, r := range []string{"plan", "slow", "designer", "reviewer"} { + pool := c.models[combo[r].lead].Pool + if pool != "A" { + t.Errorf("ox-led deliberative role %s routes to pool %s, want A", r, pool) + } + } + // Fable is on: it leads plan/slow/designer/reviewer outright. + for _, r := range []string{"plan", "slow", "designer", "reviewer"} { + if id := c.models[combo[r].lead].ID; id != "claude-fable-5" { + t.Errorf("ox-led smart + fable: %s lead = %s, want claude-fable-5", r, id) + } + } + // Pure ox: every role including advisor and reviewer stays on R. + pure := c.genCombo("ox-only", "normal", "medium", false, false, false) + for _, r := range genRoleOrder { + rt := pure[r] + if rt.lead == "" { + continue + } + if id := c.models[rt.lead].ID; id != "stealth/ox-alpha" { + t.Errorf("ox-only %s lead = %s, want stealth/ox-alpha", r, id) + } + } +} + +func TestAdvisorsIncludeOxContext(t *testing.T) { + withOx := catalogWithOx(t) + got := withOx.renderAdvisors() + if !strings.Contains(got, "glance ox stealth/ox-alpha:low") { + t.Errorf("advisor table missing ox context:\n%s", got) + } + base := fixtureCatalog(t) + if strings.Contains(base.renderAdvisors(), " ox ") { + t.Error("base catalog must not advertise an ox advisor context") + } +} diff --git a/main.go b/main.go index 72ad4b4..d3d4c32 100644 --- a/main.go +++ b/main.go @@ -123,7 +123,11 @@ var ( pad = clikit.Pad windowList = clikit.WindowList - modelRe = regexp.MustCompile(`(gpt|claude)[A-Za-z0-9._-]*:(minimal|low|medium|high|xhigh|max)`) + // Provider-qualified ids: bare catalog ids today (gpt-…, claude-…) plus + // slash-scoped ones (stealth/ox-alpha, local-qwen/qwen3.8-27b). The level + // suffix with its colon is what keeps prose out; the word boundary keeps + // "maxed" from reading as a model. + modelRe = regexp.MustCompile(`([a-z][a-z0-9._/-]*):(minimal|low|medium|high|xhigh|max)\b`) ) // ── colourisers ────────────────────────────────────────────────────────────── @@ -147,6 +151,14 @@ func shortModel(name string) string { if name == "gpt-5.4" { return name } + // Slash-scoped ids display without their provider path, and keep their + // full model part — the vendor's own naming is the recognizable bit. + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + if !strings.HasPrefix(name, "claude") { + return name + } + } p := strings.Split(name, "-") if strings.HasPrefix(name, "claude") && len(p) > 1 { return p[1] @@ -169,9 +181,13 @@ func paintModel(tok string) string { i := strings.LastIndex(tok, ":") name, level := tok[:i], tok[i+1:] var br, bg, bb float64 - if strings.HasPrefix(tok, "gpt") { + switch { + case strings.HasPrefix(tok, "gpt"): br, bg, bb = 110, 170, 240 - } else { + case strings.Contains(tok, "ox-alpha"), strings.Contains(tok, "local-qwen"): + // Free/local pools read green — the same family as their lane accents. + br, bg, bb = 96, 211, 150 + default: br, bg, bb = 240, 160, 105 } f := 0.60 + float64(lvl(level))*0.088 @@ -190,6 +206,12 @@ func bucketOf(model string) string { if i := strings.IndexByte(m, ':'); i >= 0 { m = m[:i] } + // Provider-scoped ids outside the two subscription pools (OpenRouter, + // local runtimes) have no quota window code knows about. An empty bucket + // never reads as down, which is exactly right for a free or local model. + if strings.Contains(m, "/") { + return "" + } switch { case strings.Contains(m, "fable"): return "claude-fable" @@ -965,7 +987,9 @@ type facet struct { func facetDefs(glyphs map[string]string) []facet { return []facet{ - {"lane", []string{"gpt-only", "gpt-led", "mixed", "claude-led", "claude-only"}, glyphs["lane"]}, + // 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"]}, {"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 @@ -1005,11 +1029,14 @@ func parseAdvisors(rows []string) map[string][]string { } // modelFact is a model's measured facts from omp (via the catalog): pricing -// ($/1M tokens), output throughput (tok/s), time-to-first-token (seconds), and -// the quota bucket it draws from ("" when the catalog declares none). +// ($/1M tokens), output throughput (tok/s), time-to-first-token (seconds), the +// quota bucket it draws from ("" when the catalog declares none), and the pool +// it belongs to ("" in catalogs that predate the column — the provider-prefix +// heuristic covers those). type modelFact struct { in, out, speed, ttft float64 bucket string + pool string } // effTPS folds ttft into throughput — the effective tok/s for a representative @@ -1025,9 +1052,9 @@ func (f modelFact) effTPS() float64 { } // parseFacts reads the __models__ block (rows: " -// []") into a per-model table, sourced from the catalog so meters and -// routing agree. The bucket column is optional — a catalog that declares none -// for any model omits it entirely, and the name guess covers those rows. +// [] []") into a per-model table, sourced from the catalog so +// meters and routing agree. Bucket and pool are trailing optional columns — +// older catalogs carry neither, and the name heuristics cover those rows. func parseFacts(rows []string) map[string]modelFact { out := map[string]modelFact{} for _, r := range rows { @@ -1039,12 +1066,15 @@ func parseFacts(rows []string) map[string]modelFact { outc, e2 := strconv.ParseFloat(f[2], 64) sp, e3 := strconv.ParseFloat(f[3], 64) tt, e4 := strconv.ParseFloat(f[4], 64) - bucket := "" + bucket, pool := "", "" if len(f) >= 6 { bucket = f[5] } + if len(f) >= 7 { + pool = f[6] + } if e1 == nil && e2 == nil && e3 == nil && e4 == nil { - out[f[0]] = modelFact{in, outc, sp, tt, bucket} + out[f[0]] = modelFact{in, outc, sp, tt, bucket, pool} } } return out @@ -1254,9 +1284,11 @@ func (m model) applyAdvisor(rows []string, level string) []string { // visibleFacets drops facets that don't apply to the current lane, so the // generator only ever shows actionable options: no spark/fast on a Claude-only -// pool, no fable on a GPT-only pool. main is fable's sub-setting, so it only -// shows while fable is on (and the lane can host it at all). A dial this catalog -// generated no combo for is dropped the same way — it is not a choice. +// pool, no fable on a GPT-only pool, and on the ox lanes only what an ox-led +// session can actually use (fable stays: it leads the deliberative roles). +// main is fable's sub-setting, so it only shows while fable is on (and the +// lane can host it at all). A dial this catalog generated no combo for is +// dropped the same way — it is not a choice. func (m model) visibleFacets() []facet { if _, local := m.selectedRuntime(); local { var out []facet @@ -1276,6 +1308,12 @@ func (m model) visibleFacets() []facet { if lane == "gpt-only" && (f.key == "fable" || f.key == "main") { continue } + if lane == "ox-only" && (f.key == "spark" || f.key == "fable" || f.key == "main" || f.key == "fast") { + continue + } + if lane == "ox-led" && (f.key == "spark" || f.key == "main" || f.key == "fast") { + continue + } if f.key == "spark" && m.noSpark { continue } @@ -1293,10 +1331,10 @@ func (m model) visibleFacets() []facet { func comboID(sel map[string]string) string { lane := sel["lane"] sp, fb := sel["spark"], sel["fable"] - if lane == "gpt-only" { + if lane == "gpt-only" || lane == "ox-only" { fb = "off" } - if lane == "claude-only" { + if lane == "claude-only" || lane == "ox-only" || lane == "ox-led" { sp = "off" } spid, faid := "nosp", "nofa" @@ -1305,7 +1343,10 @@ func comboID(sel map[string]string) string { } if fb == "on" { faid = "fa" - if sel["main"] == "on" { + // ox-led hosts the elite on deliberative roles only; promoting it to + // the default role would defeat the lane, and genValid refuses that + // combo outright. + if sel["main"] == "on" && lane != "ox-led" { faid = "famain" } } @@ -1323,7 +1364,13 @@ func (m *model) applyCatalog() { return // no catalog read yet: onboarding, or a broken CODE_GENERATED } spark, fable := false, false + served := map[string]bool{} for id := range m.generated { + lane := id + if i := strings.IndexByte(id, '_'); i >= 0 { + lane = id[:i] + } + served[lane] = true for _, seg := range strings.Split(id, "_") { switch seg { case "sp": @@ -1334,9 +1381,44 @@ func (m *model) applyCatalog() { } } m.noSpark, m.noFable = !spark, !fable + m.trimLanes(served) m.clampSel() } +// trimLanes narrows the lane dial to the lanes this catalog actually serves, +// and lands the selection on a served lane when a persisted or default choice +// points at one that vanished (an older catalog without ox, say). This is the +// consumer side of the ox on/off switch: no ox entries in models.yml means no +// ox values on the dial at all. +func (m *model) trimLanes(served map[string]bool) { + for i, f := range m.facets { + if f.key != "lane" { + continue + } + var values []string + for _, v := range f.values { + if served[v] { + values = append(values, v) + } + } + if len(values) == len(f.values) { + continue // nothing to trim + } + if len(values) > 0 { + m.facets[i].values = values + } + break + } + if !served[m.sel["lane"]] { + for _, fallback := range []string{"mixed", "gpt-only", "claude-only"} { + if served[fallback] { + m.sel["lane"] = fallback + break + } + } + } +} + // clampSel turns off every dial the catalog cannot serve. main is fable's // sub-setting and never outlives it. func (m *model) clampSel() { @@ -1351,6 +1433,10 @@ func (m *model) clampSel() { func laneColor(lane string) string { switch lane { + case "ox-only": + return "#1f9d5b" // deeper green — pure free pool + case "ox-led": + return "#5fce96" // lighter green — leans Ox Alpha case "gpt-only": return "#3f8ef0" // deeper blue — pure pool case "gpt-led": @@ -1365,7 +1451,20 @@ func laneColor(lane string) string { return "#ff9f52" } -func prefixed(model string) string { +// prefixed qualifies a bare catalog id with the omp provider omp routes +// 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 != "" { + switch f.pool { + case "O": + return "openai-codex/" + model + case "A": + return "anthropic/" + model + case "R": + return "openrouter/" + model + } + } if strings.HasPrefix(model, "claude") { return "anthropic/" + model } @@ -1408,13 +1507,13 @@ func (m model) genConfigYAML() string { if i == 1 && role != "advisor" { // ●-marked agent-backed role: mirror its lead route as the task-agent // model override so spawned agents follow the generated profile. - ao.WriteString(" " + role + ": " + prefixed(models[0]) + "\n") + ao.WriteString(" " + role + ": " + m.prefixed(models[0]) + "\n") } - mr.WriteString(" " + role + ": " + prefixed(models[0]) + "\n") + mr.WriteString(" " + role + ": " + m.prefixed(models[0]) + "\n") if len(models) > 1 { var fbs []string for _, x := range models[1:] { - fbs = append(fbs, prefixed(x)) + fbs = append(fbs, m.prefixed(x)) } fc.WriteString(" " + role + ": [" + strings.Join(fbs, ", ") + "]\n") } @@ -1603,10 +1702,21 @@ func (m model) bodyLines() ([]string, int) { // costs, how fast it is, and how Enter will launch it. Enter always launches // the generated profile for the current facets (the untouched default combo is // a profile like any other); m runs omp-managed on the managed defaults with -// no overlay, and the sandbox (u) key is always offered. +// no overlay, and the sandbox (u) key is always offered. A selected runtime +// target gets the same footer shape with an honest summary instead of meters: +// its tokens are free and code has no measurement to quote. func (m model) launchFooter() []string { - cs, ss := m.costScore(), m.speedScore() acc := lipgloss.NewStyle().Foreground(lipgloss.Color(m.accent())).Bold(true).Render(" ⏎ launch") + if _, local := m.selectedRuntime(); local { + return []string{ + "", + stDim.Render(" cost free · local inference"), + "", + "", + acc, + } + } + cs, ss := m.costScore(), m.speedScore() return []string{ "", m.meter("cost", "$", meterRamp[cs], cs), // dear → red, cheap → green @@ -1888,8 +1998,10 @@ func (m model) listW() int { if w > m.w-33 { w = m.w - 33 } - if w > 80 { - w = 80 + // 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 } return w } @@ -2716,12 +2828,26 @@ func (m *model) syncPreviewAt(yoff int) { if target, local := m.selectedRuntime(); local { b.WriteString(lipgloss.NewStyle().Bold(true).Render(target.Label) + "\n") b.WriteString(stDim.Render(target.statusLine()) + "\n\n") - b.WriteString("model " + target.Model + "\n") if target.ContextWindow > 0 { - b.WriteString(fmt.Sprintf("context %dk tokens\n", target.ContextWindow/1000)) + b.WriteString(fmt.Sprintf("context %dk tokens\n\n", target.ContextWindow/1000)) + } + // Same grammar as a hosted profile: every role the broker's generated + // profile routes (all of them but the advisor, which stays off), led by + // the one local model at the dialed thinking — the flag is forwarded + // verbatim and omp clamps it to what the model offers. + rows := []string{fmt.Sprintf(" thinking %s · fallback off · advisor off", m.sel["thinking"])} + for _, r := range genRoleOrder { + if r == "advisor" { + continue + } + marker := " " + if genAgentRoles[r] { + marker = "●" + } + rows = append(rows, fmt.Sprintf(" %s %-10s %s:%s", marker, r, target.Model, m.sel["thinking"])) } - b.WriteString("routing every role stays local\n") - b.WriteString("fallbacks disabled\n") + b.WriteString(m.renderRoute(rows, m.depth, m.selectedLaunchAvailability(), rw)) + b.WriteString("\n" + stDim.Render("broker-owned profile · cloud auth excluded · weights provisioned by the runtime") + "\n") content := lipgloss.NewStyle().MaxWidth(m.vp.Width).Render(b.String()) m.vp.SetContent(content) m.vp.SetYOffset(yoff) diff --git a/main_test.go b/main_test.go index f50c0e6..d8fd1b4 100644 --- a/main_test.go +++ b/main_test.go @@ -124,9 +124,9 @@ func TestParseFactsBucketColumn(t *testing.T) { t.Fatalf("parsed %d rows, want 3: %v", len(facts), facts) } want := map[string]modelFact{ - "gpt-5.6-luna": {1, 6, 52.3, 1.18, "codex-main"}, - "gpt-5.6-terra": {2.5, 15, 41, 1.4, ""}, - "claude-mythos-5": {5, 25, 30, 2, "claude-fable"}, + "gpt-5.6-luna": {1, 6, 52.3, 1.18, "codex-main", ""}, + "gpt-5.6-terra": {2.5, 15, 41, 1.4, "", ""}, + "claude-mythos-5": {5, 25, 30, 2, "claude-fable", ""}, } for id, w := range want { if got := facts[id]; got != w { @@ -141,8 +141,8 @@ func TestParseFactsBucketColumn(t *testing.T) { // would then strike models through against the wrong window. func TestBucketForPrefersCatalog(t *testing.T) { m := model{facts: map[string]modelFact{ - "claude-mythos-5": {5, 25, 30, 2, "claude-fable"}, - "gpt-5.6-luna": {1, 6, 52.3, 1.18, ""}, + "claude-mythos-5": {5, 25, 30, 2, "claude-fable", ""}, + "gpt-5.6-luna": {1, 6, 52.3, 1.18, "", ""}, }} if got := bucketOf("claude-mythos-5"); got != "claude-main" { t.Fatalf("guess baseline moved: bucketOf(claude-mythos-5) = %q", got) @@ -3636,3 +3636,72 @@ func TestSandboxLaunchStripsInheritedBrokerEnvironment(t *testing.T) { t.Errorf("sandbox inherited auth routing: %q", got) } } + +// ── pool R surface ──────────────────────────────────────────────────────────── + +func TestModelReMatchesProviderScopedIds(t *testing.T) { + line := " ● task stealth/ox-alpha:high → claude-opus-5:high" + got := modelRe.FindAllString(line, -1) + want := []string{"stealth/ox-alpha:high", "claude-opus-5:high"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("modelRe matched %v, want %v", got, want) + } + // Prose and bucket states must never read as models. + for _, s := range []string{"thinking high · fallback on · advisor on", "codex-spark maxed"} { + if got := modelRe.FindAllString(s, -1); len(got) != 0 { + t.Errorf("modelRe matched prose %q → %v", s, got) + } + } +} + +func TestShortModelStripsProviderPath(t *testing.T) { + if got := shortModel("stealth/ox-alpha"); got != "ox-alpha" { + t.Errorf("shortModel(stealth/ox-alpha) = %q, want ox-alpha", got) + } + if got := shortModel("claude-opus-5"); got != "opus" { + t.Errorf("shortModel baseline moved: %q", got) + } +} + +func TestPrefixedUsesCatalogPool(t *testing.T) { + m := model{facts: map[string]modelFact{ + "stealth/ox-alpha": {pool: "R"}, + "gpt-5.6-luna": {pool: "O"}, + "claude-opus-5": {pool: "A"}, + }} + for id, want := range map[string]string{ + "stealth/ox-alpha": "openrouter/stealth/ox-alpha", + "gpt-5.6-luna": "openai-codex/gpt-5.6-luna", + "claude-opus-5": "anthropic/claude-opus-5", + } { + if got := m.prefixed(id); got != want { + t.Errorf("prefixed(%q) = %q, want %q", id, got, want) + } + } + // A catalog without the pool column falls back to the name heuristic. + var legacy model + if got := legacy.prefixed("claude-opus-5"); got != "anthropic/claude-opus-5" { + t.Errorf("legacy heuristic broken: prefixed = %q", got) + } +} + +func TestTrimLanesResetsVanishedLane(t *testing.T) { + m := &model{ + facets: []facet{ + {key: "lane", values: []string{"gpt-only", "mixed", "ox-only", "ox-led"}}, + {key: "thinking", values: []string{"medium"}}, + }, + sel: map[string]string{"lane": "ox-only", "thinking": "medium"}, + generated: map[string][]string{ + "gpt-only_smart_medium_nosp_nofa": nil, + "mixed_smart_medium_nosp_nofa": nil, + }, + } + m.applyCatalog() + if got := m.facets[0].values; strings.Join(got, ",") != "gpt-only,mixed" { + t.Errorf("lane dial not trimmed to served lanes: %v", got) + } + if m.sel["lane"] != "gpt-only" && m.sel["lane"] != "mixed" { + t.Errorf("selection left on vanished lane: %q", m.sel["lane"]) + } +} diff --git a/suggest.go b/suggest.go index f70f049..00e826e 100644 --- a/suggest.go +++ b/suggest.go @@ -96,18 +96,22 @@ func (m model) Commander() clikit.Commander { } // repairConstraints enforces the deterministic rules a suggestion (or selection) -// must never violate — mirroring generate-profiles.py's `valid` plus live quota: -// spark is an OpenAI model, so it can't run on a pure-Claude lane; fable is an -// Anthropic elite, so it can't run on a pure-GPT lane; and neither may be left on -// when its quota bucket is maxed or unauthed. Runs after an applied proposal, so -// the generator can't land on an impossible or unavailable combo. +// must never violate — mirroring genValid plus live quota: spark is an OpenAI +// model, so it can't run on a pure-Claude or an ox lane; fable is an Anthropic +// elite, so it can't run on a pure-GPT or a pure-ox lane; fable-as-main would +// defeat ox-led's free worker; and neither lead may be left on when its quota +// 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() { - switch m.sel["lane"] { - case "claude-only": + if lane := m.sel["lane"]; lane == "claude-only" || lane == "ox-only" || lane == "ox-led" { m.sel["spark"] = "off" - case "gpt-only": + } + if lane := m.sel["lane"]; lane == "gpt-only" || lane == "ox-only" { m.sel["fable"] = "off" } + if m.sel["lane"] == "ox-led" { + m.sel["main"] = "off" + } if m.avail.down(bucketOf("fable")) { m.sel["fable"] = "off" }