Repository navigation
Conversation
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.
|
@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:
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. 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 availableBefore 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
Here is what that looks like. Each setup is a throwaway HOME that only gets
When the key sits where the policy looks, the rows stay: with a Vercel key in A hidden row also has no way back. The composer, the 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
Checked and fine
7. How I measuredVersions: opencode 1.19.0 (the GitHub release codeg pins) and 2.0.28 ( The probe script:
|
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):
session/newadvertises the full models.dev catalog — 467 rows across 10 providers — whileauth.jsonholds 2 providers, so 89+ rows belong to providers whose first prompt fails with "no credentials"; the advertised defaultcurrentValueis itself an uncredentialed model.drafts.sqliteand 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):ModelAvailability::{Unfiltered, Restricted}) is computed once per spawn from the agent's fact sources and stored onSessionState.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 (wireset, preference replay, retirement heal) keeps reading the agent's raw list.currentValueis 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").thought_level/mode/etc. travel byte-for-byte.Fact sources
auth.jsoncredential keys (values must be JSON objects — the cascade's top-levelapi_keystring is not a provider) ∪providerkeys of every config file opencode reads (opencode.json, the 1.xopencode.jsoncscaffold — comment/trailing-comma stripping, verified against 1.19.1 in an isolated HOME/XDG that a provider planted there reachessession/new— and legacyconfig.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.ai.opencode.desktop/drafts.sqlite, rowopencode.global.dat/model): a provider the desktop has toggle entries for becomes opt-in — only itsshowrows survive — and an exactshowoutranks the credential/Zen-pricing inferences (measured: 9 of 15 shows sit on providersauth.jsonnever 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.auth.jsonkeys ∪models.jsonproviders ∪settings.jsondefaultProvider, through the same projection the settings panel shows, with a health check the projection deliberately swallows.session/newrefuses 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
SessionState.config_optionsand 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 pristinemainon this machine — pre-existing and unrelated (environmental socket handshake)cargo clippy --all-targets --features test-utils -- -D warningsclean