Skip to content

perf(models): open the launcher's model pickers from the disk cache - #249

Merged
duyet merged 2 commits into
mainfrom
perf/launcher-models-instant
Oct 29, 2026
Merged

duyet merged 2 commits into
mainfrom
perf/launcher-models-instant

Conversation

@duyet

@duyet duyet commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Picking model… in the launcher used to tear down the palette and leave a blank screen while two uncached requests ran (GET /v1/models, 235 KB, then the benchmarks feed). The picker then drew roughly half a second later, and over a second on a slow network.

With this change, the picker opens in 32 ms instead of 576 ms (median of 7 interleaved runs per side, warm cache).

  • When the launcher opens, it loads the catalog and the benchmark scores on a background thread, beside the credits prefetch. A picker opened while that load still runs waits for it, under a Loading models… line, so it never shows an empty screen.
  • Every interactive picker reads the disk cache that launches already use (launch_cache): models --pick, models use --agent, Switch model, bind agent model, the config TUI and status. A fresh copy is used as is. A stale copy (under a day old) is shown while it refreshes in the background. With no copy, the full catalog is fetched without the launch path's 1.5 s cap.
  • The cache now stores pricing and created, so the price, free and new columns survive it. Rows from older cache versions are ignored. Benchmark scores get their own cached row (model-scores.json) with the same freshness rules.
  • HUD Models (ANYR_TUI=0) now opens the picker. It used to print a list into scrollback, and the launcher then drew over it.
  • anyr models from a shell is unchanged. The list, TSV, --json and use <id> validation still fetch live.

Measurement

The time is from pressing Enter on model… until the picker table is on screen. A Python pty harness drove release builds of origin/main (adaae15) and this branch, alternating A, B, A, B. Each run was checked for at least 10 model ids and 10 prices on screen.

case before after
warm cache 576 ms (523 to 2421) 32 ms (31 to 32)
cold, typed at human speed (~0.7 s) 504 ms (441 to 911) 39 ms (31 to 3178)
cold, Enter right after the launcher draws 638 ms (519 to 699) 634 ms (554 to 951)

The before number is network bound. curl puts the catalog at ~0.37 s and the benchmarks feed at ~0.22 s, run one after the other. In the cold Enter-at-once case, the picker waits on the in-flight warm instead of starting a second fetch, so it still matches the network time. It shows Loading models… instead of a blank screen. One baseline cold run never drew the picker within 15 s, because the uncached fetch stalled behind a blank screen.

Tests

  • a_warm_picker_reads_catalog_and_scores_from_disk_only uses injected fetchers that panic if they are called.
  • the_launcher_pickers_read_the_disk_cache tests models_for_picker against an unroutable base. It fails if the picker bypasses the cache (mutation checked).
  • a_saved_catalog_reads_back_with_every_launch_field now covers pricing and created. a_row_from_an_older_cache_version_is_ignored is new.
  • cargo fmt --check, cargo clippy --locked --all-targets (0 warnings) and cargo test --locked --all-targets (1047 passed) all pass. The wasm --no-default-features check builds with no new warnings.

🤖 Generated with Claude Code

The launcher warms the catalog and benchmark scores on a background
thread when it opens, so Switch model and Models show the picker without
waiting on the network. Every interactive picker reads the same disk
cache that launches use. The cache now keeps prices and dates so the
picker columns survive it. HUD Models opens the picker instead of
printing a list the launcher redraws over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Duyet Le <me@duyet.net>
Co-authored-by: duyetbot <bot@duyet.net>
@duyetbot

Copy link
Copy Markdown
Member

Verdict: APPROVE (head 7ed4e29). Tests: 33 passed (picker, launch_cache). cargo check --no-default-features --lib builds with no warnings in launch_cache.rs or cmd/models.rs.

Checked and fine:

  • Cache: version key v rejects old rows. Pricing, created, billing, list_pricing and cached_input round-trip through parse_pricing/parse_models_body (per_upstream entry matches input/output, so cached_input survives). Fresh/stale/cold paths unchanged. The key is only a hash row key (mcp_wiring::cache_key); the scores file is keyed by base only.
  • WARMING: the guard is dropped before join(), a poisoned lock is recovered, and the warm thread calls load_picker_catalog (not models_for_picker), so it never joins itself.
  • Launcher Models action maps Err("Cancelled.") to the exact string term.rs returns.

Findings (none blocking):

  1. low, src/cmd/models.rs:~134 and launch_cache.rs cached(): after the warm thread loads a stale copy, the picker's own load sees it still stale and spawns a second background refresh. Two duplicate fetches and writes. Fix: skip the refresh in the picker load, or have the warm thread return after the refresh finishes.
  2. low, src/cmd/menu.rs bind_agent_model: it used to return an error when the catalog fetch failed. Now a cold, offline or bad-key launcher opens a picker with only anyrouter/auto and no message. Fix: when models is empty and the key is set, print a dim 'Could not load the catalog' line, and add a test for the cold-offline case.
  3. low, launch_cache.rs scores_with: an Ok(empty) benchmarks response is cached as a valid copy for 5 min fresh, up to 1 day stale. Fix: skip save when the map is empty.
  4. nit, tests: no test covers scores stale/cold, the WARMING join, or the cold-fetch-fails path. The two new tests would fail if the cache were not read (offline_models panics, port 1 refuses), but a stale-scores case is untested.

🤖 Generated with Claude Code

…og cannot load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Duyet Le <me@duyet.net>
Co-authored-by: duyetbot <bot@duyet.net>
@duyetbot

Copy link
Copy Markdown
Member

Review triage (author):

  1. Duplicate refresh on a stale copy: real but bounded. It only happens when the copy is 5 min to 1 day old, and it costs one extra background fetch with no wait for the user. Left as is.
  2. Silent empty picker in bind_agent_model: fixed in the latest commit. The old error returns when the catalog is empty and lookups are on.
  3. Empty scores cached: an empty 200 is a valid answer and is kept for 5 minutes like any other. Dismissed.
  4. Test gaps: noted. The WARMING join is covered by the pty measurement in the PR body.

@duyetbot

Copy link
Copy Markdown
Member

APPROVE (delta 7ed4e29..949051d). bind_agent_model returns an error again when the catalog is empty and lookups are on. The check sits after models_for_picker, so the warm thread is joined first, and a lookup-disabled env is skipped. The lib builds. No new tests for it (nit).

🤖 Generated with Claude Code

@duyet
duyet merged commit 6f00a10 into main Oct 29, 2026
1 check failed
@github-actions

Copy link
Copy Markdown
Contributor

anyr size and startup

Startup is wall time for a cold anyr --version / anyr --help (median of 21 runs). Size is the stripped release binary, or the .wasm for the browser demo.

Size budget: anyr-linux-x86_64 must stay ≤ 4.0 MiB (4194304 bytes) stripped.

Asset Kind Size --version median --help median
anyr-darwin-arm64 native 2.5 MiB 6.9 ms 7.15 ms
anyr-darwin-x86_64 native 2.9 MiB 47.4 ms 45.78 ms
anyr-linux-arm64 native 2.9 MiB 0.95 ms 0.95 ms
anyr-linux-x86_64 native 3.4 MiB 0.77 ms 0.75 ms
anyr-windows-x86_64.exe native 2.9 MiB 9.59 ms 9.42 ms
anyr.wasm wasm 252.3 KiB — —

Budget check: ok

raw timings
[
  {
    "asset": "anyr-darwin-arm64",
    "kind": "native",
    "path": "/Users/runner/work/cli/cli/anyr-darwin-arm64",
    "bytes": 2570616,
    "size": "2.5 MiB",
    "version": "0.1.37 (built 2026-10-29 16:21:00)",
    "target": "aarch64-apple-darwin",
    "os": "macos-latest",
    "startup_version": {
      "n": 21,
      "min_ms": 4.92,
      "median_ms": 6.9,
      "p95_ms": 10.73,
      "mean_ms": 7.66
    },
    "startup_help": {
      "n": 21,
      "min_ms": 4.07,
      "median_ms": 7.15,
      "p95_ms": 9.32,
      "mean_ms": 7.23
    }
  },
  {
    "asset": "anyr-darwin-x86_64",
    "kind": "native",
    "path": "/Users/runner/work/cli/cli/anyr-darwin-x86_64",
    "bytes": 3078792,
    "size": "2.9 MiB",
    "version": "0.1.37 (built 2026-10-29 16:16:03)",
    "target": "x86_64-apple-darwin",
    "os": "macos-latest",
    "startup_version": {
      "n": 21,
      "min_ms": 38.29,
      "median_ms": 47.4,
      "p95_ms": 56.22,
      "mean_ms": 49.17
    },
    "startup_help": {
      "n": 21,
      "min_ms": 38.17,
      "median_ms": 45.78,
      "p95_ms": 55.5,
      "mean_ms": 46.58
    }
  },
  {
    "asset": "anyr-linux-arm64",
    "kind": "native",
    "path": "/home/runner/work/cli/cli/anyr-linux-arm64",
    "bytes": 3020048,
    "size": "2.9 MiB",
    "version": "0.1.37 (built 2026-10-29 16:16:09)",
    "target": "aarch64-unknown-linux-gnu",
    "os": "ubuntu-24.04-arm",
    "startup_version": {
      "n": 21,
      "min_ms": 0.84,
      "median_ms": 0.95,
      "p95_ms": 1.05,
      "mean_ms": 0.95
    },
    "startup_help": {
      "n": 21,
      "min_ms": 0.86,
      "median_ms": 0.95,
      "p95_ms": 1.01,
      "mean_ms": 0.95
    }
  },
  {
    "asset": "anyr-linux-x86_64",
    "kind": "native",
    "path": "/home/runner/work/cli/cli/anyr-linux-x86_64",
    "bytes": 3568472,
    "size": "3.4 MiB",
    "version": "0.1.37 (built 2026-10-29 16:15:06)",
    "target": "x86_64-unknown-linux-gnu",
    "os": "ubuntu-latest",
    "startup_version": {
      "n": 21,
      "min_ms": 0.75,
      "median_ms": 0.77,
      "p95_ms": 0.86,
      "mean_ms": 0.79
    },
    "startup_help": {
      "n": 21,
      "min_ms": 0.74,
      "median_ms": 0.75,
      "p95_ms": 0.84,
      "mean_ms": 0.77
    }
  },
  {
    "asset": "anyr-windows-x86_64.exe",
    "kind": "native",
    "path": "D:\\a\\cli\\cli\\anyr-windows-x86_64.exe",
    "bytes": 3040768,
    "size": "2.9 MiB",
    "version": "0.1.37 (built 2026-10-29T16:16:44Z)",
    "target": "x86_64-pc-windows-msvc",
    "os": "windows-latest",
    "startup_version": {
      "n": 21,
      "min_ms": 9.03,
      "median_ms": 9.59,
      "p95_ms": 10.63,
      "mean_ms": 9.65
    },
    "startup_help": {
      "n": 21,
      "min_ms": 9.18,
      "median_ms": 9.42,
      "p95_ms": 9.68,
      "mean_ms": 9.45
    }
  },
  {
    "asset": "anyr.wasm",
    "kind": "wasm",
    "path": "/home/runner/work/cli/cli/target/wasm-pkg/anyr_cli_bg.wasm",
    "bytes": 258392,
    "size": "252.3 KiB",
    "version": "wasm",
    "target": "wasm32-unknown-unknown",
    "os": ""
  }
]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants