From b3e5a65b73d50d722e862881cce52b265ed5ad75 Mon Sep 17 00:00:00 2001 From: xy Date: Mon, 2 Nov 2026 01:41:02 +0000 Subject: [PATCH 1/2] fix(opencode): filter disabled models in model pickers --- src-tauri/src/acp/connection.rs | 143 +++++++++++- src-tauri/src/acp/mod.rs | 1 + src-tauri/src/acp/opencode_models.rs | 206 ++++++++++++++++++ src-tauri/src/acp/session_state.rs | 5 +- ...cp-agent-settings.opencode-models.test.tsx | 46 ++++ .../settings/acp-agent-settings.tsx | 38 +--- src/lib/opencode-connect.test.ts | 50 +++++ src/lib/opencode-connect.ts | 7 + src/lib/session-settings.test.ts | 14 ++ 9 files changed, 478 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/acp/opencode_models.rs diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 79d0cbd3801..5c294bb5862 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2760,6 +2760,10 @@ pub async fn spawn_agent_connection( initial_state.pi_enabled_model_patterns = crate::commands::acp::pi_enabled_model_patterns_for_env(&runtime_env, &launch_cwd); } + if agent_type == AgentType::OpenCode { + initial_state.opencode_hidden_models = + crate::acp::opencode_models::hidden_models_for_env(&runtime_env, &launch_cwd); + } // Fixed by the owner at creation; codex reads it from its launch // environment (`apply_codex_prompt_suggestion_policy`). let wants_prompt_suggestions = initial_state.wants_prompt_suggestions; @@ -4498,6 +4502,41 @@ fn filter_pi_enabled_model_options( .collect() } +fn filter_opencode_model_options( + hidden: &HashSet, + config_options: Vec, +) -> Vec { + if hidden.is_empty() { + return config_options; + } + config_options + .into_iter() + .map(|mut option| { + if !is_model_config_option(&option) { + return option; + } + let SessionConfigKind::Select(select) = &mut option.kind else { + return option; + }; + let keep = |row: &SessionConfigSelectOption| { + let value = row.value.to_string(); + !hidden.contains(value.split('#').next().unwrap_or(&value)) + }; + match &mut select.options { + SessionConfigSelectOptions::Ungrouped(options) => options.retain(&keep), + SessionConfigSelectOptions::Grouped(groups) => { + for group in groups.iter_mut() { + group.options.retain(&keep); + } + groups.retain(|group| !group.options.is_empty()); + } + _ => {} + } + option + }) + .collect() +} + async fn emit_session_config_options_values( state: &Arc>, emitter: &EventEmitter, @@ -4506,11 +4545,12 @@ async fn emit_session_config_options_values( // Drop what this launch froze BEFORE mapping, so every producer of this // event — establishment, the answer to a set, and the agent's own // `config_option_update` push — is filtered by one rule. - let (pinned, pi_enabled_model_patterns) = { + let (pinned, pi_enabled_model_patterns, opencode_hidden_models) = { let guard = state.read().await; ( guard.env_pinned_config_option_ids.clone(), guard.pi_enabled_model_patterns.clone(), + guard.opencode_hidden_models.clone(), ) }; let visible = visible_config_options(&pinned, config_options); @@ -4518,6 +4558,7 @@ async fn emit_session_config_options_values( Some(patterns) => filter_pi_enabled_model_options(patterns, visible), None => visible, }; + let visible = filter_opencode_model_options(&opencode_hidden_models, visible); emit_with_state( state, emitter, @@ -42562,6 +42603,106 @@ mod tests { ); } + #[tokio::test] + async fn opencode_models_are_filtered_in_events_and_session_snapshots() { + let mut initial = SessionState::new( + "opencode-model-filter".into(), + AgentType::OpenCode, + None, + "w".into(), + None, + ); + initial.opencode_hidden_models = HashSet::from([ + "relay/hidden".into(), + "relay/disabled".into(), + ]); + let state = Arc::new(RwLock::new(initial)); + let options: Vec = serde_json::from_value(serde_json::json!([ + { + "type": "select", "id": "model", "name": "Model", "category": "model", + "currentValue": "relay/hidden", + "options": [ + {"value":"relay/hidden", "name":"Hidden"}, + {"value":"relay/disabled#high", "name":"Disabled variant"}, + {"value":"other/hidden", "name":"Other provider"}, + {"value":"relay/allowed", "name":"Allowed"} + ] + }, + { + "type": "select", "id": "effort", "name": "Effort", + "currentValue": "relay/disabled", + "options": [{"value":"relay/disabled", "name":"Unrelated option"}] + } + ])) + .unwrap(); + // Repeated emissions cover establishment, setter responses and pushes. + for _ in 0..2 { + emit_session_config_options_values(&state, &EventEmitter::Noop, options.clone()).await; + let guard = state.read().await; + let options = guard.config_options.as_ref().unwrap(); + let SessionConfigKindInfo::Select(model) = &options[0].kind else { + panic!("expected model selector"); + }; + assert_eq!(model.current_value, "relay/hidden"); + assert_eq!( + model.options.iter().map(|row| row.value.as_str()).collect::>(), + ["other/hidden", "relay/allowed"] + ); + let SessionConfigKindInfo::Select(effort) = &options[1].kind else { + panic!("expected effort selector"); + }; + assert_eq!(effort.options.len(), 1); + let events = guard.recent_events_after(0).unwrap(); + let AcpEvent::SessionConfigOptions { + config_options: emitted, + } = &events.last().unwrap().payload + else { + panic!("expected config option event"); + }; + assert_eq!(emitted, options); + assert_eq!(guard.to_snapshot().config_options.as_ref(), Some(options)); + } + } + + #[test] + fn opencode_models_drop_empty_groups_and_keep_the_selector_when_all_hidden() { + let options: Vec = serde_json::from_value(serde_json::json!([{ + "type": "select", "id": "custom-model", "category": "model", "name": "Model", + "currentValue": "relay/disabled", + "options": [ + {"group":"relay", "name":"Relay", "options":[ + {"value":"relay/disabled", "name":"Disabled"} + ]}, + {"group":"other", "name":"Other", "options":[ + {"value":"other/allowed", "name":"Allowed"} + ]} + ] + }])) + .unwrap(); + let hidden = HashSet::from(["relay/disabled".into()]); + let filtered = filter_opencode_model_options(&hidden, options); + let SessionConfigKind::Select(select) = &filtered[0].kind else { + panic!("expected model selector"); + }; + let SessionConfigSelectOptions::Grouped(groups) = &select.options else { + panic!("expected grouped options"); + }; + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].options[0].value.to_string(), "other/allowed"); + + let filtered = filter_opencode_model_options( + &HashSet::from(["other/allowed".into()]), + filtered, + ); + let SessionConfigKind::Select(select) = &filtered[0].kind else { + panic!("expected model selector"); + }; + let SessionConfigSelectOptions::Grouped(groups) = &select.options else { + panic!("expected grouped options"); + }; + assert!(groups.is_empty()); + } + fn pi_model_selector_options() -> Vec { serde_json::from_value(serde_json::json!([ { diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index cd9fcb33b3a..de876b994c1 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -41,6 +41,7 @@ pub mod opencode_catalog; pub(crate) mod opencode_config; pub(crate) mod opencode_credentials; pub mod opencode_launch; +pub(crate) mod opencode_models; pub mod opencode_plugins; pub mod plan_approval; pub mod preflight; diff --git a/src-tauri/src/acp/opencode_models.rs b/src-tauri/src/acp/opencode_models.rs new file mode 100644 index 00000000000..b3c06ada692 --- /dev/null +++ b/src-tauri/src/acp/opencode_models.rs @@ -0,0 +1,206 @@ +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +use serde_json::Value; + +use crate::parsers::AgentEnv; + +use super::opencode_config::{self, CONFIG_FILE_NAMES}; + +#[derive(Default)] +struct ProviderFilter { + disabled: BTreeMap, +} + +#[derive(Default)] +struct ModelFilters { + providers: BTreeMap, +} + +impl ModelFilters { + fn apply(&mut self, config: &Value) { + let native = config.get("providers").and_then(Value::as_object); + for key in ["provider", "providers"] { + let Some(blocks) = config.get(key).and_then(Value::as_object) else { + continue; + }; + for (id, block) in blocks { + if !block.is_object() + || (key == "provider" + && native + .and_then(|blocks| blocks.get(id)) + .is_some_and(Value::is_object)) + { + continue; + } + let filter = self.providers.entry(id.clone()).or_default(); + if let Some(models) = block.get("models").and_then(Value::as_object) { + for (model_id, model) in models { + if let Some(disabled) = model.get("disabled").and_then(Value::as_bool) { + filter.disabled.insert(model_id.clone(), disabled); + } + } + } + } + } + } + + fn read_file(&mut self, path: &Path) { + if let Ok(text) = std::fs::read_to_string(path) { + if let Ok(config) = opencode_config::parse(&text) { + self.apply(&config); + } + } + } + + fn read_dir(&mut self, dir: &Path) { + for name in CONFIG_FILE_NAMES { + self.read_file(&dir.join(name)); + } + } + + fn hidden_models(self) -> HashSet { + let mut hidden = HashSet::new(); + for (provider_id, filter) in self.providers { + hidden.extend( + filter + .disabled + .iter() + .filter_map(|(id, disabled)| disabled.then_some(id)) + .map(|id| format!("{provider_id}/{id}")), + ); + } + hidden + } +} + +/// Read only model visibility rules, in OpenCode's global → direct project → +/// .opencode project → system order. No credential substitution is needed. +pub(crate) fn hidden_models_for_env( + runtime_env: &BTreeMap, + working_dir: &Path, +) -> HashSet { + let env = AgentEnv::from_overrides(runtime_env.clone()); + let global = opencode_config::config_dir_for_env(&env); + let system = if cfg!(target_os = "macos") { + Some(std::path::PathBuf::from( + "/Library/Application Support/opencode", + )) + } else if cfg!(windows) { + env.var_os("ProgramData") + .map(std::path::PathBuf::from) + .map(|dir| dir.join("opencode")) + } else { + Some(std::path::PathBuf::from("/etc/opencode")) + }; + let mut filters = ModelFilters::default(); + if let Some(dir) = &global { + filters.read_dir(dir); + } + let ancestors: Vec<_> = + if global.as_deref() == Some(working_dir) || system.as_deref() == Some(working_dir) { + Vec::new() + } else { + working_dir.ancestors().collect() + }; + for dir in ancestors.iter().rev() { + if global.as_deref() != Some(*dir) && system.as_deref() != Some(*dir) { + filters.read_dir(dir); + } + } + for dir in ancestors.iter().rev() { + let dir = dir.join(".opencode"); + if global.as_ref() != Some(&dir) && system.as_ref() != Some(&dir) { + filters.read_dir(&dir); + } + } + if let Some(dir) = &system { + filters.read_dir(dir); + } + filters.hidden_models() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_models_are_provider_scoped_and_ignore_blacklist() { + let mut filters = ModelFilters::default(); + filters.apply(&serde_json::json!({ + "providers": { "relay": { + "blacklist": ["old", "vendor/hidden"], + "models": { + "disabled": {"disabled": true}, + "enabled": {"disabled": false}, + "alias": {"modelID": "old"} + } + }} + })); + assert_eq!( + filters.hidden_models(), + HashSet::from(["relay/disabled".into()]) + ); + } + + #[test] + fn native_blocks_win_and_later_layers_can_reenable_a_model() { + let mut filters = ModelFilters::default(); + filters.apply(&serde_json::json!({ + "provider": {"relay": {"blacklist": ["legacy"]}}, + "providers": {"relay": { + "blacklist": ["native"], + "models": {"old": {"disabled": true}, "hidden": {"disabled": true}} + }} + })); + filters.apply(&serde_json::json!({"providers": {"relay": { + "blacklist": [], "models": {"old": {"disabled": false}} + }}})); + assert_eq!( + filters.hidden_models(), + HashSet::from(["relay/hidden".into()]) + ); + } + + #[test] + fn jsonc_layers_merge_visibility_without_losing_unmentioned_models() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("opencode.json"), + r#"{"providers":{"relay":{"blacklist":["old"],"models":{ + "disabled":{"disabled":true},"enabled":{"disabled":true} + }}}}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("opencode.jsonc"), + r#"{ // later file overrides individual fields + "providers":{"relay":{"blacklist":["new"],"models":{ + "enabled":{"disabled":false}, + }}} + }"#, + ) + .unwrap(); + let mut filters = ModelFilters::default(); + filters.read_dir(dir.path()); + assert_eq!( + filters.hidden_models(), + HashSet::from(["relay/disabled".into()]) + ); + } + + #[test] + fn blacklist_in_later_layers_does_not_change_disabled_models() { + let mut filters = ModelFilters::default(); + filters.apply(&serde_json::json!({"providers": {"relay": { + "blacklist": ["old"], "models": {"disabled": {"disabled": true}} + }}})); + filters.apply(&serde_json::json!({"provider": {"relay": { + "blacklist": ["new"] + }}})); + assert_eq!( + filters.hidden_models(), + HashSet::from(["relay/disabled".into()]) + ); + } +} diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 74dbd534a93..a7140275789 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -1,7 +1,7 @@ //! 会话级状态结构。后端权威:流式累积、in-flight tool calls、待处理 permission 等 //! 全部住在这里。Phase 2 的 snapshot 端点直接从此处读取 live 部分。 -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -472,6 +472,8 @@ pub struct SessionState { /// /// Backend-internal — not serialized, not carried on `to_snapshot()`. pub pi_enabled_model_patterns: Option>, + /// OpenCode model IDs hidden by model disabled flags. + pub opencode_hidden_models: HashSet, pub prompt_capabilities: Option, pub fork_supported: bool, pub available_commands: Vec, @@ -907,6 +909,7 @@ impl SessionState { env_pinned_config_option_ids: Vec::new(), trae_pat_sign_in: false, pi_enabled_model_patterns: None, + opencode_hidden_models: HashSet::new(), prompt_capabilities: None, fork_supported: false, available_commands: Vec::new(), diff --git a/src/components/settings/acp-agent-settings.opencode-models.test.tsx b/src/components/settings/acp-agent-settings.opencode-models.test.tsx index 868fb288ab1..114870599bd 100644 --- a/src/components/settings/acp-agent-settings.opencode-models.test.tsx +++ b/src/components/settings/acp-agent-settings.opencode-models.test.tsx @@ -188,5 +188,51 @@ describe( expect(saved.provider.relay.models).toEqual(PICKED) expect(saved.providers).toBeUndefined() }) + + it.each(["models", "provider"])( + "keeps disabled %s out of both default model pickers before the catalog loads", + async (disabled) => { + const user = userEvent.setup() + vi.mocked(acpListAgents).mockResolvedValue([ + openCodeAgent({ + providers: { + relay: { + ...NATIVE_CONFIG.providers.relay, + models: { + "glm-5.2": { disabled: disabled === "models" }, + old: { disabled: disabled === "models" }, + }, + }, + }, + ...(disabled === "provider" + ? { + policies: [ + { + action: "provider.use", + resource: "relay", + effect: "deny", + }, + ], + } + : {}), + }), + ]) + render( + + + + ) + const inputs = + await screen.findAllByPlaceholderText("provider/model-id") + expect(inputs).toHaveLength(2) + for (const input of inputs) { + await user.click(input) + expect( + screen.queryAllByRole("option", { name: /relay\// }) + ).toHaveLength(0) + await user.keyboard("{Escape}") + } + } + ) } ) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index f0da2efbcad..02a1e0b6262 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -1770,26 +1770,6 @@ const OPENCODE_PROVIDER_NPM_OPTIONS = [ }, ] as const -function buildOpenCodeModelOptions( - config: OpenCodeConfigView | null -): OpenCodeModelOptionGroup[] { - if (!config) return [] - const groups: OpenCodeModelOptionGroup[] = [] - for (const providerId of config.providerIds) { - const provider = config.providers[providerId] - if (!provider || provider.modelIds.length === 0) continue - groups.push({ - providerId, - label: provider.name || providerId, - models: provider.modelIds.map((modelId) => ({ - value: `${providerId}/${modelId}`, - label: modelId, - })), - }) - } - return groups -} - function OpenCodeModelCombobox({ value, onValueChange, @@ -6429,16 +6409,14 @@ export function AcpAgentSettings() { openCodeIntegrations, openCodeCatalog, ]) - const openCodeModelOptions = useMemo(() => { - const catalogGroups = buildConnectedModelOptions({ - connected: openCodeConnected, - catalog: openCodeCatalog, - }) - // Fall back to the config-derived groups before the catalog has loaded. - return catalogGroups.length > 0 - ? catalogGroups - : buildOpenCodeModelOptions(selectedOpenCodeConfig) - }, [openCodeConnected, openCodeCatalog, selectedOpenCodeConfig]) + const openCodeModelOptions = useMemo( + () => + buildConnectedModelOptions({ + connected: openCodeConnected, + catalog: openCodeCatalog, + }), + [openCodeConnected, openCodeCatalog] + ) const openCodeCatalogIds = useMemo( () => new Set(openCodeCatalog.map((p) => p.id)), [openCodeCatalog] diff --git a/src/lib/opencode-connect.test.ts b/src/lib/opencode-connect.test.ts index 623d702b0c1..261449aa641 100644 --- a/src/lib/opencode-connect.test.ts +++ b/src/lib/opencode-connect.test.ts @@ -543,6 +543,56 @@ describe("integrations and the panel view", () => { }) expect(groups[1].models.map((m) => m.value)).toEqual(["relay/relay-1"]) }) + + it("filters disabled configured and catalog models by provider and config ID", () => { + const connected = buildConnectedProviders({ + config: { + provider: { + anthropic: { models: { enabled: { disabled: true } } }, + }, + providers: { + anthropic: { + blacklist: ["enabled"], + models: { + "claude-x": { disabled: true }, + "vendor/hidden": { disabled: true }, + enabled: { disabled: false, modelID: "claude-x" }, + implicit: {}, + malformed: { disabled: "true" }, + }, + }, + relay: { models: { "claude-x": {} } }, + }, + }, + integrations: [], + catalog: CATALOG, + }) + const groups = buildConnectedModelOptions({ connected, catalog: CATALOG }) + expect(groups.flatMap((group) => group.models.map((m) => m.value))).toEqual( + [ + "anthropic/enabled", + "anthropic/implicit", + "anthropic/malformed", + "relay/claude-x", + ] + ) + }) + + it.each([{ catalog: [] }, { catalog: CATALOG }])( + "omits providers with only disabled models (catalog size: $catalog.length)", + ({ catalog }) => { + const connected = buildConnectedProviders({ + config: { + providers: { + anthropic: { models: { "claude-x": { disabled: true } } }, + }, + }, + integrations: [], + catalog, + }) + expect(buildConnectedModelOptions({ connected, catalog })).toEqual([]) + } + ) }) describe("custom provider ids and labels", () => { diff --git a/src/lib/opencode-connect.ts b/src/lib/opencode-connect.ts index 323f77b1386..207981f5929 100644 --- a/src/lib/opencode-connect.ts +++ b/src/lib/opencode-connect.ts @@ -156,6 +156,7 @@ export type OpenCodeProviderShape = "native" | "legacy" export interface OpenCodeProviderModelView { id: string name: string + disabled: boolean /** Keys besides `name` (and 2.x's `modelID` alias), kept as they are. */ extraFieldCount: number } @@ -182,6 +183,7 @@ function modelViews( views[id] = { id, name: str(model.name), + disabled: model.disabled === true, extraFieldCount: Object.keys(model).filter( (key) => key !== "name" && key !== "modelID" && key !== "id" ).length, @@ -643,6 +645,7 @@ export interface OpenCodeConnectedProvider { baseUrl: string npm: string modelIds: string[] + disabledModelIds: string[] } function connectionKindOf(integration: OpenCodeIntegration | undefined): { @@ -699,6 +702,9 @@ export function buildConnectedProviders(params: { baseUrl: block?.baseUrl ?? "", npm: block?.npm ?? "", modelIds: block?.modelIds ?? [], + disabledModelIds: Object.values(block?.models ?? {}) + .filter((model) => model.disabled) + .map((model) => model.id), }) } result.sort( @@ -741,6 +747,7 @@ export function buildConnectedModelOptions(params: { const metaById = new Map(catalog?.models.map((m) => [m.id, m]) ?? []) const ids = new Set(provider.modelIds) if (catalog) for (const model of catalog.models) ids.add(model.id) + for (const modelId of provider.disabledModelIds) ids.delete(modelId) if (ids.size === 0) continue groups.push({ providerId: provider.id, diff --git a/src/lib/session-settings.test.ts b/src/lib/session-settings.test.ts index 086ae347128..c343ae14e90 100644 --- a/src/lib/session-settings.test.ts +++ b/src/lib/session-settings.test.ts @@ -354,3 +354,17 @@ describe("MiniMax Code ACP model settings", () => { ]) }) }) + +it("shows a filtered current model without offering it again", () => { + const setting = choice( + build([ + select("model", "Model", "model", "relay/disabled", ["relay/allowed"]), + ])[0] + ) + expect(setting.currentLabel).toBe("relay/disabled") + expect( + setting.groups.flatMap((group) => + group.options.map((option) => option.value) + ) + ).toEqual(["relay/allowed"]) +}) From 62c139eda83de30f774cf68e91acd217b004ff28 Mon Sep 17 00:00:00 2001 From: xy Date: Tue, 3 Nov 2026 13:47:29 +0800 Subject: [PATCH 2/2] fix(opencode): narrow disabled model filtering --- src-tauri/src/acp/connection.rs | 143 +------------------ src-tauri/src/acp/mod.rs | 1 - src-tauri/src/acp/opencode_models.rs | 206 --------------------------- src-tauri/src/acp/session_state.rs | 5 +- src/lib/opencode-connect.test.ts | 14 ++ src/lib/opencode-connect.ts | 11 +- 6 files changed, 24 insertions(+), 356 deletions(-) delete mode 100644 src-tauri/src/acp/opencode_models.rs diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 5c294bb5862..79d0cbd3801 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2760,10 +2760,6 @@ pub async fn spawn_agent_connection( initial_state.pi_enabled_model_patterns = crate::commands::acp::pi_enabled_model_patterns_for_env(&runtime_env, &launch_cwd); } - if agent_type == AgentType::OpenCode { - initial_state.opencode_hidden_models = - crate::acp::opencode_models::hidden_models_for_env(&runtime_env, &launch_cwd); - } // Fixed by the owner at creation; codex reads it from its launch // environment (`apply_codex_prompt_suggestion_policy`). let wants_prompt_suggestions = initial_state.wants_prompt_suggestions; @@ -4502,41 +4498,6 @@ fn filter_pi_enabled_model_options( .collect() } -fn filter_opencode_model_options( - hidden: &HashSet, - config_options: Vec, -) -> Vec { - if hidden.is_empty() { - return config_options; - } - config_options - .into_iter() - .map(|mut option| { - if !is_model_config_option(&option) { - return option; - } - let SessionConfigKind::Select(select) = &mut option.kind else { - return option; - }; - let keep = |row: &SessionConfigSelectOption| { - let value = row.value.to_string(); - !hidden.contains(value.split('#').next().unwrap_or(&value)) - }; - match &mut select.options { - SessionConfigSelectOptions::Ungrouped(options) => options.retain(&keep), - SessionConfigSelectOptions::Grouped(groups) => { - for group in groups.iter_mut() { - group.options.retain(&keep); - } - groups.retain(|group| !group.options.is_empty()); - } - _ => {} - } - option - }) - .collect() -} - async fn emit_session_config_options_values( state: &Arc>, emitter: &EventEmitter, @@ -4545,12 +4506,11 @@ async fn emit_session_config_options_values( // Drop what this launch froze BEFORE mapping, so every producer of this // event — establishment, the answer to a set, and the agent's own // `config_option_update` push — is filtered by one rule. - let (pinned, pi_enabled_model_patterns, opencode_hidden_models) = { + let (pinned, pi_enabled_model_patterns) = { let guard = state.read().await; ( guard.env_pinned_config_option_ids.clone(), guard.pi_enabled_model_patterns.clone(), - guard.opencode_hidden_models.clone(), ) }; let visible = visible_config_options(&pinned, config_options); @@ -4558,7 +4518,6 @@ async fn emit_session_config_options_values( Some(patterns) => filter_pi_enabled_model_options(patterns, visible), None => visible, }; - let visible = filter_opencode_model_options(&opencode_hidden_models, visible); emit_with_state( state, emitter, @@ -42603,106 +42562,6 @@ mod tests { ); } - #[tokio::test] - async fn opencode_models_are_filtered_in_events_and_session_snapshots() { - let mut initial = SessionState::new( - "opencode-model-filter".into(), - AgentType::OpenCode, - None, - "w".into(), - None, - ); - initial.opencode_hidden_models = HashSet::from([ - "relay/hidden".into(), - "relay/disabled".into(), - ]); - let state = Arc::new(RwLock::new(initial)); - let options: Vec = serde_json::from_value(serde_json::json!([ - { - "type": "select", "id": "model", "name": "Model", "category": "model", - "currentValue": "relay/hidden", - "options": [ - {"value":"relay/hidden", "name":"Hidden"}, - {"value":"relay/disabled#high", "name":"Disabled variant"}, - {"value":"other/hidden", "name":"Other provider"}, - {"value":"relay/allowed", "name":"Allowed"} - ] - }, - { - "type": "select", "id": "effort", "name": "Effort", - "currentValue": "relay/disabled", - "options": [{"value":"relay/disabled", "name":"Unrelated option"}] - } - ])) - .unwrap(); - // Repeated emissions cover establishment, setter responses and pushes. - for _ in 0..2 { - emit_session_config_options_values(&state, &EventEmitter::Noop, options.clone()).await; - let guard = state.read().await; - let options = guard.config_options.as_ref().unwrap(); - let SessionConfigKindInfo::Select(model) = &options[0].kind else { - panic!("expected model selector"); - }; - assert_eq!(model.current_value, "relay/hidden"); - assert_eq!( - model.options.iter().map(|row| row.value.as_str()).collect::>(), - ["other/hidden", "relay/allowed"] - ); - let SessionConfigKindInfo::Select(effort) = &options[1].kind else { - panic!("expected effort selector"); - }; - assert_eq!(effort.options.len(), 1); - let events = guard.recent_events_after(0).unwrap(); - let AcpEvent::SessionConfigOptions { - config_options: emitted, - } = &events.last().unwrap().payload - else { - panic!("expected config option event"); - }; - assert_eq!(emitted, options); - assert_eq!(guard.to_snapshot().config_options.as_ref(), Some(options)); - } - } - - #[test] - fn opencode_models_drop_empty_groups_and_keep_the_selector_when_all_hidden() { - let options: Vec = serde_json::from_value(serde_json::json!([{ - "type": "select", "id": "custom-model", "category": "model", "name": "Model", - "currentValue": "relay/disabled", - "options": [ - {"group":"relay", "name":"Relay", "options":[ - {"value":"relay/disabled", "name":"Disabled"} - ]}, - {"group":"other", "name":"Other", "options":[ - {"value":"other/allowed", "name":"Allowed"} - ]} - ] - }])) - .unwrap(); - let hidden = HashSet::from(["relay/disabled".into()]); - let filtered = filter_opencode_model_options(&hidden, options); - let SessionConfigKind::Select(select) = &filtered[0].kind else { - panic!("expected model selector"); - }; - let SessionConfigSelectOptions::Grouped(groups) = &select.options else { - panic!("expected grouped options"); - }; - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].options[0].value.to_string(), "other/allowed"); - - let filtered = filter_opencode_model_options( - &HashSet::from(["other/allowed".into()]), - filtered, - ); - let SessionConfigKind::Select(select) = &filtered[0].kind else { - panic!("expected model selector"); - }; - let SessionConfigSelectOptions::Grouped(groups) = &select.options else { - panic!("expected grouped options"); - }; - assert!(groups.is_empty()); - } - fn pi_model_selector_options() -> Vec { serde_json::from_value(serde_json::json!([ { diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index de876b994c1..cd9fcb33b3a 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -41,7 +41,6 @@ pub mod opencode_catalog; pub(crate) mod opencode_config; pub(crate) mod opencode_credentials; pub mod opencode_launch; -pub(crate) mod opencode_models; pub mod opencode_plugins; pub mod plan_approval; pub mod preflight; diff --git a/src-tauri/src/acp/opencode_models.rs b/src-tauri/src/acp/opencode_models.rs deleted file mode 100644 index b3c06ada692..00000000000 --- a/src-tauri/src/acp/opencode_models.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::collections::{BTreeMap, HashSet}; -use std::path::Path; - -use serde_json::Value; - -use crate::parsers::AgentEnv; - -use super::opencode_config::{self, CONFIG_FILE_NAMES}; - -#[derive(Default)] -struct ProviderFilter { - disabled: BTreeMap, -} - -#[derive(Default)] -struct ModelFilters { - providers: BTreeMap, -} - -impl ModelFilters { - fn apply(&mut self, config: &Value) { - let native = config.get("providers").and_then(Value::as_object); - for key in ["provider", "providers"] { - let Some(blocks) = config.get(key).and_then(Value::as_object) else { - continue; - }; - for (id, block) in blocks { - if !block.is_object() - || (key == "provider" - && native - .and_then(|blocks| blocks.get(id)) - .is_some_and(Value::is_object)) - { - continue; - } - let filter = self.providers.entry(id.clone()).or_default(); - if let Some(models) = block.get("models").and_then(Value::as_object) { - for (model_id, model) in models { - if let Some(disabled) = model.get("disabled").and_then(Value::as_bool) { - filter.disabled.insert(model_id.clone(), disabled); - } - } - } - } - } - } - - fn read_file(&mut self, path: &Path) { - if let Ok(text) = std::fs::read_to_string(path) { - if let Ok(config) = opencode_config::parse(&text) { - self.apply(&config); - } - } - } - - fn read_dir(&mut self, dir: &Path) { - for name in CONFIG_FILE_NAMES { - self.read_file(&dir.join(name)); - } - } - - fn hidden_models(self) -> HashSet { - let mut hidden = HashSet::new(); - for (provider_id, filter) in self.providers { - hidden.extend( - filter - .disabled - .iter() - .filter_map(|(id, disabled)| disabled.then_some(id)) - .map(|id| format!("{provider_id}/{id}")), - ); - } - hidden - } -} - -/// Read only model visibility rules, in OpenCode's global → direct project → -/// .opencode project → system order. No credential substitution is needed. -pub(crate) fn hidden_models_for_env( - runtime_env: &BTreeMap, - working_dir: &Path, -) -> HashSet { - let env = AgentEnv::from_overrides(runtime_env.clone()); - let global = opencode_config::config_dir_for_env(&env); - let system = if cfg!(target_os = "macos") { - Some(std::path::PathBuf::from( - "/Library/Application Support/opencode", - )) - } else if cfg!(windows) { - env.var_os("ProgramData") - .map(std::path::PathBuf::from) - .map(|dir| dir.join("opencode")) - } else { - Some(std::path::PathBuf::from("/etc/opencode")) - }; - let mut filters = ModelFilters::default(); - if let Some(dir) = &global { - filters.read_dir(dir); - } - let ancestors: Vec<_> = - if global.as_deref() == Some(working_dir) || system.as_deref() == Some(working_dir) { - Vec::new() - } else { - working_dir.ancestors().collect() - }; - for dir in ancestors.iter().rev() { - if global.as_deref() != Some(*dir) && system.as_deref() != Some(*dir) { - filters.read_dir(dir); - } - } - for dir in ancestors.iter().rev() { - let dir = dir.join(".opencode"); - if global.as_ref() != Some(&dir) && system.as_ref() != Some(&dir) { - filters.read_dir(&dir); - } - } - if let Some(dir) = &system { - filters.read_dir(dir); - } - filters.hidden_models() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn disabled_models_are_provider_scoped_and_ignore_blacklist() { - let mut filters = ModelFilters::default(); - filters.apply(&serde_json::json!({ - "providers": { "relay": { - "blacklist": ["old", "vendor/hidden"], - "models": { - "disabled": {"disabled": true}, - "enabled": {"disabled": false}, - "alias": {"modelID": "old"} - } - }} - })); - assert_eq!( - filters.hidden_models(), - HashSet::from(["relay/disabled".into()]) - ); - } - - #[test] - fn native_blocks_win_and_later_layers_can_reenable_a_model() { - let mut filters = ModelFilters::default(); - filters.apply(&serde_json::json!({ - "provider": {"relay": {"blacklist": ["legacy"]}}, - "providers": {"relay": { - "blacklist": ["native"], - "models": {"old": {"disabled": true}, "hidden": {"disabled": true}} - }} - })); - filters.apply(&serde_json::json!({"providers": {"relay": { - "blacklist": [], "models": {"old": {"disabled": false}} - }}})); - assert_eq!( - filters.hidden_models(), - HashSet::from(["relay/hidden".into()]) - ); - } - - #[test] - fn jsonc_layers_merge_visibility_without_losing_unmentioned_models() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("opencode.json"), - r#"{"providers":{"relay":{"blacklist":["old"],"models":{ - "disabled":{"disabled":true},"enabled":{"disabled":true} - }}}}"#, - ) - .unwrap(); - std::fs::write( - dir.path().join("opencode.jsonc"), - r#"{ // later file overrides individual fields - "providers":{"relay":{"blacklist":["new"],"models":{ - "enabled":{"disabled":false}, - }}} - }"#, - ) - .unwrap(); - let mut filters = ModelFilters::default(); - filters.read_dir(dir.path()); - assert_eq!( - filters.hidden_models(), - HashSet::from(["relay/disabled".into()]) - ); - } - - #[test] - fn blacklist_in_later_layers_does_not_change_disabled_models() { - let mut filters = ModelFilters::default(); - filters.apply(&serde_json::json!({"providers": {"relay": { - "blacklist": ["old"], "models": {"disabled": {"disabled": true}} - }}})); - filters.apply(&serde_json::json!({"provider": {"relay": { - "blacklist": ["new"] - }}})); - assert_eq!( - filters.hidden_models(), - HashSet::from(["relay/disabled".into()]) - ); - } -} diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index a7140275789..74dbd534a93 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -1,7 +1,7 @@ //! 会话级状态结构。后端权威:流式累积、in-flight tool calls、待处理 permission 等 //! 全部住在这里。Phase 2 的 snapshot 端点直接从此处读取 live 部分。 -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::Arc; @@ -472,8 +472,6 @@ pub struct SessionState { /// /// Backend-internal — not serialized, not carried on `to_snapshot()`. pub pi_enabled_model_patterns: Option>, - /// OpenCode model IDs hidden by model disabled flags. - pub opencode_hidden_models: HashSet, pub prompt_capabilities: Option, pub fork_supported: bool, pub available_commands: Vec, @@ -909,7 +907,6 @@ impl SessionState { env_pinned_config_option_ids: Vec::new(), trae_pat_sign_in: false, pi_enabled_model_patterns: None, - opencode_hidden_models: HashSet::new(), prompt_capabilities: None, fork_supported: false, available_commands: Vec::new(), diff --git a/src/lib/opencode-connect.test.ts b/src/lib/opencode-connect.test.ts index 261449aa641..d2cd6fd77f0 100644 --- a/src/lib/opencode-connect.test.ts +++ b/src/lib/opencode-connect.test.ts @@ -593,6 +593,20 @@ describe("integrations and the panel view", () => { expect(buildConnectedModelOptions({ connected, catalog })).toEqual([]) } ) + + it("keeps models a 1.x provider block marks disabled, as OpenCode 2 does", () => { + const connected = buildConnectedProviders({ + config: { + provider: { relay: { models: { a: {}, b: { disabled: true } } } }, + }, + integrations: [], + catalog: [], + }) + const groups = buildConnectedModelOptions({ connected, catalog: [] }) + expect(groups.flatMap((group) => group.models.map((m) => m.value))).toEqual( + ["relay/a", "relay/b"] + ) + }) }) describe("custom provider ids and labels", () => { diff --git a/src/lib/opencode-connect.ts b/src/lib/opencode-connect.ts index 207981f5929..d67236d8136 100644 --- a/src/lib/opencode-connect.ts +++ b/src/lib/opencode-connect.ts @@ -702,9 +702,14 @@ export function buildConnectedProviders(params: { baseUrl: block?.baseUrl ?? "", npm: block?.npm ?? "", modelIds: block?.modelIds ?? [], - disabledModelIds: Object.values(block?.models ?? {}) - .filter((model) => model.disabled) - .map((model) => model.id), + // OpenCode 2 reads `disabled` on 2.x blocks only; a 1.x block's + // model-level `disabled` is dropped when it migrates the block. + disabledModelIds: + block?.shape === "native" + ? Object.values(block.models) + .filter((model) => model.disabled) + .map((model) => model.id) + : [], }) } result.sort(