Skip to content
Merged
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ Press `enter` and `code` launches oh-my-pi with that setup, as a one-shot
overlay — your omp config is never modified.

It's made for people who run oh-my-pi with **both Anthropic and OpenAI**:
the whole point is deciding, per task, how to blend the two pools and which
quota to spend. With a single provider you can still launch, but the dials
lose most of their meaning.
the whole point is deciding, per task, how to blend the pools and which
quota to spend. A DeepSeek API key adds a third, pay-as-you-go pool — its
own `ds` lanes, a live balance readout in Usage, and a relief tail at the
end of the heavyweight fallback chains for when the metered windows are
drained. With a single provider you can still launch, but the dials lose
most of their meaning.

## Usage

Expand Down Expand Up @@ -62,9 +65,12 @@ are not orphaned onto init while still holding their memory.

## Features

- **Dials, not config files** — provider lane, model tier, thinking depth,
advisor level, plus the spark/fable toggles; every combination maps to a
pre-computed routing.
- **Dials, not config files** — a provider **lead** dial with a led/only
blend child (scales past two pools without overflowing), notched sliders
for model tier and thinking depth, advisor level, plus the spark/fable
toggles; every combination maps to a pre-computed routing. Optional pools
plug in as their own lanes, and a **relief** dial decides whether drained
metered chains may spill into the pay-as-you-go pool.
- **Hosted or local** — an optional runtime broker can advertise only the local
targets this machine supports; selecting one delegates first-use setup and
launch without mixing cloud credentials into the session.
Expand All @@ -75,10 +81,14 @@ are not orphaned onto init while still holding their memory.
- **Prompt → profile** — `ctrl+o`, describe the task, a small local model
rates its difficulty and sets the dials (optional, needs
[ollama](https://ollama.com); the prompt is forwarded into the session).
Suggestions are quota-aware: a lane whose lead pool is maxed falls to a
sibling with headroom, and a low DeepSeek balance stops proposals from
spending it.
- **Usage at a glance** — quota bars and reset countdowns per provider,
before you spend the scarce bucket.
- **Account presets** — choose broker accounts and save reusable selections (`v`).
- **Cost & speed meters** — every dial change reprices the session.
- **Cost & speed meters** — every dial change reprices the session; DeepSeek
rungs are priced by the clock during its off-peak discount window.
- **Guided first run** — no catalog? `code` builds one from your omp,
interactively; `code generate` scripts the same thing.
- **Argument passthrough** — `code <anything omp understands>` just works.
Expand Down
134 changes: 134 additions & 0 deletions colorize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
)

// ── colourisers ──────────────────────────────────────────────────────────────
func lvl(s string) int {
switch s {
case "minimal":
return 0
case "low":
return 1
case "medium":
return 2
case "high":
return 3
case "xhigh":
return 4
}
return 5
}

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]
}
return p[len(p)-1]
}

func clampByte(x float64) int {
v := int(x)
if v > 255 {
return 255
}
if v < 0 {
return 0
}
return v
}

func paintModel(tok string) string {
i := strings.LastIndex(tok, ":")
name, level := tok[:i], tok[i+1:]
p := providerByModel(name)
var br, bg, bb float64
switch {
case p != nil:
br, bg, bb = p.PaintRGB[0], p.PaintRGB[1], p.PaintRGB[2]
case strings.Contains(name, "local-"):
// Free/local runtimes read green — the same family as the ox accents.
br, bg, bb = 96, 211, 150
default:
return shortModel(name) + ":" + level // unknown provider: uncoloured
}
f := 0.60 + float64(lvl(level))*0.088
col := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", clampByte(br*f), clampByte(bg*f), clampByte(bb*f)))
return lipgloss.NewStyle().Foreground(col).Render(shortModel(name) + ":" + level)
}

func colorizeRoute(line string) string { return modelRe.ReplaceAllStringFunc(line, paintModel) }

// bucketOf guesses a quota bucket from a model name. It is the fallback for
// catalogs that declare no bucket column, and the only resolver for the bare
// facet names ("fable", "spark") the suggest box asks about — prefer
// model.bucketFor wherever a receiver is in reach. An unknown name maps to no
// bucket at all rather than someone else's quota window.
func bucketOf(model string) string {
m := model
if i := strings.IndexByte(m, ':'); i >= 0 {
m = m[:i]
}
// Provider-scoped ids outside the 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 ""
}
for _, p := range providerRegistry {
for _, s := range p.Special {
if strings.Contains(m, s.Bucket) {
return p.BucketBase + "-" + s.Bucket
}
}
}
if p := providerByModel(m); p != nil {
return p.mainBucket()
}
for _, p := range providerRegistry {
for _, pre := range p.ModelPrefixes {
if strings.Contains(m, pre) {
return p.mainBucket()
}
}
}
return ""
}

// bucketFor resolves a routing token's quota bucket from the catalog, falling
// back to the name guess only when the catalog declares none. The catalog wins
// because names are not a taxonomy: claude-mythos-5 sits in omp's catalog at
// claude-fable-5's price yet 404s on this account, and every model omp adds
// would otherwise need one more substring arm here before it could be struck
// through correctly.
func (m model) bucketFor(name string) string {
id := name
if i := strings.IndexByte(id, ':'); i >= 0 {
id = id[:i]
}
if f, ok := m.facts[id]; ok {
if f.bucket != "" {
return f.bucket
}
if p := providerByPool(f.pool); p != nil {
return p.mainBucket()
}
}
return bucketOf(id)
}
164 changes: 1 addition & 163 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,163 +1 @@
# Configuration

`code` needs no config file. Everything is a key inside the TUI or an
environment variable with a sane fallback.

## Keys

| Key | Action |
|---|---|
| `↑` `↓` | move between dials |
| `←` `→` | change the selected dial |
| `d` | reset all dials to defaults |
| `ctrl+o` | describe the task, let a local model set the dials |
| `enter` | launch oh-my-pi with the selected hosted profile or local runtime |
| `m` | launch plain managed omp (no overlay) |
| `u` | launch through a sandboxed omp, if you have one |
| `v` | manage broker account selections and presets |
| `p` / `f` / `s` | toggle routing panel / fallback chains / usage panel |
| `r` | refresh the usage panel now |
| `?` | expanded help |
| `pgup` / `pgdn` | scroll the routing preview |
| `q` | quit |

`↑↓←→` also answer to their vim aliases (`j`/`k`/`h`/`l`).

## Environment variables

| Variable | Purpose | Without it |
|---|---|---|
| `CODE_GENERATED` | path to the generated facet catalog (the routing blocks behind the dials) | `$XDG_DATA_HOME/code/generated.plain`, where `code generate` writes; if that's missing too, the TUI opens the guided first-run that builds it |
| `CODE_SELECTION_STATE` | file persisting your dial choices | choices reset each run |
| `CODE_SESSION_STATE` | directory recording live sessions for `code ls` / `code session reap`; `off` disables recording | `$XDG_STATE_HOME/code/sessions` — note this one defaults to a path rather than to disabled, so the registry works without wrapper changes |
| `CODE_OMP` | omp binary for trusted launches (`m` and `enter`) | `omp-managed`, then `omp` on PATH |
| `CODE_OMP_UNTRUSTED` | sandboxed omp for the `u` key | `ompu` on PATH, else the key is hidden and inert |
| `CODE_RUNTIME_BROKER` | executable implementing `runtime list --json` and `runtime run TARGET -- ...`; applicable targets become a runtime dial | no runtime dial; hosted behavior is unchanged |
| `OMP_AUTH_BROKER_URL` | central auth broker behind the usage panel and the account picker (`v`); inherited from your omp environment | no fetch — the usage panel has nothing to show |
| `OMP_AUTH_BROKER_TOKEN` | bearer token for that broker | same: `code` only fetches when both the URL and the token are set |
| `OMP_AUTH_BROKER_SNAPSHOT_CACHE` | broker snapshot cache path; `code` never reads it, it only forwards it to the omp it launches | forwarded empty |
| `CODE_AUTH_VAULTS` | legacy vault manifest (inline JSON), consulted only when no `OMP_AUTH_BROKER_*` variable is set | the broker variables are the only source |
| `CODE_AUTH_VAULTS_FILE` | the same legacy manifest read from a file, when `CODE_AUTH_VAULTS` is empty | ditto |
| `CODE_AUTH_ACCOUNT_STATE` | file persisting your broker account selections and presets (`v`) | selections reset each run |
| `CODE_USAGE_CACHE` | file caching the last usage snapshot, so the panel opens on last-known numbers (marked stale) instead of blank | the panel starts empty and fills on the first fetch |
| `CODE_EVAL_MODEL` | ollama model tag for `ctrl+o` | `qwen2.5:3b` |
| `CODE_OLLAMA_ENDPOINT` | non-default ollama endpoint | `http://127.0.0.1:11434` |
| `CODE_FACET_GLYPHS` | override the Nerd Font dial glyphs | built-in glyphs |

`CODE_USAGE` and `CODE_OMP_RAW` are no longer read; the dotfiles wrapper still
exports them for older pinned builds. The usage panel now comes from the auth
broker (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`).

Provider authentication is owned by OMP, not `code`. Authenticate with
`omp auth-broker login` before launching `code`.

The runtime-broker boundary is deliberately narrow. `code` reads only
schema-version-1 targets marked `applicable`, then delegates the selected
target's complete lifecycle to the broker. It does not download weights,
create credentials, or assume a container engine. Local launches receive the
thinking dial and forwarded OMP arguments, but not cloud auth-broker variables;
the runtime broker owns its OMP profile, routing config, and fallback policy.

## The `code generate` subcommand

The dials are backed by a pre-rendered catalog. Building it is two steps, both
scriptable:

```
code generate init [--models-file OUT] [--refresh] [--from-json FILE]
code generate [--models-file FILE] [--out FILE|-]
```

`init` scaffolds a models file from your own omp (`omp models --json`), keeping
the newest model per family and ranking it by thinking ceiling, context and
price — review what it derived. It also reads `omp usage --json`: a quota bucket
scoped to a model tier is how the spark and elite rungs are identified, and
without that report they are simply left empty. `generate` renders that file
into the catalog the TUI reads. Paths default to
`$XDG_CONFIG_HOME/code/models.yml` and `$XDG_DATA_HOME/code/generated.plain`
(`~/.config` and `~/.local/share` when those are unset); `--out -` prints the
catalog to stdout.

Every candidate is probed with `omp bench` before it can become a rung. This is
not optional and not a benchmark: omp lists models your account cannot actually
call, and no field distinguishes them — `claude-mythos-5` reports
`claude-fable-5`'s exact price, context window and thinking range, and 404s.
A model that does not return a passing probe is dropped, and a model missing
from the probe report entirely is dropped too, because unverified is not the
same as fine. One request per model, so expect `init` to take a minute. The
probe also supplies the real `speed`/`ttft` the meter reads.

A file whose models were all verified is marked `probed: true`, and `generate`
refuses to render one that is not — that marker is the only thing standing
between an unverified scaffold and live routing. Treat it as your attestation
rather than a permanent certificate: it describes the ids as they were written,
so if you edit an `id` by hand, re-run `init --refresh` (or satisfy yourself the
new one is callable) instead of leaving the old `true` in place.

| Flag | Effect |
|---|---|
| `--refresh` | re-derive the tiers over an existing models file instead of refusing to touch it. Without it `init` stops when the file already exists, so a scaffold from months ago keeps naming retired models. This is the line to run when a provider ships new models |
| `--from-json` | read the model list from a file instead of omp, and skip the probe. Offline inspection only: the output is marked `probed: false`, which `generate` rejects |

### The models file

Two top-level keys: `probed`, and `models:` mapping a short key to one model.

| Top-level field | Meaning |
|---|---|
| `probed` | must be `true` or `generate` refuses the file. `init` sets it after every model passed a live probe; an offline `--from-json` scaffold writes `false` |

Each entry under `models:`:

| Field | Meaning |
|---|---|
| `id` | the model id omp routes to |
| `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 |
| `speed` / `ttft` | output tok/s and seconds to first token; drives the speed meter. Measured by `init`'s probe — a single timed request each, so treat them as one sample rather than a stable benchmark |
| `context` | context window, in tokens |
| `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.

### 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 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` 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

Any ollama daemon on loopback works:

```
ollama pull qwen2.5:3b
```

The model is loaded into memory only when you choose (`ctrl+l` inside the
box toggles residency); a one-off suggestion never leaves weights resident.
Small instruct models around 3B parameters work best — smaller ones rate
every task the same.
| `pool` | `O` (OpenAI/Codex), `A` (Anthropic), `D` (DeepSeek), or `R` (OpenRouter — optional, see below). `O` and `A` must fill tiers 1..3; `D` is optional — one verified model is enough, missing tiers borrow the nearest rung; `R` is optional but all-or-nothing — when present it must fill tiers 1..3 |
Loading
Loading