diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index f5673bcfc..ddc12cf75 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -10,7 +10,7 @@ //! model's backing-agent discriminator) and is re-exported here so //! both the chat layer and the settings layer share one definition. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::agent_settings_builtin_presets::{ builtin_agent_preset, infer_builtin_agent_preset, BuiltinAgentPresetKey, BUILTIN_AGENT_PRESETS, @@ -416,6 +416,13 @@ pub struct AgentSettings { pub builtin_preset_menu_scroll: jian_core::scroll::ScrollState, pub builtin_preset_menu_hover: Option, pub next_builtin_agent_id: u64, + /// Ids of `builtin_agents` that were auto-imported from an external + /// CLI config (e.g. Zode's `~/.zode/config.json`). Runtime-only — + /// NOT persisted. These agents are re-derived from their source file + /// on every launch, so persisting them would silently duplicate the + /// source's API keys into OpenPencil's own settings.json; the host's + /// save path skips any agent whose id is in this set. + pub imported_agent_ids: BTreeSet, pub acp_agents: Vec, pub acp_agent_draft: Option, pub next_acp_agent_id: u64, @@ -478,6 +485,7 @@ impl Default for AgentSettings { builtin_preset_menu_scroll: Default::default(), builtin_preset_menu_hover: None, next_builtin_agent_id: 1, + imported_agent_ids: BTreeSet::new(), acp_agents: Vec::new(), acp_agent_draft: None, next_acp_agent_id: 1, diff --git a/crates/op-host-services/src/lib.rs b/crates/op-host-services/src/lib.rs index ef2935e8c..a9e2ed09f 100644 --- a/crates/op-host-services/src/lib.rs +++ b/crates/op-host-services/src/lib.rs @@ -52,3 +52,4 @@ pub mod validation_providers; pub mod web_canvas_server; pub mod web_chat_standard; pub mod web_static; +pub mod zode_import; diff --git a/crates/op-host-services/src/settings_io.rs b/crates/op-host-services/src/settings_io.rs index 6c6387f7c..e568b184e 100644 --- a/crates/op-host-services/src/settings_io.rs +++ b/crates/op-host-services/src/settings_io.rs @@ -174,10 +174,15 @@ fn to_payload(state: &EditorState) -> SettingsPayload { auto_update_enabled: Some(eui.agent_settings.auto_update_enabled), experimental_features_enabled: Some(eui.agent_settings.experimental_features_enabled), connected: Some(eui.agent_settings.connected), + // Skip auto-imported (e.g. Zode) agents: their source file is the + // single source of truth and they're re-imported every launch, so + // persisting them would silently duplicate the source's API keys + // into this settings.json. builtin_agents: Some( eui.agent_settings .builtin_agents .iter() + .filter(|agent| !eui.agent_settings.imported_agent_ids.contains(&agent.id)) .map(builtin_agent_to_payload) .collect(), ), @@ -507,14 +512,19 @@ pub fn load(state: &mut EditorState) { if let Some(detected) = detect_system_locale() { state.editor_ui.locale = detected; } - let Some(path) = settings_path() else { return }; - let Ok(bytes) = std::fs::read(&path) else { - return; - }; - let Ok(payload) = serde_json::from_slice::(&bytes) else { - return; - }; - apply_payload(state, payload); + if let Some(path) = settings_path() { + if let Ok(bytes) = std::fs::read(&path) { + if let Ok(payload) = serde_json::from_slice::(&bytes) { + apply_payload(state, payload); + } + } + } + // Merge any Zode CLI providers (`~/.zode/config.json`) as built-in + // custom models. Runs AFTER the persisted agents load so the + // backend-dedupe sees them; best-effort and a no-op when Zode isn't + // configured. Must not be skipped by a missing OpenPencil settings + // file, so it sits outside the load above. + crate::zode_import::import_zode_builtin_agents(state); } /// Read the host OS's preferred locale (env-var driven, no extra diff --git a/crates/op-host-services/src/settings_io_tests.rs b/crates/op-host-services/src/settings_io_tests.rs index 6379c2d8e..8e4842eb0 100644 --- a/crates/op-host-services/src/settings_io_tests.rs +++ b/crates/op-host-services/src/settings_io_tests.rs @@ -1,5 +1,38 @@ use super::*; +#[test] +fn imported_agents_are_excluded_from_persistence() { + // A user-entered agent must persist; an auto-imported (e.g. Zode) + // agent must NOT, so its API key never lands in settings.json. + let mut state = EditorState::new(); + let manual = state + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Manual", "manual-key", "m1"); + let imported = state + .editor_ui + .agent_settings + .add_builtin_agent_with_defaults("Imported", "zode-key", "m2"); + state + .editor_ui + .agent_settings + .imported_agent_ids + .insert(imported.clone()); + + let payload = to_payload(&state); + let persisted = payload.builtin_agents.unwrap(); + let ids: Vec<_> = persisted.iter().map(|a| a.id.clone()).collect(); + assert!(ids.contains(&manual), "user-entered agent should persist"); + assert!( + !ids.contains(&imported), + "imported agent (and its key) must not be persisted" + ); + assert!( + persisted.iter().all(|a| a.api_key != "zode-key"), + "imported API key must never reach settings.json" + ); +} + #[test] fn connected_state_round_trips_through_payload() { // Connect Claude (0) + Gemini (4), leave the rest off. diff --git a/crates/op-host-services/src/zode_import.rs b/crates/op-host-services/src/zode_import.rs new file mode 100644 index 000000000..f6e7a5b5f --- /dev/null +++ b/crates/op-host-services/src/zode_import.rs @@ -0,0 +1,292 @@ +//! Auto-import Zode (`~/.zode/config.json`) provider configs as +//! OpenPencil built-in ("custom model") agents. +//! +//! The Zode CLI stores its LLM providers in `~/.zode/config.json`, each +//! carrying a protocol `type`, an API key, a base URL, and a set of +//! models. On startup we merge every `(provider, model)` pair into +//! `agent_settings.builtin_agents` so a user who already configured +//! providers in Zode gets the same custom models in OpenPencil without +//! re-entering keys. +//! +//! Best-effort and idempotent: a missing / malformed file is a silent +//! no-op, and re-running each launch dedupes against existing agents by +//! backend (`kind` + `api_key` + `model` + `base_url`) so no duplicates +//! accumulate. New Zode providers appear automatically; the trade-off is +//! that an imported agent deleted inside OpenPencil reappears next launch +//! because Zode still lists it. +//! +//! Imported agents are recorded in `agent_settings.imported_agent_ids` +//! and are NOT written to OpenPencil's own `settings.json`: Zode's config +//! stays the single source of truth for those keys (they're re-imported +//! every launch), so we never silently duplicate a Zode API key onto a +//! second on-disk location. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use op_editor_core::{BuiltinAgentKind, EditorState}; +use serde::Deserialize; + +/// Only the fields we map; everything else in the file (theme, images, +/// per-model pricing, the active `provider.model`, …) is ignored. +#[derive(Debug, Deserialize)] +struct ZodeConfig { + #[serde(default)] + providers: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ZodeProvider { + /// Protocol dialect — `"anthropic"` maps to the Anthropic backend, + /// anything else (openai / openai-compat / unset) to OpenAI-compat. + #[serde(default, rename = "type")] + kind: String, + #[serde(default)] + api_key: String, + #[serde(default)] + base_url: String, + /// Keyed by model id; the values (contextWindow / prices) carry + /// metadata OpenPencil doesn't model, so they're discarded. + #[serde(default)] + models: BTreeMap, +} + +/// Resolve `~/.zode/config.json`. `None` when no home dir is known. +fn zode_config_path() -> Option { + Some(dirs::home_dir()?.join(".zode").join("config.json")) +} + +/// Best-effort startup import: read + parse + merge. Silent no-op on a +/// missing home dir, missing file, or malformed JSON. +pub fn import_zode_builtin_agents(state: &mut EditorState) { + let Some(path) = zode_config_path() else { + return; + }; + let Ok(bytes) = std::fs::read(&path) else { + return; + }; + let Ok(config) = serde_json::from_slice::(&bytes) else { + return; + }; + import_from_config(state, &config); +} + +/// Merge parsed Zode providers into `builtin_agents`. Pure (no IO) so it +/// is unit-testable. Returns the number of newly-added agents. +fn import_from_config(state: &mut EditorState, config: &ZodeConfig) -> usize { + let settings = &mut state.editor_ui.agent_settings; + let before = settings.builtin_agents.len(); + for (provider_name, provider) in &config.providers { + let api_key = provider.api_key.trim(); + // A provider with no key or no models can't produce a usable + // custom model — skip it. + if api_key.is_empty() || provider.models.is_empty() { + continue; + } + let kind = match provider.kind.trim().to_ascii_lowercase().as_str() { + "anthropic" => BuiltinAgentKind::Anthropic, + _ => BuiltinAgentKind::OpenAiCompat, + }; + let base_url = { + let trimmed = provider.base_url.trim(); + if trimmed.is_empty() { + kind.default_base_url().to_string() + } else { + trimmed.to_string() + } + }; + // With multiple models the provider name alone is ambiguous, so + // qualify the card with the model id. + let multi = provider.models.len() > 1; + for model_id in provider.models.keys() { + let model = model_id.trim(); + if model.is_empty() { + continue; + } + let display_name = if multi { + format!("{provider_name} · {model}") + } else { + provider_name.clone() + }; + // `add_builtin_agent_config` backend-dedupes + assigns the + // id + infers the preset, so this stays idempotent across + // launches. Only tag an id as imported when a NEW agent was + // actually created — a dedupe hit against a user-entered + // agent returns that agent's id, which must stay persisted. + let before_len = settings.builtin_agents.len(); + let id = + settings.add_builtin_agent_config(display_name, api_key, model, kind, &base_url); + if settings.builtin_agents.len() > before_len { + settings.imported_agent_ids.insert(id); + } + } + } + let added = settings.builtin_agents.len() - before; + if added > 0 { + // New rows change which models the chat picker lists. + state.rebuild_chat_models(); + } + added +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(json: &str) -> ZodeConfig { + serde_json::from_str(json).expect("fixture parses") + } + + /// The real deepseek + LongCat shape → one agent per model, with the + /// provider name qualified by model id when a provider has >1 model. + #[test] + fn imports_one_agent_per_model() { + let config = parse( + r#"{ + "providers": { + "deepseek": { + "type": "anthropic", + "apiKey": "sk-deepseek", + "baseUrl": "https://api.deepseek.com/anthropic", + "models": { + "deepseek-v4-pro": {"contextWindow": 1000000, "inputPrice": 0.435}, + "deepseek-chat": {"contextWindow": 1000000, "inputPrice": 0.14} + } + }, + "LongCat": { + "type": "anthropic", + "apiKey": "ak-longcat", + "baseUrl": "https://api.longcat.chat/anthropic", + "models": {"LongCat-2.0": {"contextWindow": 1000000}} + } + } + }"#, + ); + let mut state = EditorState::new(); + let added = import_from_config(&mut state, &config); + assert_eq!(added, 3); + + let agents = &state.editor_ui.agent_settings.builtin_agents; + assert_eq!(agents.len(), 3); + + // deepseek has two models → qualified display names. + let ds_pro = agents + .iter() + .find(|a| a.model == "deepseek-v4-pro") + .expect("deepseek-v4-pro imported"); + assert_eq!(ds_pro.display_name, "deepseek · deepseek-v4-pro"); + assert_eq!(ds_pro.kind, BuiltinAgentKind::Anthropic); + assert_eq!(ds_pro.api_key, "sk-deepseek"); + assert_eq!(ds_pro.base_url, "https://api.deepseek.com/anthropic"); + assert!(ds_pro.enabled); + assert!(agents + .iter() + .any(|a| a.model == "deepseek-chat" && a.display_name == "deepseek · deepseek-chat")); + + // LongCat has one model → bare provider name. + let lc = agents + .iter() + .find(|a| a.model == "LongCat-2.0") + .expect("LongCat-2.0 imported"); + assert_eq!(lc.display_name, "LongCat"); + assert_eq!(lc.base_url, "https://api.longcat.chat/anthropic"); + } + + /// `type` drives the backend kind; unknown / missing → OpenAI-compat. + #[test] + fn kind_maps_from_type_field() { + let config = parse( + r#"{ + "providers": { + "anth": {"type": "anthropic", "apiKey": "k", "baseUrl": "https://a", "models": {"m": {}}}, + "oai": {"type": "openai", "apiKey": "k", "baseUrl": "https://o", "models": {"m": {}}}, + "custom": {"type": "whatever", "apiKey": "k", "baseUrl": "https://c", "models": {"m": {}}} + } + }"#, + ); + let mut state = EditorState::new(); + import_from_config(&mut state, &config); + let agents = &state.editor_ui.agent_settings.builtin_agents; + let kind_of = |name: &str| { + agents + .iter() + .find(|a| a.display_name == name) + .map(|a| a.kind) + .unwrap() + }; + assert_eq!(kind_of("anth"), BuiltinAgentKind::Anthropic); + assert_eq!(kind_of("oai"), BuiltinAgentKind::OpenAiCompat); + assert_eq!(kind_of("custom"), BuiltinAgentKind::OpenAiCompat); + } + + /// Empty base URL falls back to the kind's default endpoint. + #[test] + fn empty_base_url_falls_back_to_kind_default() { + let config = parse( + r#"{"providers": {"p": {"type": "openai", "apiKey": "k", "models": {"m": {}}}}}"#, + ); + let mut state = EditorState::new(); + import_from_config(&mut state, &config); + let agent = &state.editor_ui.agent_settings.builtin_agents[0]; + assert_eq!( + agent.base_url, + BuiltinAgentKind::OpenAiCompat.default_base_url() + ); + } + + /// Providers with no key or no models never produce an agent. + #[test] + fn skips_providers_without_key_or_models() { + let config = parse( + r#"{ + "providers": { + "nokey": {"type": "anthropic", "apiKey": "", "baseUrl": "https://a", "models": {"m": {}}}, + "nomodels": {"type": "anthropic", "apiKey": "k", "baseUrl": "https://a", "models": {}}, + "good": {"type": "anthropic", "apiKey": "k", "baseUrl": "https://a", "models": {"m": {}}} + } + }"#, + ); + let mut state = EditorState::new(); + let added = import_from_config(&mut state, &config); + assert_eq!(added, 1); + assert_eq!( + state.editor_ui.agent_settings.builtin_agents[0].display_name, + "good" + ); + } + + /// Re-running the import (every launch) adds nothing the second time. + #[test] + fn import_is_idempotent() { + let config = parse( + r#"{ + "providers": { + "deepseek": { + "type": "anthropic", + "apiKey": "sk-deepseek", + "baseUrl": "https://api.deepseek.com/anthropic", + "models": {"deepseek-v4-pro": {}, "deepseek-chat": {}} + } + } + }"#, + ); + let mut state = EditorState::new(); + let first = import_from_config(&mut state, &config); + let second = import_from_config(&mut state, &config); + assert_eq!(first, 2); + assert_eq!(second, 0); + assert_eq!(state.editor_ui.agent_settings.builtin_agents.len(), 2); + // Both imported agents are tagged so persistence can skip them. + assert_eq!(state.editor_ui.agent_settings.imported_agent_ids.len(), 2); + } + + /// An empty / provider-less config is a clean no-op. + #[test] + fn empty_config_adds_nothing() { + let config = parse(r#"{"providers": {}}"#); + let mut state = EditorState::new(); + assert_eq!(import_from_config(&mut state, &config), 0); + assert!(state.editor_ui.agent_settings.builtin_agents.is_empty()); + } +}