merge: integrate ticket 98 OpenCode provider fix

This commit is contained in:
2026-07-25 14:27:10 +02:00
5 changed files with 246 additions and 31 deletions

View File

@ -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) ----------------------- // ---- permission projection wiring (ticket #94) -----------------------
/// Builds an [`EffectivePermissions`] with the given fallback posture (only /// Builds an [`EffectivePermissions`] with the given fallback posture (only
@ -4515,8 +4540,7 @@ command = "idea-mcp"
.unwrap(); .unwrap();
use domain::permission::Posture; use domain::permission::Posture;
let allowed = let allowed = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)));
opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)));
assert_eq!(allowed["permission"]["bash"], "allow"); assert_eq!(allowed["permission"]["bash"], "allow");
assert_eq!(allowed["permission"]["edit"], "allow"); assert_eq!(allowed["permission"]["edit"], "allow");

View File

@ -29,19 +29,19 @@ pub use catalogue::{
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
}; };
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use provider_catalogue::{
opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput,
OpenCodeProviderCatalogEntry,
};
pub use lifecycle::{ pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, resolve_opencode_mcp_timeout_ms, ChangeAgentProfile, ChangeAgentProfileInput,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, DeleteAgent, DeleteAgentInput, HandoffProvider, InjectedLiveRow, LaunchAgent, LaunchAgentInput,
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LiveStateLeanProvider,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
UpdateAgentContextInput, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, AGENT_MEMORY_RECALL_BUDGET, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
LIVE_STATE_INJECT_MAX, resolve_opencode_mcp_timeout_ms, 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::{ pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -21,6 +21,10 @@ use std::path::PathBuf;
use serde::Deserialize; use serde::Deserialize;
use domain::profile::CustomProviderConfig;
const BUILT_IN_PROVIDERS: [&str; 3] = ["anthropic", "openai", "openrouter"];
/// One entry of the OpenCode cloud-provider catalogue. /// One entry of the OpenCode cloud-provider catalogue.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenCodeProviderCatalogEntry { pub struct OpenCodeProviderCatalogEntry {
@ -30,6 +34,11 @@ pub struct OpenCodeProviderCatalogEntry {
pub display_name: String, pub display_name: String,
/// Model identifiers this provider serves, offered for selection. /// Model identifiers this provider serves, offered for selection.
pub models: Vec<String>, pub models: Vec<String>,
/// Runtime metadata kept inside the application layer. It is deliberately
/// not exposed by the existing frontend DTO.
pub(crate) npm: Option<String>,
pub(crate) api: Option<String>,
pub(crate) model_labels: BTreeMap<String, String>,
} }
/// The original lot-B3 catalogue: three well-known providers, used whenever /// The original lot-B3 catalogue: three well-known providers, used whenever
@ -44,6 +53,9 @@ fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
"claude-opus-4-8".to_owned(), "claude-opus-4-8".to_owned(),
"claude-haiku-4-5-20251001".to_owned(), "claude-haiku-4-5-20251001".to_owned(),
], ],
npm: None,
api: None,
model_labels: BTreeMap::new(),
}, },
OpenCodeProviderCatalogEntry { OpenCodeProviderCatalogEntry {
provider_id: "openrouter".to_owned(), provider_id: "openrouter".to_owned(),
@ -53,11 +65,17 @@ fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
"openai/gpt-5".to_owned(), "openai/gpt-5".to_owned(),
"google/gemini-3-pro".to_owned(), "google/gemini-3-pro".to_owned(),
], ],
npm: None,
api: None,
model_labels: BTreeMap::new(),
}, },
OpenCodeProviderCatalogEntry { OpenCodeProviderCatalogEntry {
provider_id: "openai".to_owned(), provider_id: "openai".to_owned(),
display_name: "OpenAI".to_owned(), display_name: "OpenAI".to_owned(),
models: vec!["gpt-5".to_owned(), "gpt-5-mini".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<OpenCodeProviderCatalogEntry> {
/// **permanent** compatibility shim tied to the upstream bug, not a /// **permanent** compatibility shim tied to the upstream bug, not a
/// workaround for "OpenCode never launched": even on a machine where OpenCode /// workaround for "OpenCode never launched": even on a machine where OpenCode
/// runs regularly, this is the only path that matches its actual output. /// runs regularly, this is the only path that matches its actual output.
fn opencode_models_cache_path() -> Option<PathBuf> { pub fn opencode_models_cache_path() -> Option<PathBuf> {
let cache_home = std::env::var_os("XDG_CACHE_HOME") let cache_home = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?; .or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
@ -89,14 +107,18 @@ struct ModelsDevProvider {
#[serde(default)] #[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(default)] #[serde(default)]
models: BTreeMap<String, ModelsDevModelIgnored>, npm: Option<String>,
#[serde(default)]
api: Option<String>,
#[serde(default)]
models: BTreeMap<String, ModelsDevModel>,
} }
/// 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)] #[derive(Debug, Deserialize)]
struct ModelsDevModelIgnored {} struct ModelsDevModel {
#[serde(default)]
name: Option<String>,
}
/// Parses a models.dev-shaped cache payload into catalogue entries. /// Parses a models.dev-shaped cache payload into catalogue entries.
/// ///
@ -109,6 +131,17 @@ fn parse_models_dev_cache(bytes: &[u8]) -> Option<Vec<OpenCodeProviderCatalogEnt
let mut entries: Vec<OpenCodeProviderCatalogEntry> = providers let mut entries: Vec<OpenCodeProviderCatalogEntry> = providers
.into_iter() .into_iter()
.filter_map(|(provider_id, provider)| { .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<String> = provider.models.into_keys().collect(); let models: Vec<String> = provider.models.into_keys().collect();
if models.is_empty() { if models.is_empty() {
return None; return None;
@ -121,6 +154,9 @@ fn parse_models_dev_cache(bytes: &[u8]) -> Option<Vec<OpenCodeProviderCatalogEnt
provider_id, provider_id,
display_name, display_name,
models, models,
npm: provider.npm,
api: provider.api,
model_labels,
}) })
}) })
.collect(); .collect();
@ -152,6 +188,68 @@ pub fn opencode_provider_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
catalogue_from_cache_bytes(bytes) 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<Option<CustomProviderConfig>, String> {
catalogue_custom_provider_from(opencode_provider_catalogue(), provider_id, model_id)
}
fn catalogue_custom_provider_from(
catalogue: Vec<OpenCodeProviderCatalogEntry>,
provider_id: &str,
model_id: &str,
) -> Result<Option<CustomProviderConfig>, 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<String>,
field: &str,
provider_id: &str,
) -> Result<String, String> {
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 /// Use case exposing [`opencode_provider_catalogue`] to the driving side. No
/// port: reading the OpenCode cache is a best-effort local convenience read, /// 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 /// 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()]); 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] #[test]
fn catalogue_from_cache_bytes_defaults_display_name_to_provider_id() { fn catalogue_from_cache_bytes_defaults_display_name_to_provider_id() {
let json = br#"{"mystery": {"models": {"m1": {}}}}"#.to_vec(); let json = br#"{"mystery": {"models": {"m1": {}}}}"#.to_vec();

View File

@ -22,6 +22,7 @@ use domain::profile::{
use crate::error::AppError; use crate::error::AppError;
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
use super::provider_catalogue::catalogue_custom_provider;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DetectProfiles // DetectProfiles
@ -361,6 +362,12 @@ impl SaveOpenCodeProviderProfile {
&self, &self,
input: SaveOpenCodeProviderProfileInput, input: SaveOpenCodeProviderProfileInput,
) -> Result<SaveOpenCodeProviderProfileOutput, AppError> { ) -> Result<SaveOpenCodeProviderProfileOutput, AppError> {
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 let secret_ref = input
.profile .profile
.opencode_provider .opencode_provider
@ -370,10 +377,9 @@ impl SaveOpenCodeProviderProfile {
self.secret_store.put(&secret_ref, &input.api_key).await?; self.secret_store.put(&secret_ref, &input.api_key).await?;
let mut provider = let mut provider = OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref)
OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref) .map_err(|e| AppError::Invalid(e.to_string()))?;
.map_err(|e| AppError::Invalid(e.to_string()))?; if let Some(custom) = custom {
if let Some(custom) = input.custom {
provider = provider.with_custom(custom); provider = provider.with_custom(custom);
} }

View File

@ -611,18 +611,14 @@ fn parent_dir(base: &ProjectPath, rel: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod opencode_provider_config_json_tests { mod opencode_provider_config_json_tests {
use domain::profile::CustomProviderConfig;
use domain::ports::SecretRef; use domain::ports::SecretRef;
use domain::profile::CustomProviderConfig;
use super::*; use super::*;
fn known_provider_config() -> OpenCodeProviderConfig { fn known_provider_config() -> OpenCodeProviderConfig {
OpenCodeProviderConfig::new( OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-ref"))
"anthropic", .unwrap()
"claude-sonnet-5",
SecretRef::new("secret-ref"),
)
.unwrap()
} }
fn custom_provider_config() -> OpenCodeProviderConfig { 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) ----------------------- // ---- permission projection wiring (ticket #94) -----------------------
/// Builds an [`EffectivePermissions`] with the given fallback posture (only /// Builds an [`EffectivePermissions`] with the given fallback posture (only