diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 786d246..b6a9344 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -4487,6 +4487,31 @@ command = "idea-mcp" ); } + #[test] + fn opencode_provider_config_json_zai_is_cache_independent() { + let custom = domain::profile::CustomProviderConfig::new( + "@ai-sdk/openai-compatible", + "https://api.z.ai/api/paas/v4", + Some("GLM-5.1".to_owned()), + ) + .unwrap(); + let config = + OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref")) + .unwrap() + .with_custom(custom); + let body = opencode_provider_config_json(&config, "secret", "/project", None, None); + assert_eq!(body["model"], "zai/glm-5.1"); + assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible"); + assert_eq!( + body["provider"]["zai"]["options"]["baseURL"], + "https://api.z.ai/api/paas/v4" + ); + assert_eq!( + body["provider"]["zai"]["models"]["glm-5.1"]["name"], + "GLM-5.1" + ); + } + // ---- permission projection wiring (ticket #94) ----------------------- /// Builds an [`EffectivePermissions`] with the given fallback posture (only @@ -4515,8 +4540,7 @@ command = "idea-mcp" .unwrap(); use domain::permission::Posture; - let allowed = - opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow))); + let allowed = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow))); assert_eq!(allowed["permission"]["bash"], "allow"); assert_eq!(allowed["permission"]["edit"], "allow"); diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 944394f..9035c68 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -29,19 +29,19 @@ pub use catalogue::{ reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, }; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; -pub use provider_catalogue::{ - opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput, - OpenCodeProviderCatalogEntry, -}; pub use lifecycle::{ - ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, - CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, - InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, - ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext, - UpdateAgentContextInput, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, AGENT_MEMORY_RECALL_BUDGET, - LIVE_STATE_INJECT_MAX, resolve_opencode_mcp_timeout_ms, + resolve_opencode_mcp_timeout_ms, ChangeAgentProfile, ChangeAgentProfileInput, + ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, + DeleteAgent, DeleteAgentInput, HandoffProvider, InjectedLiveRow, LaunchAgent, LaunchAgentInput, + LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LiveStateLeanProvider, + McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext, + ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode, + StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, +}; +pub use provider_catalogue::{ + opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders, + ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry, }; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/agent/provider_catalogue.rs b/crates/application/src/agent/provider_catalogue.rs index 315c866..8f0a2ee 100644 --- a/crates/application/src/agent/provider_catalogue.rs +++ b/crates/application/src/agent/provider_catalogue.rs @@ -21,6 +21,10 @@ use std::path::PathBuf; use serde::Deserialize; +use domain::profile::CustomProviderConfig; + +const BUILT_IN_PROVIDERS: [&str; 3] = ["anthropic", "openai", "openrouter"]; + /// One entry of the OpenCode cloud-provider catalogue. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenCodeProviderCatalogEntry { @@ -30,6 +34,11 @@ pub struct OpenCodeProviderCatalogEntry { pub display_name: String, /// Model identifiers this provider serves, offered for selection. pub models: Vec, + /// Runtime metadata kept inside the application layer. It is deliberately + /// not exposed by the existing frontend DTO. + pub(crate) npm: Option, + pub(crate) api: Option, + pub(crate) model_labels: BTreeMap, } /// The original lot-B3 catalogue: three well-known providers, used whenever @@ -44,6 +53,9 @@ fn static_fallback_catalogue() -> Vec { "claude-opus-4-8".to_owned(), "claude-haiku-4-5-20251001".to_owned(), ], + npm: None, + api: None, + model_labels: BTreeMap::new(), }, OpenCodeProviderCatalogEntry { provider_id: "openrouter".to_owned(), @@ -53,11 +65,17 @@ fn static_fallback_catalogue() -> Vec { "openai/gpt-5".to_owned(), "google/gemini-3-pro".to_owned(), ], + npm: None, + api: None, + model_labels: BTreeMap::new(), }, OpenCodeProviderCatalogEntry { provider_id: "openai".to_owned(), display_name: "OpenAI".to_owned(), models: vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()], + npm: None, + api: None, + model_labels: BTreeMap::new(), }, ] } @@ -76,7 +94,7 @@ fn static_fallback_catalogue() -> Vec { /// **permanent** compatibility shim tied to the upstream bug, not a /// workaround for "OpenCode never launched": even on a machine where OpenCode /// runs regularly, this is the only path that matches its actual output. -fn opencode_models_cache_path() -> Option { +pub fn opencode_models_cache_path() -> Option { let cache_home = std::env::var_os("XDG_CACHE_HOME") .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?; @@ -89,14 +107,18 @@ struct ModelsDevProvider { #[serde(default)] name: Option, #[serde(default)] - models: BTreeMap, + npm: Option, + #[serde(default)] + api: Option, + #[serde(default)] + models: BTreeMap, } -/// Per-model payload: only the key (model id) is used, so the value is parsed -/// but its fields are ignored (`deny_unknown_fields` is deliberately absent — -/// the cache carries many fields we don't need, e.g. pricing, limits). #[derive(Debug, Deserialize)] -struct ModelsDevModelIgnored {} +struct ModelsDevModel { + #[serde(default)] + name: Option, +} /// Parses a models.dev-shaped cache payload into catalogue entries. /// @@ -109,6 +131,17 @@ fn parse_models_dev_cache(bytes: &[u8]) -> Option = providers .into_iter() .filter_map(|(provider_id, provider)| { + let model_labels = provider + .models + .iter() + .filter_map(|(id, model)| { + model + .name + .as_ref() + .filter(|name| !name.trim().is_empty()) + .map(|name| (id.clone(), name.clone())) + }) + .collect(); let models: Vec = provider.models.into_keys().collect(); if models.is_empty() { return None; @@ -121,6 +154,9 @@ fn parse_models_dev_cache(bytes: &[u8]) -> Option Vec { catalogue_from_cache_bytes(bytes) } +/// Builds the persisted standalone-provider metadata for a catalogue provider. +/// +/// OpenCode only knows the three allowlisted providers without a registry +/// cache. Every other provider must therefore carry enough configuration to +/// remain runnable after the host cache disappears. +pub(crate) fn catalogue_custom_provider( + provider_id: &str, + model_id: &str, +) -> Result, String> { + catalogue_custom_provider_from(opencode_provider_catalogue(), provider_id, model_id) +} + +fn catalogue_custom_provider_from( + catalogue: Vec, + provider_id: &str, + model_id: &str, +) -> Result, String> { + if BUILT_IN_PROVIDERS.contains(&provider_id) { + return Ok(None); + } + + let provider = catalogue + .into_iter() + .find(|entry| entry.provider_id == provider_id) + .ok_or_else(|| format!("provider `{provider_id}` is absent from models.dev"))?; + if !provider.models.iter().any(|model| model == model_id) { + return Err(format!( + "model `{model_id}` is absent from models.dev provider `{provider_id}`" + )); + } + + let npm = required_metadata(provider.npm, "npm", provider_id)?; + let api = required_metadata(provider.api, "api", provider_id)?; + if !(api.starts_with("http://") || api.starts_with("https://")) { + return Err(format!( + "models.dev provider `{provider_id}` has an invalid api URL" + )); + } + let label = provider + .model_labels + .get(model_id) + .cloned() + .filter(|label| !label.trim().is_empty()) + .ok_or_else(|| { + format!("model `{model_id}` of models.dev provider `{provider_id}` has no label") + })?; + + CustomProviderConfig::new(npm, api, Some(label)) + .map(Some) + .map_err(|error| error.to_string()) +} + +fn required_metadata( + value: Option, + field: &str, + provider_id: &str, +) -> Result { + value + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("models.dev provider `{provider_id}` has no valid `{field}`")) +} + /// Use case exposing [`opencode_provider_catalogue`] to the driving side. No /// port: reading the OpenCode cache is a best-effort local convenience read, /// never a failure mode the driving side needs to react to (see the @@ -277,6 +375,73 @@ mod tests { assert_eq!(models, vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()]); } + #[test] + fn models_dev_parser_keeps_runtime_metadata_and_model_labels() { + let json = br#"{ + "zai": { + "name": "Z.AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.z.ai/api/paas/v4", + "models": { + "glm-5.1": {"name": "GLM-5.1"} + } + } + }"#; + let entries = parse_models_dev_cache(json).unwrap(); + let zai = &entries[0]; + assert_eq!(zai.npm.as_deref(), Some("@ai-sdk/openai-compatible")); + assert_eq!(zai.api.as_deref(), Some("https://api.z.ai/api/paas/v4")); + assert_eq!( + zai.model_labels.get("glm-5.1").map(String::as_str), + Some("GLM-5.1") + ); + } + + #[test] + fn zai_is_materialized_as_a_standalone_provider() { + let catalogue = parse_models_dev_cache( + br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"https://api.z.ai/api/paas/v4","models":{"glm-5.1":{"name":"GLM-5.1"}}}}"#, + ) + .unwrap(); + let custom = catalogue_custom_provider_from(catalogue, "zai", "glm-5.1") + .unwrap() + .unwrap(); + assert_eq!(custom.npm, "@ai-sdk/openai-compatible"); + assert_eq!(custom.base_url, "https://api.z.ai/api/paas/v4"); + assert_eq!(custom.display_name.as_deref(), Some("GLM-5.1")); + } + + #[test] + fn built_ins_do_not_require_models_dev_runtime_metadata() { + assert_eq!( + catalogue_custom_provider_from(Vec::new(), "anthropic", "claude-sonnet-5").unwrap(), + None + ); + } + + #[test] + fn standalone_provider_rejects_missing_or_invalid_runtime_metadata() { + let missing_label = parse_models_dev_cache( + br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"https://api.z.ai/api/paas/v4","models":{"glm-5.1":{}}}}"#, + ) + .unwrap(); + assert!( + catalogue_custom_provider_from(missing_label, "zai", "glm-5.1") + .unwrap_err() + .contains("has no label") + ); + + let invalid_api = parse_models_dev_cache( + br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"api.z.ai","models":{"glm-5.1":{"name":"GLM-5.1"}}}}"#, + ) + .unwrap(); + assert!( + catalogue_custom_provider_from(invalid_api, "zai", "glm-5.1") + .unwrap_err() + .contains("invalid api URL") + ); + } + #[test] fn catalogue_from_cache_bytes_defaults_display_name_to_provider_id() { let json = br#"{"mystery": {"models": {"m1": {}}}}"#.to_vec(); diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 99e39df..5cfd809 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -22,6 +22,7 @@ use domain::profile::{ use crate::error::AppError; use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; +use super::provider_catalogue::catalogue_custom_provider; // --------------------------------------------------------------------------- // DetectProfiles @@ -361,6 +362,12 @@ impl SaveOpenCodeProviderProfile { &self, input: SaveOpenCodeProviderProfileInput, ) -> Result { + let custom = match input.custom { + Some(custom) => Some(custom), + None => catalogue_custom_provider(&input.provider_id, &input.model) + .map_err(AppError::Invalid)?, + }; + let secret_ref = input .profile .opencode_provider @@ -370,10 +377,9 @@ impl SaveOpenCodeProviderProfile { self.secret_store.put(&secret_ref, &input.api_key).await?; - let mut provider = - OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref) - .map_err(|e| AppError::Invalid(e.to_string()))?; - if let Some(custom) = input.custom { + let mut provider = OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref) + .map_err(|e| AppError::Invalid(e.to_string()))?; + if let Some(custom) = custom { provider = provider.with_custom(custom); } diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index c357bef..4dad78c 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -611,18 +611,14 @@ fn parent_dir(base: &ProjectPath, rel: &str) -> String { #[cfg(test)] mod opencode_provider_config_json_tests { - use domain::profile::CustomProviderConfig; use domain::ports::SecretRef; + use domain::profile::CustomProviderConfig; use super::*; fn known_provider_config() -> OpenCodeProviderConfig { - OpenCodeProviderConfig::new( - "anthropic", - "claude-sonnet-5", - SecretRef::new("secret-ref"), - ) - .unwrap() + OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-ref")) + .unwrap() } fn custom_provider_config() -> OpenCodeProviderConfig { @@ -674,6 +670,30 @@ mod opencode_provider_config_json_tests { ); } + #[test] + fn zai_provider_is_rendered_as_a_cache_independent_provider() { + let custom = CustomProviderConfig::new( + "@ai-sdk/openai-compatible", + "https://api.z.ai/api/paas/v4", + Some("GLM-5.1".to_owned()), + ) + .unwrap(); + let config = OpenCodeProviderConfig::new("zai", "glm-5.1", SecretRef::new("ref")) + .unwrap() + .with_custom(custom); + let body = opencode_provider_config_json(&config, "secret", "/project", None, None); + assert_eq!(body["model"], "zai/glm-5.1"); + assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible"); + assert_eq!( + body["provider"]["zai"]["options"]["baseURL"], + "https://api.z.ai/api/paas/v4" + ); + assert_eq!( + body["provider"]["zai"]["models"]["glm-5.1"]["name"], + "GLM-5.1" + ); + } + // ---- permission projection wiring (ticket #94) ----------------------- /// Builds an [`EffectivePermissions`] with the given fallback posture (only