Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -4498,6 +4502,41 @@ fn filter_pi_enabled_model_options(
.collect()
}

fn filter_opencode_model_options(
hidden: &HashSet<String>,
config_options: Vec<SessionConfigOption>,
) -> Vec<SessionConfigOption> {
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<RwLock<SessionState>>,
emitter: &EventEmitter,
Expand All @@ -4506,18 +4545,20 @@ 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);
let visible = match pi_enabled_model_patterns.as_deref() {
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,
Expand Down Expand Up @@ -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<SessionConfigOption> = 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::<Vec<_>>(),
["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<SessionConfigOption> = 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<SessionConfigOption> {
serde_json::from_value(serde_json::json!([
{
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
206 changes: 206 additions & 0 deletions src-tauri/src/acp/opencode_models.rs
Original file line number Diff line number Diff line change
@@ -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<String, bool>,
}

#[derive(Default)]
struct ModelFilters {
providers: BTreeMap<String, ProviderFilter>,
}

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<String> {
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<String, String>,
working_dir: &Path,
) -> HashSet<String> {
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()])
);
}
}
Loading
Loading