feat(acp): honor the opencode desktop's per-model visibility toggles by Liang-HZ · Pull Request #975 · spacering-net/codeg · GitHub
Skip to content

feat(acp): honor the opencode desktop's per-model visibility toggles - #975

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

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

Conversation

@Liang-HZ

@Liang-HZ Liang-HZ commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What this adds

Revision note (supersedes the earlier push)

The first version of this PR derived "availability" from files — auth.json keys, config-file provider entries, a Zen free-tier snapshot, pi's config trio. Review feedback landed and is correct: an ACP agent already applies its own availability rules (credentials, env vars, project config, cloud-account chains, local servers) to what it advertises on session/new, so client-side re-derivation can only wrongly hide models the agent itself considers available. That entire layer is deleted outright in 23207457, not disabled.

What stays is the one fact the agent cannot know about itself: the opencode desktop app's per-model show/hide toggles — the user's own explicit word, persisted where no agent reads it. This is a display-side stopgap; the long-term fix for oversized model lists belongs in the agents' own broadcast (opencode applying its availability + account state when advertising), and this PR deliberately does not second-guess the wire.

The rule

One fact source: the desktop's ai.opencode.desktop/drafts.sqlite (Electron userData; on Linux both XDG data and config roots are probed, first existing wins — measured: the db lives under ~/.config on this shape of install), table state, row opencode.global.dat/model, JSON user[] of {providerID, modelID, visibility}.

An entry whose visibility is exactly "show" or "hide" is a honored opinion; anything else (or missing) is skipped whole — no opinion, no shown row, no guess. A provider with ≥1 honored opinion becomes opt-in: only its show rows survive. A row is kept when, in order:

  1. its value equals the select's currentValue — the session IS on that model, even if the desktop marks it hide;
  2. its exact value is a show row (a show rescues a row the provider gate would drop — measured 2026-10-16: the desktop reaches Console-account models nothing else sees);
  3. its value has no /, so it cannot be attributed to any provider (fail-open);
  4. its provider has no desktop opinions — an unopinionated provider's advertised list stands untouched, because the agent's word is the availability authority.

Otherwise the row is dropped. Filtering never adds rows: a show for a model the agent does not advertise creates nothing.

Plumbing (unchanged from v1, kept per review)

  • Snapshot computed once per spawn (ModelAvailability::{Unfiltered, Restricted}), stored on SessionState; a fresh connect re-reads the desktop db.
  • 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 the agent's raw list.
  • Zero-row guard: if filtering empties the model select, the whole original list is restored — the policy misread the shape, it never means "the user has no choices".
  • Only the model option is touched; thought_level/mode/etc. travel byte-for-byte.
  • Fail-soft everywhere on the desktop source: no db, unparsable db, absent row, unexpected JSON, or an empty opinion set → Unfiltered (no filtering at all).
  • Pi and the 13 other built-ins: Unfiltered — pi self-filters its advertised list; Codex/Kimi/Cursor/Cline fail closed unauthenticated; the single-account families list the same account face; custom agents' bridges filter upstream.

Measured end-to-end (2026-10-16, opencode 2.x): the desktop held 562 toggles (15 show / 547 hide) covering all 10 advertised providers; the composer collapses from the full catalog to exactly the 16 models the user enabled.

Test plan

  • 16 module tests in model_visibility.rs (desktop gating, show-rescue, currentValue over hide, unopinionated-kept, ghost-show no-op, unknown-visibility skip, corrupt-db fail-soft, candidate first-wins incl. corrupt-first, platform-root mapping, slash-less fail-open, non-model options byte-for-byte, zero-row guard, grouped selects) + 1 emit-level test pinning that SessionState.config_options and the emitted event carry the SAME filtered rows
  • cargo clippy --all-targets --features test-utils -- -D warnings clean
  • cargo test --lib: 5460 passed; the single failure (commands::remote_proxy::tests::an_event_socket_gets_past_an_address_that_never_answers) predates this branch (socket-handshake test from windows v0.35.1 打开远程工作区显示连接已断开 #930's line of work) and fails identically without this PR's changes — environmental, unrelated
  • Policy validated against the real desktop db on this machine: 547 hide / 15 show, 443 nested modelIDs (vercel/anthropic/claude-x shape) attributed by first segment, 0 duplicate provider/model pairs

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

…r source

PR spacering-net#975's maintainer objection lands: opencode already applies its own
availability rules (credentials, env, project config, cloud chains) to
what it advertises, so re-deriving availability from auth.json/config
files can only hide models the agent considers available — file
inference is deleted outright (opencode_availability, the Zen pricing
subset, pi file reads, the JSONC scaffold parser).

What stays is the one fact the agent cannot know about itself: the
desktop app's per-model show/hide toggles (drafts.sqlite row
opencode.global.dat/model), now the sole source of a Restricted
policy. An opinionated provider (any honored toggle) becomes opt-in —
its show rows and the session's currentValue survive, everything else
drops; an unopinionated provider's advertised list stands untouched;
no or unreadable desktop state fails soft to Unfiltered; a toggle
whose visibility is neither show nor hide is skipped, not guessed.

Linux picks up the missing desktop DB location: candidates probe
XDG data AND config roots and take whichever exists (measured: only
~/.config carries the DB on this shape of install).

Tests: 15 desktop-gating cases replace the file-inference suite;
every decision path keeps reading the agent's raw list, the emit
choke point stays the only filter, and the zero-row guard holds.
Review round two returned PASS with four docs/test items: the emit-path
comment still spoke the pre-pivot "can actually run" framing (the
policy now answers what the user chose to see); the slash-less
fail-open rule had no test; the candidate probe's first-wins semantics
(past a missing candidate, committed to the first EXISTING one even if
corrupt) were unpinned; and a doc comment name-dropped the now-private
pi_agent_dir_for_env.

No behavior change: 16 model_visibility tests, the emit-path test and
clippy -D warnings are green.
@Liang-HZ Liang-HZ changed the title feat(acp): filter the composer model list to what the machine can run feat(acp): honor the opencode desktop's per-model visibility toggles Oct 17, 2026
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