Skip to content

feat(acp): filter the composer model list to what the machine can run - #975

Open
Liang-HZ wants to merge 4 commits into
spacering-net:mainfrom
Liang-HZ:feat/acp-model-visibility
Open

Liang-HZ wants to merge 4 commits into
spacering-net:mainfrom
Liang-HZ:feat/acp-model-visibility

Conversation

@Liang-HZ

Copy link
Copy Markdown
Contributor

What this adds

ACP agents advertise every model their adapter can name, not what the machine's credentials can run. Measured on real machines (2026-10-15/16):

  • opencode 2.0.28: session/new advertises the full models.dev catalog — 467 rows across 10 providers — while auth.json holds 2 providers, so 89+ rows belong to providers whose first prompt fails with "no credentials"; the advertised default currentValue is itself an uncredentialed model.
  • pi-acp 0.0.34: 335 rows advertised against 2 credentialed providers.
  • opencode desktop app: its model page per-model toggles are persisted in the desktop's drafts.sqlite and read by nothing — neither opencode's ACP nor any client — so a user who disabled 547 of 562 catalog entries still sees the whole catalog in the composer.

This PR adds a per-agent, display-side availability policy (src-tauri/src/acp/model_visibility.rs):

  • A snapshot (ModelAvailability::{Unfiltered, Restricted}) is computed once per spawn from the agent's fact sources and stored on SessionState.
  • Filtering is applied at the single emit choke point (emit_session_config_options_values), so the composer, the /-model picker and the delegation default-model probe read one filtered list, while every decision path (wire set, preference replay, retirement heal) keeps reading the agent's raw list.
  • The row equal to currentValue is always kept (the session IS on that model); a zero-row result reverts the whole list (the policy misread the shape — not "the user has no choices").
  • Only the model option is touched; thought_level/mode/etc. travel byte-for-byte.

Fact sources

  • opencode: auth.json credential keys (values must be JSON objects — the cascade's top-level api_key string is not a provider) ∪ provider keys of every config file opencode reads (opencode.json, the 1.x opencode.jsonc scaffold — comment/trailing-comma stripping, verified against 1.19.1 in an isolated HOME/XDG that a provider planted there reaches session/new — and legacy config.json) ∪ Zen's free subset when Zen is not logged in (deny-list from the bundled models.dev snapshot; ids the snapshot does not carry fail open per row) ∪ the opencode desktop app's per-model visibility toggles.
  • opencode desktop (ai.opencode.desktop/drafts.sqlite, row opencode.global.dat/model): a provider the desktop has toggle entries for becomes opt-in — only its show rows survive — and an exact show outranks the credential/Zen-pricing inferences (measured: 9 of 15 shows sit on providers auth.json never sees; the desktop reaches them through its Console account). Every read failure (no desktop app, locked/corrupt db, absent row, unexpected JSON) is fail-soft: the file-derived policy stands untouched.
  • pi: auth.json keys ∪ models.json providers ∪ settings.json defaultProvider, through the same projection the settings panel shows, with a health check the projection deliberately swallows.
  • The other 13 built-ins are judged not to need it (Codex/Kimi/Cursor/Cline fail closed unauthenticated — session/new refuses before listing; single-account families list the same account face; Codex catalog / Cline provider / Kimi catalog are already codeg-mediated) and custom agents are never second-guessed (their bridges filter upstream). The per-agent reasoning is documented in the module.

Fail-open tiers (file sources): unlocatable location → Unfiltered; missing auth file → authoritative emptiness (keep filtering with the remaining sources — a never-logged-in machine is exactly the case this feature exists for); existing-but-unparsable file → Unfiltered.

Measured end-to-end: opencode 467 → 350 rows (credential filtering), and on a machine with the desktop app → exactly the models the user enabled (~16); pi 335 → 2.

Test plan

  • 27 unit tests (26 module-level — jsonc quartet, desktop octet, zen snapshot split, pi trio, zero-row guard, grouped selects, byte-for-byte non-model options — plus 1 emit-level test pinning that SessionState.config_options and the emitted event carry the SAME filtered rows)
  • cargo test --features test-utils --lib: 5471 passed; the single failure (commands::remote_proxy::tests::an_event_socket_gets_past_an_address_that_never_answers) reproduces identically on pristine main on this machine — pre-existing and unrelated (environmental socket handshake)
  • cargo clippy --all-targets --features test-utils -- -D warnings clean
  • opencode 1.19.1 verified in an isolated HOME/XDG (same wire shape, same auth location, jsonc read, no prompts sent)
  • Real-machine harness re-measurement: opencode 467→350, pi 335→2; with the desktop allow-list the composer collapses to the user's enabled models

An agent's ACP `model` selector advertises everything its adapter can
NAME, not what the machine's credentials can actually RUN. Measured
2026-10-15: `opencode acp` v2.0.28 lists 467 rows across ten providers
against the two in `auth.json` (89 rows can only fail, and the session's
own default `currentValue` is one of them); `pi-acp` 0.0.34 lists 335
against two credentialed providers. Codeg's job is the display side: a
per-agent availability policy applied at the single emit choke point, so
the composer, the `/`-grouped model picker and the delegation "default
model" probe read one list.

This commit lands the policy module itself (wired in the next one):

- `acp/model_visibility.rs`: `ModelAvailability` (`Unfiltered` /
  `Restricted { providers, hidden_models }`), `compute()` over the SAME
  resolvers the launch and the credential cascade use, and the pure
  `filter_model_options()`. Only the model option is touched (dual
  signal: `category: model` or id `model`), grouped selects lose emptied
  groups, and the row equal to the select's `currentValue` is always
  kept.
- Fail-open semantics, three tiers: an unlocatable fact-source location
  → Unfiltered + warn; a MISSING auth file → authoritative emptiness
  (continue with custom providers ∪ Zen free / models.json ∪
  defaultProvider); a file that exists but cannot be read/parsed →
  Unfiltered + warn. Plus the zero-row guard: an emptied model select
  reverts the whole original list.
- Zen (`opencode`) without credentials keeps the free subset via the
  bundled models.dev snapshot, as a HIDE-list of models priced above
  zero — an ACP row the snapshot does not carry yet (41 ACP rows vs
  118–122 snapshot entries, a known divergence) must stay.
- The launch-env join is a marked extension point (C3), not built here;
  the thirteen other families and every `Custom(_)` are Unfiltered by
  design (custom bridges filter upstream).
- commands/acp.rs: `resolve_opencode_config_path`, `opencode_auth_json_path`
  and `pi_agent_dir_for_env` promoted `pub(crate)` so the policy reads the
  exact files the launch does — no path logic copied.

Unit tests (temp-dir fixtures, no spawns): the opencode main path
(authed + explicit + nested prefix + zen free/paid/unknown + current),
all three fail-open tiers, missing-auth authoritative emptiness,
grouped-select group dropping, the zero-row guard, non-model options
byte-for-byte (serialized equality), pi's three-source union, pi
missing-auth/missing-dir/corrupt-file, and Custom/unpolicied passthrough.
Wire the model-visibility layer (previous commit) into the connection:

- `SessionState::model_availability`: the per-launch snapshot, computed in
  `spawn_agent_connection` right beside `env_pinned_config_option_ids` —
  the one spot that holds both `agent_type` and the merged `runtime_env`
  the process is launched with. Fixed for the connection's lifetime; a
  fresh connect re-reads the fact sources, so `opencode auth login`
  reaches the next session without a restart.
- `emit_session_config_options_values`: apply `filter_model_options`
  after `visible_config_options` and before mapping, so all three
  producers (establishment, the answer to a set, the agent's push) obey
  one rule and `SessionState.config_options` — what the delegation
  "default model" probe reads — is the same filtered list the composer
  event carries.
- Decision paths stay on the agent's raw list: the wire `set` is still an
  opaque passthrough, `apply_preferred_session_options` and the
  retirement healing keep reading `initial_config_options`, and the
  `currentValue` row is always kept (no fallback, no silent set).

Test: an emit-level case pins state/event identity — after one emit the
stored `SessionState.config_options` equals the emitted list exactly, and
both carry the filtered rows (hidden provider dropped, current kept), so
the probe/composer one-source property cannot regress silently.

Tests: desktop 5234 lib (5232 pass, 2 ignored) + all integration
binaries green; clippy --all-targets -D warnings clean; server-bin
`codeg-server` check green.
…affold

OpenCode 1.19.1 reads opencode.jsonc alongside opencode.json (verified
against a real 1.19.1 acp session), so a provider configured only in the
1.x scaffold would otherwise vanish from the filtered model list. The
availability policy now unions the provider keys of every config file
OpenCode reads (opencode.json, opencode.jsonc, legacy config.json) and
strips comments/trailing commas from .jsonc candidates with string-aware
state machines; any unparsable candidate still fails open to Unfiltered.
The desktop app persists its model-page toggles in
ai.opencode.desktop/drafts.sqlite (opencode.global.dat/model), which
neither opencode's ACP nor codeg read — so the composer offered the
whole materialized catalog (measured: 562 entries, 547 hide / 15 show)
no matter what the user had enabled. The toggles are now a third
opencode fact source: a provider the desktop has opinions about becomes
opt-in, and an exact `show` outranks the credential and Zen-pricing
inferences (9 of this machine's 15 shows sit on providers auth.json
never sees — Console-account reachability). Every read failure is
fail-soft and leaves the file-derived policy untouched; pi and the
other agents are unaffected.
@xintaofei

Copy link
Copy Markdown
Collaborator

@Liang-HZ hi, and thank you for this PR! 🙏

A model picker with hundreds of rows is a real usability problem, and so is an OpenCode desktop model page whose choices codeg ignores. A lot of the plumbing here is careful work:

  • Filtering once, at the emit choke point, means the composer, the / picker and the delegation probe all read one list, while preference replay, retirement healing and the wire set keep the agent's raw one.
  • The current row always stays, an emptied select puts the whole list back, and every non-model option travels byte for byte.
  • Missing and unparsable files are kept apart, the JSONC stripping respects strings, and the emit-level test pins the stored state and the emitted event to the same rows.

CI is green on all seven jobs, and the 27 tests pass locally for me too.

That said, I'm not going to merge it in its current form. The core reason is that opencode and pi already filter the model lists they advertise, using their own availability rules, and those rules consult more sources than the files this PR reads. A filter built from those files can therefore only hide models the agent already considers available. Below is everything in one place (what the agents already do, what the filter misses, why adding sources won't close the gap, the desktop layer, a suggested way forward, smaller issues, and how I measured). If you think I got something wrong, please push back 🙂

Summary

  • §1 Main issue: both agents already filter their model lists.
  • §2 The filter reads only some of the sources the agents use, so it hides models they consider available.
  • §3 Adding more sources won't close the gap.
  • §4 The OpenCode desktop layer.
  • §5 Suggested way forward.
  • §6 Smaller issues in the current diff.
  • §7 How I measured.

1. Both agents already filter their model lists

  • opencode 1.19.0, which is codeg's built-in (registry.rs L3396). The ACP bridge takes its models from config.providers (acp/service.ts L745-L747), which returns Provider.list() (handlers/config.ts L24-L30). That list only holds providers the state loaded from an environment variable, an auth.json key, plugin auth, a custom loader that autoloads, or the config (provider.ts L1633-L1706). When no Zen key is set in the environment, auth.json or the config, the Zen loader drops every paid Zen model (provider.ts L220-L242).
  • opencode 2.0.28. The ACP catalog lists the enabled models from model.list() and picks its default from that same list (acp/catalog.ts L150-L154). Model state is built only from available providers (model.ts L97-L99). The provider snapshot drops disabled providers and ones tied to a previous account's connection, then keeps a provider when it is explicitly enabled, has a connection, or has no integration at all (provider.ts L416-L434, "Connection presence decides availability"). By default, paid Zen models are disabled when there is no Zen key (plugin/provider/opencode.ts L330-L355), and explicit model config can turn a model back on (config/plugin/provider.ts L150).
  • pi. get_available_models returns the model runtime's available snapshot (rpc-mode.ts L490-L493), which is filled by getAvailable(): "Return models whose providers have complete auth configuration" (models.ts L188-L189, L534-L554). pi 0.81.0, the oldest release pi-acp 0.0.34 supports, also answers it from getAvailable() (rpc-mode.ts L485-L488).
  • Measured (§7): with no credentials or config at all, opencode 1.19.0 and 2.0.28 both list only the 11 free Zen models, and pi-acp refuses session/new with "Authentication required". Neither agent advertises the whole models.dev catalog, and on such a machine there is nothing for the filter to remove. The module doc names the never-logged-in machine as the case this feature exists for (model_visibility.rs L61-L65). Where such a machine does show more rows, they come from sources like the environment variables in §2.
  • About the 467 and 335 rows you measured. Given the above, each of those rows belongs to a provider that passed the agent's own availability check. On 2.x, opencode acp starts its own private server (acp.ts L26-L27). To see the saved accounts and environment connections it works with, run opencode auth list --standalone in the environment codeg launches opencode with (auth/list.ts L19-L41). A provider can pass that check and still fail on its first prompt, for example with a stale key. Codeg can't tell a stale key from a working one without trying it, so I think that case belongs at the prompt (§5) rather than in the list.

2. The filter reads only some of the sources the agents use, so it hides models they consider available

Before the desktop layer (§4), the opencode policy keeps a row when it is the current one, or when its provider is an object-valued key in auth.json or a key under provider in a global config file. For Zen without a key in those files, it hides the bundled snapshot's models whose input and output prices aren't both known to be zero, and keeps model ids the snapshot doesn't have. The pi policy uses auth.json, the models.json providers and settings.json's defaultProvider. In both, a missing auth.json adds no providers and the filter stays on (model_visibility.rs L519-L540, L743-L754). These are other places the two agents find providers, and the policy reads none of them:

Here is what that looks like. Each setup is a throwaway HOME that only gets initialize and session/new, and the last column is this PR's own opencode_availability / pi_availability plus filter_model_options, run over the captured frames (§7):

Setup The agent lists After this PR
opencode 1.19.0, ANTHROPIC_API_KEY only 31 11 (all 20 Anthropic rows gone)
opencode 1.19.0, a provider in the project's opencode.json 12 11
opencode 1.19.0, OPENCODE_API_KEY only 115 11
opencode 2.0.28, ANTHROPIC_API_KEY only 31 12 (one Anthropic row left, the current one)
opencode 2.0.28, Anthropic key stored in its database, no auth.json 31 12
opencode 2.0.28, a provider under providers in the global config 12 11
opencode 2.0.28, OPENCODE_API_KEY only 115 12
pi 0.87.1, ANTHROPIC_API_KEY only 15 1
pi 1.0.0, ANTHROPIC_API_KEY only 16 1

When the key sits where the policy looks, the rows stay: with a Vercel key in auth.json and no desktop app involved, 1.19.0 lists 417 rows before and after. On a fresh 2.0.28 install the same file isn't read at all, and the list is just the 11 free Zen models.

A hidden row also has no way back. The composer, the / picker and the delegation default-model pickers all read the filtered list, and nothing lets the user show the rest.

3. Adding more sources won't close the gap

  • Each source above differs by agent and version: auth.json in 1.x against SQLite with an active account per provider in 2.x, provider against providers, how project discovery walks, two catalogs of environment-variable names, the AWS, Google and Azure chains, plugin auth, and local discovery. Mirroring all of it means re-implementing both agents' provider loading and following every release.
  • Even a perfect mirror would only rebuild the list the agent already sends. An incomplete one can hide models the agent considers available, and keeping it aligned as either agent changes its sources is ongoing work.

4. The OpenCode desktop layer

  • It reads the desktop app's private UI state (drafts.sqlite, row opencode.global.dat / model), a format the desktop is free to change in any release.
  • Its rule isn't the desktop's rule. The desktop honors explicit hide and show toggles. Otherwise it shows the newest model per provider and family whose release date is within six months of now, plus models without a parseable release date (app models.tsx L45-L72, L104-L116). Here, a provider with any toggle becomes opt-in and only its show rows survive (model_visibility.rs L329-L333), so a new model the desktop would show is hidden.
  • It applies a preference from the desktop app to whichever opencode CLI codeg launches, which can be a different install and version.
  • On Linux the lookup misses ~/.config, where the doc comment says the file lives. dirs::data_dir() resolves to $XDG_DATA_HOME or ~/.local/share whenever the home directory is known (docs), and .or_else(dirs::config_dir) only runs when it doesn't resolve, not when the database is missing there (model_visibility.rs L648-L656).

5. Suggested way forward

  1. Treat the agent's list as the source of truth for what can run. It already reflects every source the agent reads.
  2. If the goal is a shorter list, one option worth discussing is making it the user's choice. A per-agent model visibility setting in codeg (hide or show single models or whole providers, with a way to show everything again) would cover both the 467-row list and the desktop user who picked about 15 models. Most of this PR's plumbing would carry over: the per-launch snapshot on SessionState, filter_model_options with the current row kept and the empty-select guard, the grouped-select handling, and the emit-level test. Only the input changes, from inferred files to the user's saved choice. Since that means a new setting and some UI, I'd like us to agree on where it lives and how it's edited before code is written.
  3. The desktop toggles could then become an explicit, one-time import into that setting, using the desktop's own rule from §4, instead of a read on every launch.
  4. For a provider that is listed but whose key no longer works, the place to catch it is the prompt that fails: the agent can return the provider's authentication error, and codeg can surface it.

6. Smaller issues in the current diff

  1. The Zen snapshot parse isn't cached. Whenever the policy reaches the Zen fallback, because opencode isn't among the providers it found, zen_hidden_models() parses the whole ~1.7 MB bundled models.dev snapshot through bundled_catalog(), with no cache (opencode_catalog.rs L191-L193, model_visibility.rs L600-L613), inside the async spawn path (connection.rs L2480-L2485). Both opencode versions already hide paid Zen models by default when they have no key (§1), so in practice this rule only changes the list when opencode would show paid Zen models because of a key or setting the policy doesn't read, or when the bundled snapshot disagrees with opencode's own catalog.
  2. The Linux desktop path (§4).
  3. The tests don't reach the sources the policy doesn't read. The credential-source fixtures put the credential in one of the files the policy reads, so none of them shows a provider the agent lists only because of an environment variable or another outside source. A frame captured that way, like the ones in §7, would.

Checked and fine

  • For opencode and pi, every producer of the model list goes through emit_session_config_options_values: establishment (connection.rs L5559), the answer to a set (L9599) and the agent's own push (L20709). The Grok and Kiro paths that emit directly belong to agents this PR leaves unfiltered.
  • Preference replay still gets the raw initial_config_options (connection.rs L5548-L5558), and the wire set is untouched.
  • The zero-row guard, the current-row rule, the grouped selects and the non-model options behave as described.

7. How I measured

Versions: opencode 1.19.0 (the GitHub release codeg pins) and 2.0.28 (@opencode/cli), and pi-acp 0.0.34 with pi 0.87.1 and with pi 1.0.0. No prompt was sent in any of these runs, so no model was called, and none of the keys is real.

The probe script: bash probe.sh opencode /path/to/opencode or bash probe.sh pi

Each setup runs under env -i, so no API keys or other variables from your shell reach the agent, and HOME, the XDG directories and pi's agent directory point into a fresh directory under /tmp. It still runs the installed executables, and it doesn't hide system-wide configuration or model servers running locally. The directory is deleted at the end unless KEEP=1 is set. Pass the opencode executable you want to test; the script keeps node's directory on PATH in case that executable is a launcher script. For pi it uses the pi-acp, pi and node found on your PATH, and prints which pi that is. The driver needs Python 3.

#!/usr/bin/env bash
# Usage:
#   bash probe.sh opencode /path/to/opencode
#   bash probe.sh pi              # uses pi-acp, pi and node from PATH
# Runs the agent in a throwaway HOME, sends only `initialize` and `session/new`
# (never a prompt, so no model is called) and prints the model rows per
# provider for each setup. With KEEP=1 the directories are kept for the test.
set -euo pipefail

AGENT="${1:?usage: bash probe.sh opencode /path/to/opencode | bash probe.sh pi}"
PY="$(command -v python3)"
ROOT="$(mktemp -d /tmp/codeg-model-probe.XXXXXXXX)"
if [ "${KEEP:-}" = 1 ]; then
  echo "keeping $ROOT"
else
  trap 'rm -rf "$ROOT"' EXIT
fi

cat > "$ROOT/driver.py" <<'EOF'
import collections, json, subprocess, sys, threading, time

case, out, cwd, *cmd = sys.argv[1:]
proc = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stderr, replies, pushes, lock = [], {}, [], threading.Lock()

def send(msg):
    try:
        proc.stdin.write((json.dumps(msg) + "\n").encode())
        proc.stdin.flush()
    except BrokenPipeError:  # the agent exited; wait() reports it below
        pass

def read():
    for raw in proc.stdout:
        try:
            msg = json.loads(raw)
        except ValueError:
            continue
        if "method" in msg and "id" in msg:  # a request from the agent: refuse it
            send({"jsonrpc": "2.0", "id": msg["id"],
                  "error": {"code": -32601, "message": "probe"}})
        elif "method" in msg:
            with lock:
                pushes.append(msg)
        else:
            with lock:
                replies[msg.get("id")] = msg

def wait(rid, secs):
    end = time.time() + secs
    while time.time() < end and proc.poll() is None:
        with lock:
            if rid in replies:
                return replies[rid]
        time.sleep(0.05)
    with lock:
        return replies.get(rid)

threading.Thread(target=read, daemon=True).start()
threading.Thread(target=lambda: stderr.extend(proc.stderr), daemon=True).start()
try:
    send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
          "params": {"protocolVersion": 1, "clientCapabilities": {}}})
    wait(1, 60)
    send({"jsonrpc": "2.0", "id": 2, "method": "session/new",
          "params": {"cwd": cwd, "mcpServers": []}})
    new = wait(2, 120)
    time.sleep(4)  # opencode may push a refreshed list right after session/new
finally:
    proc.kill()
    proc.wait(timeout=10)
with lock:
    json.dump({"session_new": new, "updates": pushes}, open(out, "w"))
    updates = list(pushes)

if new is None:
    tail = b"".join(stderr).decode(errors="replace")[-1500:]
    sys.exit(f"{case}: no reply to session/new\n{tail}")
if "error" in new:
    print(f"{case}: session/new failed: {new['error'].get('message')}")
    sys.exit(0)
options = new["result"].get("configOptions") or []
for update in updates:
    body = update.get("params", {}).get("update", {})
    if body.get("sessionUpdate") == "config_option_update":
        options = body.get("configOptions", options)
select = next((o for o in options
               if o.get("category") == "model" or o.get("id") == "model"), None)
if select is None:
    print(f"{case}: no model select")
    sys.exit(0)
rows = []
for entry in select.get("options", []):
    rows += [r["value"] for r in entry["options"]] if "group" in entry else [entry["value"]]
count = collections.Counter(v.split("/", 1)[0] for v in rows)
summary = ", ".join(f"{p} {n}" for p, n in sorted(count.items()))
print(f"{case}: {len(rows)} rows, current {select.get('currentValue')} ({summary})")
EOF

# probe <dir> <case> <extra VAR=value...> -- <agent command...>
probe() {
  local dir="$1" case="$2"
  shift 2
  local extra=()
  while [ "$1" != "--" ]; do extra+=("$1"); shift; done
  shift
  (cd "$dir/proj" && env -i HOME="$dir/home" PATH="$PATH_FOR_AGENT" \
    XDG_CONFIG_HOME="$dir/home/.config" XDG_DATA_HOME="$dir/home/.local/share" \
    XDG_CACHE_HOME="$dir/home/.cache" XDG_STATE_HOME="$dir/home/.local/state" \
    OPENCODE_DISABLE_MODELS_FETCH=true OPENCODE_DISABLE_AUTOUPDATE=true \
    PI_OFFLINE=1 PI_CODING_AGENT_DIR="$dir/home/.pi/agent" \
    ${extra[@]+"${extra[@]}"} \
    "$PY" -I "$ROOT/driver.py" "$case" "$dir/out.json" "$dir/proj" "$@")
}

# fresh <n-case> <agent>: a new directory with an empty HOME and project
fresh() {
  local dir="$ROOT/$1"
  mkdir -p "$dir/home/.config/opencode" "$dir/home/.local/share/opencode" \
    "$dir/home/.pi/agent" "$dir/proj"
  echo "$2" > "$dir/agent"
  echo "$dir"
}

case "$AGENT" in
  opencode)
    BIN="${2:?pass the opencode binary}"
    PATH_FOR_AGENT=/usr/bin:/bin:/usr/sbin:/sbin
    # Keep node reachable in case the opencode you pass is a launcher script.
    if command -v node >/dev/null 2>&1; then
      PATH_FOR_AGENT="$(dirname "$(command -v node)"):$PATH_FOR_AGENT"
    fi
    VERSION="$(env -i HOME="$ROOT" PATH="$PATH_FOR_AGENT" "$BIN" --version 2>/dev/null | head -n 1 || true)"
    if [ -z "$VERSION" ]; then
      echo "could not run $BIN --version" >&2
      exit 1
    fi
    echo "opencode --version: $VERSION" | tee "$ROOT/version"

    d="$(fresh 1-none opencode)"
    probe "$d" none -- "$BIN" acp

    d="$(fresh 2-env opencode)"
    probe "$d" env ANTHROPIC_API_KEY=sk-ant-not-a-real-key -- "$BIN" acp

    d="$(fresh 3-zen-env opencode)"
    probe "$d" zen-env OPENCODE_API_KEY=not-a-real-key -- "$BIN" acp

    d="$(fresh 4-project opencode)"
    cat > "$d/proj/opencode.json" <<'EOF'
{"provider":{"projllm":{"npm":"@ai-sdk/openai-compatible","name":"Project LLM","options":{"baseURL":"http://127.0.0.1:9/v1"},"models":{"m1":{"name":"M1"}}}}}
EOF
    probe "$d" project -- "$BIN" acp

    d="$(fresh 5-providers opencode)"
    cat > "$d/home/.config/opencode/opencode.json" <<'EOF'
{"providers":{"globllm":{"name":"Global LLM","package":"aisdk:@ai-sdk/openai-compatible","settings":{"baseURL":"http://127.0.0.1:9/v1"},"models":{"m1":{"name":"M1"}}}}}
EOF
    probe "$d" providers -- "$BIN" acp

    d="$(fresh 6-auth-json opencode)"
    echo '{"vercel":{"type":"api","key":"not-a-real-key"}}' > "$d/home/.local/share/opencode/auth.json"
    probe "$d" auth-json -- "$BIN" acp

    case "$VERSION" in
      *v2.*|2.*)
        d="$(fresh 7-stored opencode)"
        printf '%s' '[{"id":"cred_probe","integrationID":"anthropic","label":"probe","active":true,"value":{"type":"key","key":"sk-ant-not-a-real-key"}}]' > "$d/creds.json"
        (cd "$d/proj" && env -i HOME="$d/home" PATH="$PATH_FOR_AGENT" \
          XDG_CONFIG_HOME="$d/home/.config" XDG_DATA_HOME="$d/home/.local/share" \
          XDG_CACHE_HOME="$d/home/.cache" XDG_STATE_HOME="$d/home/.local/state" \
          OPENCODE_DISABLE_MODELS_FETCH=true OPENCODE_DISABLE_AUTOUPDATE=true \
          "$BIN" auth import --standalone "$d/creds.json" >/dev/null 2>&1)
        probe "$d" stored -- "$BIN" acp
        ;;
    esac
    ;;
  pi)
    PATH_FOR_AGENT="$(dirname "$(command -v pi-acp)"):$(dirname "$(command -v pi)"):$(dirname "$(command -v node)"):/usr/bin:/bin"
    echo "pi --version: $(env -i HOME="$ROOT" PATH="$PATH_FOR_AGENT" PI_OFFLINE=1 pi --version 2>/dev/null | head -n 1)" | tee "$ROOT/version"
    d="$(fresh 1-none pi)"
    probe "$d" none -- pi-acp

    d="$(fresh 2-env pi)"
    probe "$d" env ANTHROPIC_API_KEY=sk-ant-not-a-real-key -- pi-acp
    ;;
  *)
    echo "unknown agent: $AGENT" >&2
    exit 2
    ;;
esac

Here it printed this, one run per version: bash probe.sh opencode with the 1.19.0 binary, then with the 2.0.28 binary, then bash probe.sh pi with pi 0.87.1 first on PATH, then with pi 1.0.0 first on PATH.

opencode --version: 1.19.0
none: 11 rows, current opencode/big-pickle (opencode 11)
env: 31 rows, current opencode/big-pickle (anthropic 20, opencode 11)
zen-env: 115 rows, current opencode/big-pickle (opencode 84, opencode-go 31)
project: 12 rows, current opencode/big-pickle (opencode 11, projllm 1)
providers: 11 rows, current opencode/big-pickle (opencode 11)
auth-json: 417 rows, current opencode/big-pickle (opencode 11, vercel 406)
opencode --version: opencode v2.0.28
none: 11 rows, current opencode/exo-free (opencode 11)
env: 31 rows, current anthropic/claude-haiku-5-5 (anthropic 20, opencode 11)
zen-env: 115 rows, current opencode-go/claude-haiku-5-5 (opencode 84, opencode-go 31)
project: 12 rows, current opencode/exo-free (opencode 11, projllm 1)
providers: 12 rows, current opencode/exo-free (globllm 1, opencode 11)
auth-json: 11 rows, current opencode/exo-free (opencode 11)
stored: 31 rows, current anthropic/claude-haiku-5-5 (anthropic 20, opencode 11)
pi --version: 0.87.1
none: session/new failed: Authentication required: Configure an API key or log in with an OAuth provider.
env: 15 rows, current anthropic/claude-opus-4-8 (anthropic 15)
pi --version: 1.0.0
none: session/new failed: Authentication required: Configure an API key or log in with an OAuth provider.
env: 16 rows, current anthropic/claude-opus-4-8 (anthropic 16)
Running this PR's filter over the captured frames

Append this module to the end of src-tauri/src/acp/model_visibility.rs (it calls the module's private functions), run the probe script with KEEP=1, and pass the directories it prints, comma-separated, in PROBE_ROOTS. The test calls opencode_availability / pi_availability and filter_model_options directly, so it measures the policy and the filter, not compute()'s path resolution or the emit path.

#[cfg(test)]
mod probe_frames {
    use super::*;

    /// The model list the agent last sent: session/new's, or a later
    /// `config_option_update` push.
    fn advertised(dir: &Path) -> Option<Vec<SessionConfigOption>> {
        let raw = std::fs::read_to_string(dir.join("out.json")).ok()?;
        let frames: serde_json::Value = serde_json::from_str(&raw).ok()?;
        let mut options = frames["session_new"]["result"]["configOptions"].clone();
        for update in frames["updates"].as_array().into_iter().flatten() {
            let body = &update["params"]["update"];
            if body["sessionUpdate"] == "config_option_update" {
                options = body["configOptions"].clone();
            }
        }
        serde_json::from_value(options).ok()
    }

    fn per_provider(options: &[SessionConfigOption]) -> BTreeMap<String, usize> {
        let mut counts = BTreeMap::new();
        for option in options.iter().filter(|option| is_model_config_option(option)) {
            let SessionConfigKind::Select(select) = &option.kind else {
                continue;
            };
            let rows: Vec<&SessionConfigSelectOption> = match &select.options {
                SessionConfigSelectOptions::Ungrouped(rows) => rows.iter().collect(),
                SessionConfigSelectOptions::Grouped(groups) => {
                    groups.iter().flat_map(|group| group.options.iter()).collect()
                }
                _ => Vec::new(),
            };
            for row in rows {
                let value = row.value.to_string();
                let provider = value.split_once('/').map_or(value.as_str(), |(p, _)| p);
                *counts.entry(provider.to_string()).or_insert(0) += 1;
            }
        }
        counts
    }

    /// Feeds frames captured by probe.sh (run with KEEP=1) through this PR's
    /// own policy and filter. The desktop-toggle layer is left out: it reads
    /// the real machine's desktop database, not the throwaway HOME.
    #[test]
    fn probe_frames() {
        let Ok(roots) = std::env::var("PROBE_ROOTS") else {
            return;
        };
        for root in roots.split(',') {
            let root = Path::new(root);
            println!("{}", std::fs::read_to_string(root.join("version")).unwrap().trim());
            let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
                .unwrap()
                .map(|entry| entry.unwrap().path())
                .filter(|path| path.join("out.json").exists())
                .collect();
            dirs.sort();
            for dir in dirs {
                let case = dir.file_name().unwrap().to_string_lossy().into_owned();
                let Some(options) = advertised(&dir) else {
                    println!("  {case}: no model list");
                    continue;
                };
                let agent = std::fs::read_to_string(dir.join("agent")).unwrap();
                let availability = if agent.trim() == "pi" {
                    pi_availability(&dir.join("home/.pi/agent"))
                } else {
                    let config = dir.join("home/.config/opencode");
                    opencode_availability(
                        &dir.join("home/.local/share/opencode/auth.json"),
                        &[
                            config.join("opencode.json"),
                            config.join("opencode.jsonc"),
                            config.join("config.json"),
                        ],
                    )
                };
                let before = per_provider(&options);
                let after = per_provider(&filter_model_options(&availability, options));
                let detail: Vec<String> = before
                    .iter()
                    .map(|(provider, n)| {
                        format!("{provider} {n} -> {}", after.get(provider).copied().unwrap_or(0))
                    })
                    .collect();
                println!(
                    "  {case}: {} -> {} rows ({})",
                    before.values().sum::<usize>(),
                    after.values().sum::<usize>(),
                    detail.join(", ")
                );
            }
        }
    }
}
cd src-tauri
PROBE_ROOTS=/first/kept/dir,/second/kept/dir cargo test --no-default-features --features server-bin --lib probe_frames -- --nocapture

With the four runs above, the test printed:

opencode --version: 1.19.0
  1-none: 11 -> 11 rows (opencode 11 -> 11)
  2-env: 31 -> 11 rows (anthropic 20 -> 0, opencode 11 -> 11)
  3-zen-env: 115 -> 11 rows (opencode 84 -> 11, opencode-go 31 -> 0)
  4-project: 12 -> 11 rows (opencode 11 -> 11, projllm 1 -> 0)
  5-providers: 11 -> 11 rows (opencode 11 -> 11)
  6-auth-json: 417 -> 417 rows (opencode 11 -> 11, vercel 406 -> 406)
opencode --version: opencode v2.0.28
  1-none: 11 -> 11 rows (opencode 11 -> 11)
  2-env: 31 -> 12 rows (anthropic 20 -> 1, opencode 11 -> 11)
  3-zen-env: 115 -> 12 rows (opencode 84 -> 11, opencode-go 31 -> 1)
  4-project: 12 -> 11 rows (opencode 11 -> 11, projllm 1 -> 0)
  5-providers: 12 -> 11 rows (globllm 1 -> 0, opencode 11 -> 11)
  6-auth-json: 11 -> 11 rows (opencode 11 -> 11)
  7-stored: 31 -> 12 rows (anthropic 20 -> 1, opencode 11 -> 11)
pi --version: 0.87.1
  1-none: no model list
  2-env: 15 -> 1 rows (anthropic 15 -> 1)
pi --version: 1.0.0
  1-none: no model list
  2-env: 16 -> 1 rows (anthropic 16 -> 1)

Afterwards, remove the appended module and delete the directories printed by KEEP=1.

Thanks again! The emit-path plumbing is the part that's hard to get right, and it carries straight over to the direction in §5. If anything here is unclear, or you see it differently, just reply here. I'm happy to look at the next round.

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