fix(opencode): make models.dev providers cache-independent
This commit is contained in:
@ -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");
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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
|
||||
@ -44,6 +53,9 @@ fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
|
||||
"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<OpenCodeProviderCatalogEntry> {
|
||||
"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<OpenCodeProviderCatalogEntry> {
|
||||
/// **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<PathBuf> {
|
||||
pub fn opencode_models_cache_path() -> Option<PathBuf> {
|
||||
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<String>,
|
||||
#[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)]
|
||||
struct ModelsDevModelIgnored {}
|
||||
struct ModelsDevModel {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
.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<String> = provider.models.into_keys().collect();
|
||||
if models.is_empty() {
|
||||
return None;
|
||||
@ -121,6 +154,9 @@ fn parse_models_dev_cache(bytes: &[u8]) -> Option<Vec<OpenCodeProviderCatalogEnt
|
||||
provider_id,
|
||||
display_name,
|
||||
models,
|
||||
npm: provider.npm,
|
||||
api: provider.api,
|
||||
model_labels,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@ -152,6 +188,68 @@ pub fn opencode_provider_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
|
||||
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
|
||||
/// 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();
|
||||
|
||||
@ -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<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
|
||||
.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)
|
||||
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 {
|
||||
if let Some(custom) = custom {
|
||||
provider = provider.with_custom(custom);
|
||||
}
|
||||
|
||||
|
||||
@ -611,17 +611,13 @@ 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"),
|
||||
)
|
||||
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-ref"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user