diff --git a/src-tauri/src/provider/claude.rs b/src-tauri/src/provider/claude.rs index f7a0cd83e..02984b0a7 100644 --- a/src-tauri/src/provider/claude.rs +++ b/src-tauri/src/provider/claude.rs @@ -125,6 +125,7 @@ fn migrate_v1(v1: ClaudeV1Settings) -> Vec { label: slug.clone(), slug, effort_levels: Vec::new(), + ..Default::default() }) .collect(), ..Default::default() diff --git a/src-tauri/src/provider/codex.rs b/src-tauri/src/provider/codex.rs index 45c6f4d2a..3aa665339 100644 --- a/src-tauri/src/provider/codex.rs +++ b/src-tauri/src/provider/codex.rs @@ -3,7 +3,10 @@ //! never touches `~/.codex/config.toml`. Each provider is its own catalog id //! (`codex:`) since Codex binds the provider at thread start (no mid-thread switch). -use super::types::{is_enabled, CustomProvider, CustomProviderModel}; +use super::types::{ + is_enabled, CustomProvider, CustomProviderModel, InterleavedConfig, ModelCost, + ModelLimit, ModelModalities, ModelStatus, +}; use super::CustomProviderBackend; const SETTINGS_KEY: &str = "app.codex_custom_providers"; @@ -192,6 +195,41 @@ pub(super) fn parse_models_response( slug: slug.to_string(), label, effort_levels: Vec::new(), + reasoning: item.get("reasoning").and_then(serde_json::Value::as_bool), + tool_call: item.get("tool_call").and_then(serde_json::Value::as_bool), + temperature: item.get("temperature").and_then(serde_json::Value::as_bool), + attachment: item.get("attachment").and_then(serde_json::Value::as_bool), + limit: item + .get("limit") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + modalities: item + .get("modalities") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + cost: item + .get("cost") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + family: item + .get("family") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + release_date: item + .get("release_date") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + status: item + .get("status") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + interleaved: item + .get("interleaved") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + variants: item.get("variants").and_then(|v| { + let obj = v.as_object()?; + let map: std::collections::BTreeMap = obj + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + Some(map) + }), }); } Ok(out) @@ -231,6 +269,7 @@ mod tests { slug: slug.to_string(), label: slug.to_uppercase(), effort_levels: Vec::new(), + ..Default::default() } } diff --git a/src-tauri/src/provider/kimi.rs b/src-tauri/src/provider/kimi.rs index 5f6861a20..89af43110 100644 --- a/src-tauri/src/provider/kimi.rs +++ b/src-tauri/src/provider/kimi.rs @@ -198,6 +198,7 @@ fn read_custom_providers() -> Result> { slug, label, effort_levels: Vec::new(), + ..Default::default() }); } } @@ -512,6 +513,7 @@ keep = true slug: "deepseek-v4-pro".into(), label: "DeepSeek V4 Pro".into(), effort_levels: Vec::new(), + ..Default::default() }], ..Default::default() } diff --git a/src-tauri/src/provider/opencode.rs b/src-tauri/src/provider/opencode.rs index d99d1172e..b63fff97f 100644 --- a/src-tauri/src/provider/opencode.rs +++ b/src-tauri/src/provider/opencode.rs @@ -1,5 +1,5 @@ //! OpenCode custom-provider backend. File-backed; list/upsert/remove -//! delegate to `opencode_config`. Written models always carry `reasoning: true`. +//! delegate to `opencode_config`. use super::opencode_config::{self, OpencodeCustomModel, OpencodeCustomProvider}; use super::types::{CustomProvider, CustomProviderModel}; @@ -37,6 +37,18 @@ fn to_custom(p: OpencodeCustomProvider) -> CustomProvider { slug: m.id, label: m.name, effort_levels: Vec::new(), + reasoning: Some(m.reasoning), + tool_call: Some(m.tool_call), + temperature: Some(m.temperature), + attachment: Some(m.attachment), + limit: m.limit, + modalities: m.modalities, + cost: m.cost, + family: m.family, + release_date: m.release_date, + status: m.status, + interleaved: m.interleaved, + variants: m.variants, }) .collect(), enabled_model_ids: None, @@ -63,7 +75,19 @@ fn to_opencode(p: CustomProvider) -> OpencodeCustomProvider { .map(|m| OpencodeCustomModel { id: m.slug, name: m.label, - reasoning: true, + + reasoning: m.reasoning.unwrap_or(true), + tool_call: m.tool_call.unwrap_or(false), + temperature: m.temperature.unwrap_or(false), + attachment: m.attachment.unwrap_or(false), + family: m.family, + release_date: m.release_date, + status: m.status, + cost: m.cost, + interleaved: m.interleaved, + variants: m.variants, + limit: m.limit, + modalities: m.modalities, }) .collect(), } @@ -117,6 +141,7 @@ mod tests { slug: "m".to_string(), label: "M".to_string(), effort_levels: Vec::new(), + ..Default::default() }], enabled_model_ids: None, ..Default::default() @@ -128,6 +153,10 @@ mod tests { assert_eq!(back.api_style.as_deref(), Some("responses")); assert_eq!(back.preset_key, None); assert_eq!(back.models[0].slug, "m"); + assert_eq!(back.models[0].reasoning, Some(true)); + assert_eq!(back.models[0].tool_call, Some(false)); + assert_eq!(back.models[0].temperature, Some(false)); + assert_eq!(back.models[0].attachment, Some(false)); } #[test] @@ -160,4 +189,75 @@ mod tests { }; assert_eq!(to_custom(oc).preset_key, None); } + + #[test] + fn api_silent_fields_fall_back_to_defaults() { + let custom = CustomProvider { + id: "sparse".to_string(), + name: "Sparse".to_string(), + preset_key: None, + base_url: "https://sparse.example.com/v1".to_string(), + api_key: "sk-test".to_string(), + api_style: Some("chat".to_string()), + headers: None, + models: vec![CustomProviderModel { + slug: "m".to_string(), + label: "M".to_string(), + effort_levels: Vec::new(), + ..Default::default() + }], + enabled_model_ids: None, + }; + let oc = to_opencode(custom); + let m = &oc.models[0]; + assert!(m.reasoning); + assert!(!m.tool_call); + assert!(!m.temperature); + assert!(!m.attachment); + assert!(m.limit.is_none()); + assert!(m.modalities.is_none()); + assert!(m.cost.is_none()); + } + + #[test] + fn api_fields_override_defaults() { + use crate::provider::types::{ModelLimit, ModelModalities}; + + let custom = CustomProvider { + id: "rich".to_string(), + name: "Rich".to_string(), + preset_key: None, + base_url: "https://rich.example.com/v1".to_string(), + api_key: "sk-test".to_string(), + api_style: Some("chat".to_string()), + headers: None, + models: vec![CustomProviderModel { + slug: "m".to_string(), + label: "M".to_string(), + effort_levels: Vec::new(), + reasoning: Some(false), + tool_call: Some(true), + temperature: Some(true), + attachment: Some(true), + limit: Some(ModelLimit { + context: 1_000_000, + output: 384_000, + }), + modalities: Some(ModelModalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), + ..Default::default() + }], + enabled_model_ids: None, + }; + let oc = to_opencode(custom); + let m = &oc.models[0]; + assert!(!m.reasoning); + assert!(m.tool_call); + assert!(m.temperature); + assert!(m.attachment); + assert_eq!(m.limit.as_ref().unwrap().context, 1_000_000); + assert_eq!(m.modalities.as_ref().unwrap().input, vec!["text", "image"]); + } } diff --git a/src-tauri/src/provider/opencode_config.rs b/src-tauri/src/provider/opencode_config.rs index b0ed82249..089872bd6 100644 --- a/src-tauri/src/provider/opencode_config.rs +++ b/src-tauri/src/provider/opencode_config.rs @@ -10,6 +10,10 @@ use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode}; use jsonc_parser::ParseOptions; use serde::{Deserialize, Serialize}; +pub use super::types::{ + InterleavedConfig, ModelCost, ModelLimit, ModelModalities, ModelStatus, +}; + const SCHEMA_URL: &str = "https://opencode.ai/config.json"; const DEFAULT_NPM: &str = "@ai-sdk/openai-compatible"; @@ -25,7 +29,7 @@ const OPENCODE_FAMILY: FamilyConfig = FamilyConfig { file_candidates: ["opencode.jsonc", "opencode.json", "config.json"], }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct OpencodeCustomModel { pub id: String, @@ -34,9 +38,31 @@ pub struct OpencodeCustomModel { // `reasoning: true` makes opencode compute effort variants. #[serde(default)] pub reasoning: bool, + #[serde(default)] + pub tool_call: bool, + #[serde(default)] + pub temperature: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modalities: Option, + #[serde(default)] + pub attachment: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interleaved: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variants: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct OpencodeCustomProvider { pub id: String, @@ -195,17 +221,82 @@ fn read_models(models: Option<&serde_json::Value>) -> Vec { return Vec::new(); }; map.iter() - .map(|(model_id, block)| OpencodeCustomModel { - id: model_id.clone(), - name: block - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(model_id) - .to_string(), - reasoning: block - .get("reasoning") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + .map(|(model_id, block)| { + OpencodeCustomModel { + id: model_id.clone(), + name: block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(model_id) + .to_string(), + reasoning: block + .get("reasoning") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + tool_call: block + .get("tool_call") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + temperature: block + .get("temperature") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + attachment: block + .get("attachment") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + family: block + .get("family") + .and_then(serde_json::Value::as_str) + .map(String::from), + release_date: block + .get("release_date") + .and_then(serde_json::Value::as_str) + .map(String::from), + status: block + .get("status") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + cost: block + .get("cost") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + interleaved: block + .get("interleaved") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + variants: block + .get("variants") + .and_then(serde_json::Value::as_object) + .map(|obj| { + obj.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }), + limit: block.get("limit").and_then(|v| { + let obj = v.as_object()?; + Some(ModelLimit { + context: obj.get("context")?.as_u64()?, + output: obj.get("output")?.as_u64()?, + }) + }), + modalities: block.get("modalities").and_then(|v| { + let obj = v.as_object()?; + Some(ModelModalities { + input: obj + .get("input") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter().filter_map(|v| v.as_str().map(String::from)).collect() + }) + .unwrap_or_default(), + output: obj + .get("output") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter().filter_map(|v| v.as_str().map(String::from)).collect() + }) + .unwrap_or_default(), + }) + }), + } }) .collect() } @@ -213,6 +304,9 @@ fn read_models(models: Option<&serde_json::Value>) -> Vec { fn upsert_custom_provider_at(path: &Path, provider: &OpencodeCustomProvider) -> Result<()> { let text = std::fs::read_to_string(path) .unwrap_or_else(|_| format!("{{\n \"$schema\": \"{SCHEMA_URL}\"\n}}\n")); + + let provider = provider.clone(); + // Only rewrite `models` when changed, to preserve comments in it. let models_unchanged = read_custom_providers_at(path) .ok() @@ -303,29 +397,152 @@ fn models_to_cst(models: &[OpencodeCustomModel]) -> CstInputValue { if m.reasoning { fields.push(("reasoning".to_string(), CstInputValue::Bool(true))); } + if m.tool_call { + fields.push(("tool_call".to_string(), CstInputValue::Bool(true))); + } + if m.temperature { + fields.push(("temperature".to_string(), CstInputValue::Bool(true))); + } + if m.attachment { + fields.push(("attachment".to_string(), CstInputValue::Bool(true))); + } + if let Some(family) = &m.family { + fields.push(("family".to_string(), CstInputValue::String(family.clone()))); + } + if let Some(release_date) = &m.release_date { + fields.push(("release_date".to_string(), CstInputValue::String(release_date.clone()))); + } + if let Some(status) = &m.status { + let s = serde_json::to_string(status).unwrap_or_default(); + fields.push(("status".to_string(), CstInputValue::String(s.trim_matches('"').to_string()))); + } + if let Some(cost) = &m.cost { + if let Ok(json) = serde_json::to_value(cost) { + if let Some(obj) = json.as_object() { + let cost_fields: Vec<(String, CstInputValue)> = obj.iter().map(|(k, v)| { + (k.clone(), json_to_cst(v)) + }).collect(); + fields.push(("cost".to_string(), CstInputValue::Object(cost_fields))); + } + } + } + if let Some(interleaved) = &m.interleaved { + match interleaved { + InterleavedConfig::Flag(true) => { + fields.push(("interleaved".to_string(), CstInputValue::Bool(true))); + } + InterleavedConfig::Flag(false) => {} + InterleavedConfig::Object { field } => { + fields.push(("interleaved".to_string(), CstInputValue::Object( + vec![("field".to_string(), CstInputValue::String(field.clone()))], + ))); + } + } + } + if let Some(variants) = &m.variants { + if let Ok(json) = serde_json::to_value(variants) { + if let Some(obj) = json.as_object() { + let var_fields: Vec<(String, CstInputValue)> = obj.iter() + .map(|(k, v)| (k.clone(), json_to_cst(v))) + .collect(); + fields.push(("variants".to_string(), CstInputValue::Object(var_fields))); + } + } + } + if let Some(limit) = &m.limit { + fields.push(( + "limit".to_string(), + CstInputValue::Object(vec![ + ( + "context".to_string(), + CstInputValue::Number(limit.context.to_string()), + ), + ( + "output".to_string(), + CstInputValue::Number(limit.output.to_string()), + ), + ]), + )); + } + if let Some(modalities) = &m.modalities { + let mut mod_fields: Vec<(String, CstInputValue)> = Vec::new(); + if !modalities.input.is_empty() { + mod_fields.push(( + "input".to_string(), + CstInputValue::Array( + modalities + .input + .iter() + .map(|s| CstInputValue::String(s.clone())) + .collect(), + ), + )); + } + if !modalities.output.is_empty() { + mod_fields.push(( + "output".to_string(), + CstInputValue::Array( + modalities + .output + .iter() + .map(|s| CstInputValue::String(s.clone())) + .collect(), + ), + )); + } + if !mod_fields.is_empty() { + fields.push(("modalities".to_string(), CstInputValue::Object(mod_fields))); + } + } (m.id.clone(), CstInputValue::Object(fields)) }) .collect(), ) } +// Convert a serde_json::Value to CstInputValue for models_to_cst +fn json_to_cst(value: &serde_json::Value) -> CstInputValue { + match value { + serde_json::Value::Null => CstInputValue::Null, + serde_json::Value::Bool(b) => CstInputValue::Bool(*b), + serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()), + serde_json::Value::String(s) => CstInputValue::String(s.clone()), + serde_json::Value::Array(arr) => { + CstInputValue::Array(arr.iter().map(json_to_cst).collect()) + } + serde_json::Value::Object(obj) => { + CstInputValue::Object( + obj.iter().map(|(k, v)| (k.clone(), json_to_cst(v))).collect(), + ) + } + } +} + // Order-independent equality. fn models_equal(a: &[OpencodeCustomModel], b: &[OpencodeCustomModel]) -> bool { - let key = |models: &[OpencodeCustomModel]| { - let mut out: Vec<(String, String, bool)> = models - .iter() - .map(|m| { - ( - m.id.trim().to_string(), - m.name.trim().to_string(), - m.reasoning, - ) - }) - .collect(); - out.sort(); - out - }; - key(a) == key(b) + if a.len() != b.len() { + return false; + } + let mut a_sorted: Vec<&OpencodeCustomModel> = a.iter().collect(); + let mut b_sorted: Vec<&OpencodeCustomModel> = b.iter().collect(); + a_sorted.sort_by(|a, b| a.id.trim().cmp(b.id.trim())); + b_sorted.sort_by(|a, b| a.id.trim().cmp(b.id.trim())); + a_sorted.into_iter().zip(b_sorted).all(|(a, b)| { + a.id.trim() == b.id.trim() + && a.name.trim() == b.name.trim() + && a.reasoning == b.reasoning + && a.tool_call == b.tool_call + && a.temperature == b.temperature + && a.attachment == b.attachment + && a.family == b.family + && a.release_date == b.release_date + && a.status == b.status + && a.cost == b.cost + && a.limit == b.limit + && a.modalities == b.modalities + && a.interleaved == b.interleaved + && a.variants == b.variants + }) } // Preset providers only need an apiKey; set just that, preserve the rest. @@ -392,8 +609,357 @@ mod tests { id: "deepseek-v4-pro".to_string(), name: "DeepSeek V4 Pro".to_string(), reasoning: true, + tool_call: true, + temperature: true, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { + context: 1000000, + output: 384000, + }), + modalities: None, + }], + } + } + + #[test] + fn roundtrip_with_all_capabilities() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("opencode.jsonc"); + + let provider = OpencodeCustomProvider { + id: "acme".to_string(), + name: "Acme".to_string(), + npm: "@ai-sdk/openai-compatible".to_string(), + base_url: "https://acme.example.com/v1".to_string(), + api_key: "{env:MY_KEY}".to_string(), + headers: BTreeMap::new(), + models: vec![ + OpencodeCustomModel { + id: "deepseek-v4-pro-fusion".to_string(), + name: "DeepSeek V4 Pro Fusion".to_string(), + reasoning: true, + tool_call: true, + temperature: true, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { + context: 1000000, + output: 384000, + }), + modalities: Some(ModelModalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), + }, + OpencodeCustomModel { + id: "deepseek-v4-flash-fusion".to_string(), + name: "DeepSeek V4 Flash Fusion".to_string(), + reasoning: true, + tool_call: true, + temperature: true, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { + context: 1000000, + output: 384000, + }), + modalities: Some(ModelModalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), + }, + ], + }; + + upsert_custom_provider_at(&path, &provider).unwrap(); + + // Verify written JSON contains all capability fields + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("\"tool_call\": true")); + assert!(written.contains("\"temperature\": true")); + assert!(written.contains("\"context\": 1000000")); + assert!(written.contains("\"output\": 384000")); + assert!(written.contains("\"input\"")); + assert!(written.contains("\"image\"")); + + // Verify read matches the original (models may reorder by id during JSON round-trip) + let mut original = provider; + let mut read = read_custom_providers_at(&path).unwrap(); + original.models.sort_by(|a, b| a.id.cmp(&b.id)); + read[0].models.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(read, vec![original]); + } + + #[test] + fn roundtrip_vision_model_with_modalities() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("opencode.jsonc"); + + let provider = OpencodeCustomProvider { + id: "acme".to_string(), + name: "Acme".to_string(), + npm: "@ai-sdk/openai-compatible".to_string(), + base_url: "https://acme.example.com/v1".to_string(), + api_key: "{env:MY_KEY}".to_string(), + headers: BTreeMap::new(), + models: vec![OpencodeCustomModel { + id: "cmc/xiaomi/mimo-v2.5".to_string(), + name: "MiMo V2.5".to_string(), + reasoning: true, + tool_call: true, + temperature: true, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { + context: 1000000, + output: 128000, + }), + modalities: Some(ModelModalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), }], + }; + + upsert_custom_provider_at(&path, &provider).unwrap(); + let read = read_custom_providers_at(&path).unwrap(); + assert_eq!(read, vec![provider]); + } + + #[test] + fn read_model_without_optional_fields_defaults_to_false() { + let json = serde_json::json!({ + "m": { "name": "M" } + }); + let models = read_models(Some(&json)); + assert_eq!(models.len(), 1); + let m = &models[0]; + assert_eq!(m.id, "m"); + assert_eq!(m.name, "M"); + assert!(!m.reasoning); + assert!(!m.tool_call); + assert!(!m.temperature); + assert_eq!(m.limit, None); + assert_eq!(m.modalities, None); + } + + #[test] + fn read_model_with_partial_limit_defaults_missing_fields() { + let json = serde_json::json!({ + "m": { + "name": "M", + "limit": { "context": 256000 } + } + }); + let models = read_models(Some(&json)); + assert_eq!(models.len(), 1); + // missing "output" in limit → None because the inner `?` short-circuits + assert_eq!(models[0].limit, None); + } + + #[test] + fn upsert_overwrites_with_provider_values() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("opencode.jsonc"); + std::fs::write( + &path, + r#"{ + "provider": { + "acme": { + "npm": "@ai-sdk/openai-compatible", + "options": { "baseURL": "https://acme.example.com/v1", "apiKey": "old" }, + "models": { + "deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "reasoning": true, + "tool_call": true, + "temperature": true, + "attachment": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 384000 }, + "cost": { "input": 1.74, "output": 3.48, "cache_read": 0.17 }, + "family": "claude", + "status": "active" + } + } + } + } +}"#, + ) + .unwrap(); + + let updated = OpencodeCustomProvider { + id: "acme".to_string(), + name: "Acme".to_string(), + npm: "@ai-sdk/openai-compatible".to_string(), + base_url: "https://acme.example.com/v2".to_string(), + api_key: "new".to_string(), + headers: BTreeMap::new(), + models: vec![OpencodeCustomModel { + id: "deepseek-v4-pro".to_string(), + name: "DeepSeek V4 Pro".to_string(), + reasoning: false, + tool_call: false, + temperature: false, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: None, + modalities: None, + }], + }; + upsert_custom_provider_at(&path, &updated).unwrap(); + + let read = read_custom_providers_at(&path).unwrap(); + let model = &read[0].models[0]; + + assert!(!model.tool_call, "tool_call should be false (provider value)"); + assert!(!model.temperature, "temperature should be false (provider value)"); + assert!(!model.attachment, "attachment should be false (provider value)"); + assert!(!model.reasoning, "reasoning should be false (provider value)"); + assert!(model.family.is_none()); + assert!(model.limit.is_none()); + assert!(model.modalities.is_none()); + assert!(model.cost.is_none()); + assert!(model.status.is_none()); + assert_eq!(read[0].base_url, "https://acme.example.com/v2"); + assert_eq!(read[0].api_key, "new"); + } + + + + #[test] + fn read_model_from_jsonc_text() { + let text = r#"{ + "provider": { + "acme": { + "npm": "@ai-sdk/openai-compatible", + "name": "Acme", + "options": { + "baseURL": "https://acme.example.com/v1", + "apiKey": "{env:MY_KEY}" + }, + "models": { + "deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "reasoning": true, + "tool_call": true, + "temperature": true, + "limit": { "context": 1000000, "output": 384000 } + }, + "mimo-v2.5": { + "name": "MiMo V2.5", + "reasoning": true, + "tool_call": true, + "temperature": true, + "limit": { "context": 1000000, "output": 128000 }, + "modalities": { "input": ["text", "image"], "output": ["text"] } } + } + } + } +} +"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("opencode.jsonc"); + std::fs::write(&path, text).unwrap(); + + let providers = read_custom_providers_at(&path).unwrap(); + assert_eq!(providers.len(), 1); + let m0 = &providers[0].models[0]; + assert_eq!(m0.id, "deepseek-v4-pro"); + assert!(m0.reasoning); + assert!(m0.tool_call); + assert!(m0.temperature); + let limit = m0.limit.as_ref().unwrap(); + assert_eq!(limit.context, 1_000_000); + assert_eq!(limit.output, 384_000); + assert_eq!(m0.modalities, None); + + let m1 = &providers[0].models[1]; + assert_eq!(m1.id, "mimo-v2.5"); + let mods = m1.modalities.as_ref().unwrap(); + assert_eq!(mods.input, vec!["text", "image"]); + assert_eq!(mods.output, vec!["text"]); + } + + #[test] + fn models_equal_compares_new_fields() { + let a = vec![OpencodeCustomModel { + id: "m".to_string(), + name: "M".to_string(), + reasoning: true, + tool_call: true, + temperature: false, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { context: 1000, output: 500 }), + modalities: None, + }]; + let b = vec![OpencodeCustomModel { + id: " m ".to_string(), + name: " M ".to_string(), + reasoning: true, + tool_call: true, + temperature: false, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { context: 1000, output: 500 }), + modalities: None, + }]; + let c = vec![OpencodeCustomModel { + id: "m".to_string(), + name: "M".to_string(), + reasoning: true, + tool_call: true, + temperature: true, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: Some(ModelLimit { context: 1000, output: 500 }), + modalities: None, + }]; + assert!(models_equal(&a, &b), "identical models should be equal"); + assert!(!models_equal(&a, &c), "different temperature should not match"); } #[test] @@ -557,6 +1123,17 @@ mod tests { id: "deepseek-v4-pro".to_string(), name: "DeepSeek V4 Pro".to_string(), reasoning: false, + tool_call: false, + temperature: false, + attachment: false, + family: None, + release_date: None, + status: None, + cost: None, + interleaved: None, + variants: None, + limit: None, + modalities: None, }], }; upsert_custom_provider_at(&path, &edited).unwrap(); diff --git a/src-tauri/src/provider/types.rs b/src-tauri/src/provider/types.rs index 029f4aa62..84789dd49 100644 --- a/src-tauri/src/provider/types.rs +++ b/src-tauri/src/provider/types.rs @@ -1,6 +1,6 @@ //! Unified custom-provider config types. Mirror of frontend `lib/provider-config.ts`. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; @@ -25,7 +25,62 @@ impl ProviderFamily { } } -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] + + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelLimit { + pub context: u64, + pub output: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ModelCost { + pub input: f64, + pub output: f64, + #[serde(default, alias = "cache_read", skip_serializing_if = "Option::is_none")] + pub cache_read: Option, + #[serde(default, alias = "cache_write", skip_serializing_if = "Option::is_none")] + pub cache_write: Option, + #[serde( + default, + alias = "context_over_200k", + skip_serializing_if = "Option::is_none" + )] + pub context_over_200k: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelModalities { + #[serde(default)] + pub input: Vec, + #[serde(default)] + pub output: Vec, +} + +/// `true` | `{ field: "reasoning" | "reasoning_content" | "reasoning_details" }` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum InterleavedConfig { + Flag(bool), + Object { field: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ModelStatus { + #[serde(rename = "alpha")] + Alpha, + #[serde(rename = "beta")] + Beta, + #[serde(rename = "deprecated")] + Deprecated, + #[serde(rename = "active")] + Active, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CustomProviderModel { /// Wire model name, sent verbatim to the endpoint. @@ -35,6 +90,31 @@ pub struct CustomProviderModel { /// Non-empty ⟺ the composer shows an effort switch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effort_levels: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modalities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interleaved: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variants: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/lib/provider-config.ts b/src/lib/provider-config.ts index 4d355755a..afae94399 100644 --- a/src/lib/provider-config.ts +++ b/src/lib/provider-config.ts @@ -2,11 +2,47 @@ export type ProviderFamily = "claude" | "codex" | "opencode" | "kimi"; +export type ModelLimit = { + context: number; + output: number; +}; + +export type ModelCost = { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + contextOver200k?: ModelCost; +}; + +export type ModelModalities = { + input?: string[]; + output?: string[]; +}; + +export type ModelStatus = "alpha" | "beta" | "deprecated" | "active"; + +export type InterleavedConfig = + | boolean + | { field: "reasoning" | "reasoning_content" | "reasoning_details" }; + export type CustomProviderModel = { slug: string; label: string; /** Non-empty ⟺ the composer shows an effort switch. */ effortLevels?: string[]; + reasoning?: boolean; + toolCall?: boolean; + temperature?: boolean; + attachment?: boolean; + limit?: ModelLimit; + modalities?: ModelModalities; + cost?: ModelCost; + family?: string; + releaseDate?: string; + status?: ModelStatus; + interleaved?: InterleavedConfig; + variants?: Record; }; export type ApiStyle = "chat" | "responses";