feat(agent): auto-import Zode providers as custom models
Read `~/.zode/config.json` on startup (native/daemon) and merge every Zode `(provider, model)` pair into agent_settings.builtin_agents as a BuiltinAgentConfig, so providers already configured in the Zode CLI show up as OpenPencil custom models without re-entering keys. Anthropic vs OpenAI-compat is chosen by the provider `type`; one custom model per model id (provider name qualified by model when a provider has several). Best-effort + idempotent: missing/malformed file is a silent no-op, and add_builtin_agent_config backend-dedupes so re-running each launch adds nothing new. Imported agents are tracked in imported_agent_ids and excluded from settings.json persistence — Zode's config stays the single source of truth and its API keys are never silently duplicated onto a second on-disk location (they're re-imported every launch).
This commit is contained in:
parent
8cd03adacd
commit
14bbb0c409
|
|
@ -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<BuiltinAgentPresetKey>,
|
||||
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<String>,
|
||||
pub acp_agents: Vec<AcpAgentConfig>,
|
||||
pub acp_agent_draft: Option<AcpAgentConfig>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::<SettingsPayload>(&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::<SettingsPayload>(&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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
292
crates/op-host-services/src/zode_import.rs
Normal file
292
crates/op-host-services/src/zode_import.rs
Normal file
|
|
@ -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<String, ZodeProvider>,
|
||||
}
|
||||
|
||||
#[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<String, serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// Resolve `~/.zode/config.json`. `None` when no home dir is known.
|
||||
fn zode_config_path() -> Option<PathBuf> {
|
||||
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::<ZodeConfig>(&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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue