feat(opencode): round-trip native v2 providers via JSON editor (#7579)

OpenCode 2.0 accepts native provider declarations under `providers` next
to the legacy `provider` map. CC Switch only read `provider`, so native
providers were invisible and writes landed in the map OpenCode ignored.

- Read both containers. For the same ID a valid native declaration wins
  regardless of key order; an invalid one does not shadow a valid legacy
  entry and is left untouched, matching OpenCode's normalizer (schema as
  of v2.0.12). Unknown extension fields are kept.
- Provider meta records the source format (opencodeConfigFormat), so
  `{}` and models-only built-in overrides stay native across database
  round-trips and re-enabling.
- Native providers are written under `providers` through the
  comment-preserving JSONC editor; legacy writes shadowed by a valid
  native declaration are rejected, and removing a provider deletes the
  ID from both containers.
- Native JSON is validated before it is saved and the error names the
  first invalid field, so a rejected edit no longer leaves the database
  ahead of the live file.
- Native providers are edited in the JSON editor; legacy ones keep the
  structured form. Existing native IDs are kept as-is, new native IDs
  need a package and at least one model, and duplicating a native card
  checks `package`.
- Common config extraction also skips native settings.apiKey/baseURL.

Refs #7382

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Bryan NieandJason authored and GitHub committed 2026-10-06 13:59:57 +08:00
1 parent f68fe30fd9
commit 3af7482317
14 files changed
+1592 -94

No files matched your search

+761 -16
View File
@@ -1,7 +1,9 @@
use crate::config::atomic_write;
use crate::error::AppError;
use crate::jsonc_document::JsoncDocument;
use crate::provider::OpenCodeConfigFormat;
use crate::settings::get_opencode_override_dir;
use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
@@ -162,12 +164,23 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
fn edit_config(
resolve_path: impl FnOnce() -> Result<PathBuf, AppError>,
edit: impl FnOnce(&mut Value),
) -> Result<bool, AppError> {
try_edit_config(resolve_path, |config| {
edit(config);
Ok(())
})
}
/// Like `edit_config`, but the edit may reject the change; nothing is written then.
fn try_edit_config(
resolve_path: impl FnOnce() -> Result<PathBuf, AppError>,
edit: impl FnOnce(&mut Value) -> Result<(), AppError>,
) -> Result<bool, AppError> {
let _guard = opencode_config_lock().lock()?;
let path = resolve_path()?;
let mut document = OpenCodeDocument::load(&path)?;
let mut desired = document.document.value().clone();
edit(&mut desired);
edit(&mut desired)?;
if !document.document.apply(&desired)? {
return Ok(false);
}
@@ -176,31 +189,397 @@ fn edit_config(
}
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
.get("provider")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default())
Ok(get_providers_with_format()?
.into_iter()
.map(|(id, (value, _))| (id, value))
.collect())
}
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
edit_config(get_opencode_config_path, |full_config| {
if !full_config.get("provider").is_some_and(Value::is_object) {
if full_config.get("provider").is_some() {
log::warn!("OpenCode 的供应商配置格式有误,将清空原有供应商配置,再保存当前供应商");
/// Preserve the declaration's format alongside its JSON, including package-less
/// built-in overrides. Do not recursively mix legacy and native entries.
pub fn get_providers_with_format(
) -> Result<IndexMap<String, (Value, OpenCodeConfigFormat)>, AppError> {
let mut config = read_opencode_config()?;
let mut take = |key| match config.get_mut(key).map(Value::take) {
Some(Value::Object(providers)) => providers,
_ => Map::new(),
};
let (legacy, native) = (take("provider"), take("providers"));
let mut providers: IndexMap<_, _> = legacy
.into_iter()
.map(|(id, value)| (id, (value, OpenCodeConfigFormat::V1)))
.collect();
for (id, value) in native {
match native_provider_problem(&value) {
None => {
providers.insert(id, (value, OpenCodeConfigFormat::V2));
}
Some(path) => {
let field = if path.is_empty() { "<root>" } else { &path };
log::warn!(
"Invalid native OpenCode provider '{id}' at {field}, leaving its source untouched"
);
}
full_config["provider"] = json!({});
}
full_config["provider"][id] = config;
}
Ok(providers)
}
/// Validate known native fields before giving V2 precedence over V1. Keep the
/// original JSON (including unknown extensions) rather than projecting it into
/// a partial type. Constraints follow OpenCode v2.0.12, commit 2670273ff17d:
/// packages/schema/src/config/provider.ts, model.ts and provider.ts.
pub fn is_native_provider(value: &Value) -> bool {
native_provider_problem(value).is_none()
}
/// The dotted path of the first known field that does not match the native
/// schema; an empty path means the declaration is not an object. Like OpenCode,
/// which decodes with `onExcessProperty: "ignore"`, treat V1-only keys such as
/// `npm` and `options` as unknown extensions rather than as a format mismatch.
pub fn native_provider_problem(value: &Value) -> Option<String> {
let Some(obj) = value.as_object() else {
return Some(String::new());
};
if let Some(key) = first_invalid_field(
obj,
&[
("name", Value::is_string),
("package", Value::is_string),
("canonical", Value::is_string),
("env", native_string_array),
("settings", native_provider_settings),
("headers", native_headers),
("body", Value::is_object),
],
) {
return Some(key.to_string());
}
match obj.get("models") {
None => None,
Some(Value::Object(models)) => models.iter().find_map(|(id, model)| {
native_model_problem(model).map(|key| match key {
"" => format!("models.{id}"),
key => format!("models.{id}.{key}"),
})
}),
Some(_) => Some("models".to_string()),
}
}
type NativeFieldCheck<'a> = (&'a str, fn(&Value) -> bool);
/// Optional means absent, not null. Unknown keys remain untouched at every level.
fn first_invalid_field<'a>(
obj: &Map<String, Value>,
fields: &[NativeFieldCheck<'a>],
) -> Option<&'a str> {
fields
.iter()
.find(|(key, check)| obj.get(*key).is_some_and(|value| !check(value)))
.map(|(key, _)| *key)
}
fn native_fields_valid(value: &Value, fields: &[NativeFieldCheck<'_>]) -> bool {
value
.as_object()
.is_some_and(|obj| first_invalid_field(obj, fields).is_none())
}
fn native_string_array(value: &Value) -> bool {
value
.as_array()
.is_some_and(|values| values.iter().all(Value::is_string))
}
fn native_finite(value: &Value) -> bool {
value.as_f64().is_some_and(f64::is_finite)
}
fn native_integer(value: &Value) -> bool {
// Effect Schema.Int uses JavaScript's safe integer range, including 1.0.
value
.as_f64()
.is_some_and(|n| n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0)
}
fn native_compaction(value: &Value) -> bool {
value.as_object().is_some_and(|obj| {
matches!(
obj.get("type").and_then(Value::as_str),
Some("summary" | "native")
)
})
}
fn native_provider_settings(value: &Value) -> bool {
native_fields_valid(
value,
&[
("timeout", |v| v == &Value::Bool(false) || native_finite(v)),
("chunkTimeout", native_finite),
("compaction", native_compaction),
("transport", |v| {
matches!(v.as_str(), Some("http" | "websocket"))
}),
],
)
}
fn native_headers(value: &Value) -> bool {
value
.as_object()
.is_some_and(|headers| headers.values().all(Value::is_string))
}
// Model/variant settings only constrain compaction; timeout and transport here
// are package-specific extensions, unlike provider settings.
fn native_model_settings(value: &Value) -> bool {
native_fields_valid(value, &[("compaction", native_compaction)])
}
fn native_model_overlays(value: &Value) -> bool {
native_fields_valid(
value,
&[
("settings", native_model_settings),
("headers", native_headers),
("body", Value::is_object),
],
)
}
/// The first invalid field of a model; an empty name means it is not an object.
fn native_model_problem(value: &Value) -> Option<&'static str> {
let Some(obj) = value.as_object() else {
return Some("");
};
first_invalid_field(
obj,
&[
("modelID", Value::is_string),
("family", Value::is_string),
("name", Value::is_string),
("package", Value::is_string),
("disabled", Value::is_boolean),
("settings", native_model_settings),
("headers", native_headers),
("body", Value::is_object),
("compatibility", native_compatibility),
("capabilities", |v| {
v.as_object().is_some_and(|obj| {
obj.get("tools").is_some_and(Value::is_boolean)
&& obj.get("input").is_some_and(native_string_array)
&& obj.get("output").is_some_and(native_string_array)
})
}),
("variants", |v| {
v.as_array().is_some_and(|variants| {
variants.iter().all(|variant| {
variant.get("id").is_some_and(Value::is_string)
&& native_model_overlays(variant)
})
})
}),
("cost", |v| match v.as_array() {
Some(costs) => costs.iter().all(native_cost),
None => native_cost(v),
}),
("limit", |v| {
native_fields_valid(
v,
&[
("context", native_integer),
("input", native_integer),
("output", native_integer),
],
)
}),
],
)
}
fn native_compatibility(value: &Value) -> bool {
native_fields_valid(
value,
&[
("reasoningField", Value::is_string),
("requireReasoning", Value::is_boolean),
("maxTokensField", |v| {
matches!(v.as_str(), Some("max_completion_tokens" | "max_tokens"))
}),
("requireFinishReason", Value::is_boolean),
("requireAssistantAfterTool", Value::is_boolean),
("supportsPromptCacheKey", Value::is_boolean),
],
)
}
fn native_cost(value: &Value) -> bool {
value.get("input").is_some_and(native_finite)
&& value.get("output").is_some_and(native_finite)
&& native_fields_valid(
value,
&[
("tier", |v| {
v.as_object().is_some_and(|obj| {
obj.get("type").and_then(Value::as_str) == Some("context")
&& obj.get("size").is_some_and(native_integer)
})
}),
("cache", |v| {
native_fields_valid(v, &[("read", native_finite), ("write", native_finite)])
}),
],
)
}
// Keys only a V1 declaration has, and keys only a native one has. Mirrored by
// isNativeOpencodeConfig in src/components/providers/forms/helpers/opencodeFormUtils.ts.
const LEGACY_ONLY_KEYS: [&str; 3] = ["npm", "options", "api"];
const NATIVE_ONLY_KEYS: [&str; 5] = ["package", "settings", "headers", "body", "canonical"];
/// Infer the format of a declaration whose source is unknown. Unlike validation,
/// V1-only keys decide here: a native declaration would not normally carry them.
pub fn provider_format(
value: &Value,
source: Option<OpenCodeConfigFormat>,
) -> OpenCodeConfigFormat {
source.unwrap_or_else(|| {
let has_any = |keys: &[&str]| keys.iter().any(|key| value.get(*key).is_some());
if !has_any(&LEGACY_ONLY_KEYS) && has_any(&NATIVE_ONLY_KEYS) {
OpenCodeConfigFormat::V2
} else {
OpenCodeConfigFormat::V1
}
})
}
/// The declaration to write for a stored provider, and its format. Settings are
/// normally the declaration itself but may hold a full config (older copies or a
/// pasted file); pick from it as the reader does, preferring a valid native entry.
pub fn provider_fragment<'a>(
id: &str,
settings: &'a Value,
source: Option<OpenCodeConfigFormat>,
) -> Result<(&'a Value, OpenCodeConfigFormat), AppError> {
let Some(obj) = settings.as_object().filter(|obj| {
["$schema", "provider", "providers"]
.iter()
.any(|key| obj.contains_key(*key))
}) else {
return Ok((settings, provider_format(settings, source)));
};
log::warn!(
"OpenCode provider '{id}' has full config structure in settings_config, attempting to extract fragment"
);
let native = obj.get("providers").and_then(|providers| providers.get(id));
let legacy = obj.get("provider").and_then(|providers| providers.get(id));
match (native, legacy) {
(Some(native), legacy) if legacy.is_none() || is_native_provider(native) => {
Ok((native, OpenCodeConfigFormat::V2))
}
(_, Some(legacy)) => Ok((legacy, OpenCodeConfigFormat::V1)),
_ if obj.contains_key("provider") || obj.contains_key("providers") => {
Err(AppError::localized(
"provider.opencode.fragment_missing",
format!("OpenCode 配置中没有供应商「{id}」"),
format!("OpenCode config does not contain provider '{id}'"),
))
}
_ => Ok((settings, provider_format(settings, source))),
}
}
/// Reject a native declaration OpenCode would skip, naming the offending field.
pub fn validate_native_provider(id: &str, value: &Value) -> Result<(), AppError> {
match native_provider_problem(value) {
None => Ok(()),
Some(path) if path.is_empty() => Err(AppError::localized(
"provider.opencode.native_not_object",
format!("OpenCode 原生供应商「{id}」必须是 JSON 对象"),
format!("Native OpenCode provider '{id}' must be a JSON object"),
)),
Some(path) => Err(AppError::localized(
"provider.opencode.native_invalid_field",
format!("OpenCode 原生供应商「{id}」的字段 {path} 不符合 OpenCode V2 配置格式"),
format!("Native OpenCode provider '{id}' has an invalid field: {path}"),
)),
}
}
/// Test convenience: writes with the format inferred from the content alone.
#[cfg(test)]
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let format = provider_format(&config, None);
set_provider_with_format(id, config, format)
}
pub fn set_provider_with_format(
id: &str,
config: Value,
format: OpenCodeConfigFormat,
) -> Result<(), AppError> {
try_edit_config(get_opencode_config_path, |full_config| {
let key = match format {
OpenCodeConfigFormat::V1 => {
if full_config
.get("providers")
.and_then(|providers| providers.get(id))
.is_some_and(is_native_provider)
{
return Err(AppError::localized(
"provider.opencode.native_shadows_legacy",
format!("OpenCode 配置中已有原生 V2 格式的供应商「{id}」,请重新导入供应商后再编辑"),
format!(
"OpenCode provider '{id}' has a native V2 declaration. Reload providers before editing it."
),
));
}
"provider"
}
OpenCodeConfigFormat::V2 => {
validate_native_provider(id, &config)?;
"providers"
}
};
// 判空要连「存在但不是对象」一起算:否则写入会静默失效——界面显示添加成功而
// 文件里没有。provider 段是 cc-switch 的投影区,归一化不会碰用户自有的
// model / theme 等顶层配置;providers 是原生声明,格式有误时拒绝而不是清空。
match full_config.get(key) {
Some(Value::Object(_)) => {}
Some(_) if format == OpenCodeConfigFormat::V2 => {
return Err(AppError::localized(
"provider.opencode.providers_not_object",
"OpenCode 配置中的 providers 必须是 JSON 对象",
"OpenCode providers must be a JSON object",
));
}
Some(_) => {
log::warn!("OpenCode 的供应商配置格式有误,将清空原有供应商配置,再保存当前供应商");
full_config[key] = json!({});
}
None => full_config[key] = json!({}),
}
full_config[key][id] = config;
Ok(())
})
.map(|_| ())
}
pub fn remove_provider(id: &str) -> Result<(), AppError> {
edit_config(get_opencode_config_path, |config| {
if let Some(providers) = config.get_mut("provider").and_then(Value::as_object_mut) {
providers.remove(id);
let removed_legacy = config
.get_mut("provider")
.and_then(Value::as_object_mut)
.is_some_and(|providers| providers.remove(id).is_some());
// A valid native declaration shadowed the legacy one; remove both so the
// older one cannot resurface. An invalid one never took effect: leave it.
if let Some(providers) = config.get_mut("providers").and_then(Value::as_object_mut) {
if !removed_legacy || providers.get(id).is_some_and(is_native_provider) {
providers.remove(id);
}
}
})
.map(|_| ())
@@ -327,6 +706,372 @@ mod tests {
std::fs::write(dir.join("opencode.json"), content).expect("write config");
}
#[test]
#[serial_test::serial]
fn native_providers_take_precedence_independently_of_key_order() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
let legacy = r#""provider":{"shared":{"npm":"@ai-sdk/anthropic"},"legacy":{"npm":"@ai-sdk/openai"}}"#;
let native = r#""providers":{"shared":{"settings":{"baseURL":"https://native.example"}},"builtin":{"models":{"alias":{"modelID":"upstream"}}}}"#;
for text in [
format!("{{{legacy},{native}}}"),
format!("{{{native},{legacy}}}"),
] {
write_config(temp.path(), &text);
let providers = get_providers_with_format().unwrap();
assert_eq!(providers.len(), 3);
assert_eq!(providers["legacy"].1, OpenCodeConfigFormat::V1);
assert_eq!(providers["builtin"].1, OpenCodeConfigFormat::V2);
assert_eq!(
providers["shared"].0,
json!({"settings":{"baseURL":"https://native.example"}})
);
}
}
#[test]
#[serial_test::serial]
fn native_provider_write_and_remove_preserve_other_declarations() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
let original = json!({
"model": "shared/model",
"provider": {"shared": {"npm": "@ai-sdk/anthropic"}, "legacy": {"npm": "@ai-sdk/openai"}},
"providers": {"shared": {}, "builtin": {"body": {"metadata": {"keep": true}}}},
"mcp": {"servers": {"example": {"type": "remote", "url": "https://mcp.example"}}},
"plugins": [{"package": "example", "options": {"keep": true}}]
});
write_config(temp.path(), &original.to_string());
let native = json!({"models": {"model": {"variants": [
{"id": "low", "settings": {"reasoningEffort": "low"}},
{"id": "high", "body": {"reasoning": {"effort": "high"}}}
]}}});
set_provider_with_format("shared", native.clone(), OpenCodeConfigFormat::V2).unwrap();
let mut expected = original;
expected["providers"]["shared"] = native;
assert_eq!(read_opencode_config().unwrap(), expected);
let before = std::fs::read(get_opencode_config_path().unwrap()).unwrap();
assert!(set_provider("shared", json!({"npm": "@ai-sdk/anthropic"})).is_err());
assert!(set_provider_with_format(
"shared",
json!({"settings": []}),
OpenCodeConfigFormat::V2
)
.is_err());
assert_eq!(
std::fs::read(get_opencode_config_path().unwrap()).unwrap(),
before
);
remove_provider("shared").unwrap();
expected["providers"]
.as_object_mut()
.unwrap()
.remove("shared");
expected["provider"]
.as_object_mut()
.unwrap()
.remove("shared");
assert_eq!(read_opencode_config().unwrap(), expected);
assert!(!get_providers().unwrap().contains_key("shared"));
}
#[test]
#[serial_test::serial]
fn malformed_native_provider_does_not_hide_legacy_or_rewrite_source() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
let original = r#"{
"provider": {"shared": {"npm": "@ai-sdk/openai"}},
"providers": {"shared": {"package": false}, "valid": {}}
}"#;
write_config(temp.path(), original);
let providers = get_providers_with_format().unwrap();
assert_eq!(providers["shared"].1, OpenCodeConfigFormat::V1);
assert_eq!(providers["valid"].1, OpenCodeConfigFormat::V2);
assert_eq!(
std::fs::read_to_string(get_opencode_config_path().unwrap()).unwrap(),
original
);
let updated = json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "fake-new"}});
set_provider_with_format("shared", updated.clone(), OpenCodeConfigFormat::V1).unwrap();
let mut expected: Value = serde_json::from_str(original).unwrap();
expected["provider"]["shared"] = updated;
assert_eq!(read_opencode_config().unwrap(), expected);
}
#[test]
#[serial_test::serial]
fn malformed_native_nested_fields_fall_back_to_legacy() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
let legacy = json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "fake-old"}});
let mut invalid = vec![
json!({"models": {"m": {"variants": {}}}}),
json!({"settings": {"timeout": "1000"}}),
json!({"settings": {"timeout": true}}),
json!({"settings": {"timeout": null}}),
json!({"settings": {"chunkTimeout": false}}),
json!({"settings": {"compaction": {}}}),
json!({"settings": {"compaction": {"type": "unknown"}}}),
json!({"settings": {"transport": "sse"}}),
json!({"headers": {"X-Tenant": 1}}),
json!({"env": [1]}),
];
for model in [
json!(null),
json!({"modelID": false}),
json!({"family": []}),
json!({"name": null}),
json!({"package": {}}),
json!({"disabled": "false"}),
json!({"settings": null}),
json!({"settings": {"compaction": "native"}}),
json!({"headers": {"X-Tenant": false}}),
json!({"body": []}),
json!({"variants": [null]}),
json!({"variants": [{}]}),
json!({"variants": [{"id": 1}]}),
json!({"variants": [{"id": "low", "settings": {"compaction": {"type": false}}}]}),
json!({"variants": [{"id": "low", "headers": {"X-Tenant": null}}]}),
json!({"variants": [{"id": "low", "body": []}]}),
json!({"limit": []}),
json!({"limit": {"context": "1000"}}),
json!({"limit": {"input": 1.5}}),
json!({"limit": {"output": null}}),
json!({"limit": {"context": 9007199254740992_u64}}),
json!({"capabilities": {"tools": true}}),
json!({"capabilities": {"tools": "true", "input": [], "output": []}}),
json!({"capabilities": {"tools": true, "input": "text", "output": []}}),
json!({"capabilities": {"tools": true, "input": [], "output": [false]}}),
json!({"compatibility": false}),
json!({"compatibility": {"reasoningField": false}}),
json!({"compatibility": {"maxTokensField": "tokens"}}),
json!({"cost": {}}),
json!({"cost": [{"input": 1}]}),
json!({"cost": {"input": "1", "output": 2}}),
json!({"cost": {"input": 1, "output": 2, "cache": {"read": false}}}),
json!({"cost": {"input": 1, "output": 2, "cache": {"write": null}}}),
json!({"cost": {"input": 1, "output": 2, "tier": {"type": "context"}}}),
json!({"cost": {"input": 1, "output": 2, "tier": {"type": "other", "size": 10}}}),
json!({"cost": {"input": 1, "output": 2, "tier": {"type": "context", "size": 1.5}}}),
] {
invalid.push(json!({"models": {"m": model}}));
}
for key in [
"requireReasoning",
"requireFinishReason",
"requireAssistantAfterTool",
"supportsPromptCacheKey",
] {
invalid.push(json!({"models": {"m": {"compatibility": {key: "true"}}}}));
}
for native in invalid {
let original = json!({
"provider": {"shared": legacy},
"providers": {"shared": native, "native-only": native}
});
let source = original.to_string();
write_config(temp.path(), &source);
let providers = get_providers_with_format().unwrap();
assert_eq!(providers.len(), 1, "{native}");
assert_eq!(
providers["shared"],
(legacy.clone(), OpenCodeConfigFormat::V1),
"{native}"
);
assert!(
set_provider_with_format("shared", native.clone(), OpenCodeConfigFormat::V2)
.is_err(),
"{native}"
);
assert_eq!(
std::fs::read_to_string(get_opencode_config_path().unwrap()).unwrap(),
source
);
let updated = json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "fake-new"}});
set_provider_with_format("shared", updated.clone(), OpenCodeConfigFormat::V1).unwrap();
let mut expected = original;
expected["provider"]["shared"] = updated;
assert_eq!(read_opencode_config().unwrap(), expected);
}
}
#[test]
#[serial_test::serial]
fn native_nested_fields_and_unknown_extensions_roundtrip() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
let native = json!({
"name": "Native", "package": "@opencode/ai/providers/openai", "canonical": "openai",
"env": ["TEST_API_KEY"], "extension": [null, {"keep": true}],
"settings": {"timeout": false, "chunkTimeout": 1.5, "transport": "websocket",
"compaction": {"type": "native", "extension": true}, "custom": {"keep": null}},
"headers": {"X-Tenant": "example"}, "body": {"metadata": {"keep": true}},
"models": {"m": {
"modelID": "upstream", "family": "gpt", "name": "Model", "package": "custom",
"disabled": false, "extension": {"keep": [1, 2]},
"settings": {"compaction": {"type": "summary"}, "timeout": "package-specific"},
"headers": {"X-Model": "example"}, "body": {"custom": [null]},
"limit": {"context": 1000.0, "input": -1, "output": 0, "extension": true},
"capabilities": {"tools": false, "input": ["text", "custom"], "output": [], "extension": []},
"compatibility": {"reasoningField": "custom", "requireReasoning": true,
"maxTokensField": "max_tokens", "requireFinishReason": false,
"requireAssistantAfterTool": true, "supportsPromptCacheKey": false, "extension": null},
"cost": [{"input": 1.5, "output": 2, "cache": {"read": 0.5, "extension": true},
"tier": {"type": "context", "size": 1000, "extension": null}, "extension": []}],
"variants": [
{"id": "high", "settings": {"compaction": {"type": "native"}, "transport": "custom"},
"headers": {"X-Variant": "example"}, "body": {"custom": true}, "extension": null},
{"id": "low"}
]
}}
});
for value in [
native,
json!({}),
json!({"settings": {"timeout": 1000.5, "transport": "http"}}),
json!({"models": {"m": {"cost": {"input": 0, "output": 0, "cache": {}}, "variants": []}}}),
json!({"models": {"m": {"cost": [], "limit": {}, "compatibility": {"maxTokensField": "max_completion_tokens"}}}}),
] {
let mut expected = json!({"provider": {"shared": {"npm": "@ai-sdk/openai"}}});
write_config(temp.path(), &expected.to_string());
set_provider_with_format("shared", value.clone(), OpenCodeConfigFormat::V2).unwrap();
expected["providers"] = json!({"shared": value});
assert_eq!(read_opencode_config().unwrap(), expected);
assert_eq!(
get_providers_with_format().unwrap()["shared"],
(value, OpenCodeConfigFormat::V2)
);
assert!(set_provider_with_format(
"shared",
json!({"npm": "@ai-sdk/openai"}),
OpenCodeConfigFormat::V1
)
.is_err());
assert_eq!(read_opencode_config().unwrap(), expected);
}
}
#[test]
#[serial_test::serial]
fn native_provider_with_v1_only_keys_still_takes_precedence() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
// OpenCode ignores keys the native schema does not know, V1-only ones included.
let native = json!({"settings": {"apiKey": "native"}, "options": {}});
let renamed = json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "k"}, "models": {"m": {"name": "M"}}});
let source = json!({
"provider": {"shared": {"npm": "@ai-sdk/openai", "options": {"apiKey": "legacy"}}},
"providers": {"shared": native, "renamed": renamed}
})
.to_string();
write_config(temp.path(), &source);
let providers = get_providers_with_format().unwrap();
assert_eq!(
providers["shared"],
(native.clone(), OpenCodeConfigFormat::V2)
);
assert_eq!(providers["renamed"], (renamed, OpenCodeConfigFormat::V2));
assert!(set_provider_with_format(
"shared",
json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "edited"}}),
OpenCodeConfigFormat::V1
)
.is_err());
assert_eq!(
std::fs::read_to_string(get_opencode_config_path().unwrap()).unwrap(),
source
);
// Format inference still treats V1-only keys as legacy when the source is unknown.
assert_eq!(provider_format(&native, None), OpenCodeConfigFormat::V1);
set_provider_with_format("shared", native.clone(), OpenCodeConfigFormat::V2).unwrap();
assert_eq!(
read_opencode_config().unwrap()["providers"]["shared"],
native
);
}
#[test]
fn native_provider_problem_names_the_invalid_field() {
for (value, path) in [
(json!(5), Some("")),
(json!({}), None),
(json!({"settings": {"timeout": "1"}}), Some("settings")),
(json!({"headers": {"X": 1}}), Some("headers")),
(json!({"models": []}), Some("models")),
(json!({"models": {"m": null}}), Some("models.m")),
(
json!({"models": {"m": {"limit": {"input": "1"}}}}),
Some("models.m.limit"),
),
(
json!({"models": {"m": {"variants": {}}}}),
Some("models.m.variants"),
),
(
json!({"models": {"m": {"settings": {"compaction": {}}}}}),
Some("models.m.settings"),
),
] {
assert_eq!(native_provider_problem(&value).as_deref(), path, "{value}");
}
let err = validate_native_provider("p", &json!({"models": {"m": {"cost": {}}}}))
.unwrap_err()
.to_string();
assert!(err.contains("models.m.cost"), "{err}");
}
#[test]
#[serial_test::serial]
fn removing_a_legacy_provider_leaves_an_invalid_native_declaration() {
let temp = tempfile::tempdir().unwrap();
let _guard = TestHomeGuard::set(temp.path());
write_config(
temp.path(),
r#"{"provider":{"shared":{"npm":"@ai-sdk/openai"}},"providers":{"shared":{"package":false},"orphan":{"package":false}}}"#,
);
remove_provider("shared").unwrap();
remove_provider("orphan").unwrap();
assert_eq!(
read_opencode_config().unwrap(),
json!({"provider": {}, "providers": {"shared": {"package": false}}})
);
}
#[test]
fn provider_fragment_picks_from_a_full_config_like_the_reader() {
let legacy = json!({"npm": "@ai-sdk/openai"});
let full = |native: Value| json!({"provider": {"shared": legacy}, "providers": {"shared": native}});
assert_eq!(
provider_fragment("shared", &full(json!({"package": false})), None).unwrap(),
(&legacy, OpenCodeConfigFormat::V1)
);
assert_eq!(
provider_fragment("shared", &full(json!({})), None).unwrap(),
(&json!({}), OpenCodeConfigFormat::V2)
);
// Without a legacy entry an invalid native one is still chosen, so that
// validation reports its field instead of an unrelated fallback.
assert_eq!(
provider_fragment(
"shared",
&json!({"providers": {"shared": {"package": false}}}),
None
)
.unwrap()
.1,
OpenCodeConfigFormat::V2
);
assert!(provider_fragment("other", &full(json!({})), None).is_err());
assert_eq!(
provider_fragment("shared", &json!({}), Some(OpenCodeConfigFormat::V2)).unwrap(),
(&json!({}), OpenCodeConfigFormat::V2)
);
}
#[test]
#[serial_test::serial]
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
+19
View File
@@ -112,6 +112,11 @@ impl Provider {
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
}
/// The stored OpenCode source format; only native declarations record one.
pub fn opencode_config_format(&self) -> Option<OpenCodeConfigFormat> {
self.meta.as_ref().and_then(|m| m.opencode_config_format)
}
fn claude_base_url_contains(&self, needle: &str) -> bool {
self.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
@@ -555,6 +560,13 @@ pub struct ProviderMeta {
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
pub live_config_managed: Option<bool>,
/// Source format for OpenCode entries whose fields alone are ambiguous
/// (for example, a native V2 built-in provider containing only models).
#[serde(
rename = "opencodeConfigFormat",
skip_serializing_if = "Option::is_none"
)]
pub opencode_config_format: Option<OpenCodeConfigFormat>,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
@@ -943,6 +955,13 @@ requires_openai_auth = true"#
// OpenCode 供应商配置结构
// ============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OpenCodeConfigFormat {
V1,
V2,
}
/// OpenCode 供应商的 settings_config 结构
///
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
+57 -36
View File
@@ -562,27 +562,24 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
use crate::provider::{OpenCodeConfigFormat, OpenCodeProviderConfig};
use serde::Deserialize;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
provider.id
);
// Try to extract from provider.{id}
obj.get("provider")
.and_then(|p| p.get(&provider.id))
.cloned()
.unwrap_or_else(|| provider.settings_config.clone())
} else {
provider.settings_config.clone()
}
} else {
provider.settings_config.clone()
};
// Native declarations may rely on a built-in definition without a package:
// a stored override must stay re-addable after removal from live. The UI
// still requires a definition for a new or renamed ID, as it does for V1.
let (config_to_write, format) = opencode_config::provider_fragment(
&provider.id,
&provider.settings_config,
provider.opencode_config_format(),
)?;
if format == OpenCodeConfigFormat::V2 {
return opencode_config::set_provider_with_format(
&provider.id,
config_to_write.clone(),
format,
);
}
// A new ID cannot inherit an existing provider's built-in definition.
// Check at the write boundary as well as in the UI, including old copies.
@@ -606,12 +603,15 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
// Validate with the existing type, but persist the original fragment:
// the type does not describe every OpenCode provider/model field.
let opencode_config_result =
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
let opencode_config_result = OpenCodeProviderConfig::deserialize(config_to_write);
match opencode_config_result {
Ok(_) => {
opencode_config::set_provider(&provider.id, config_to_write)?;
opencode_config::set_provider_with_format(
&provider.id,
config_to_write.clone(),
format,
)?;
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
@@ -624,7 +624,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
if config_to_write.get("npm").is_some()
|| config_to_write.get("options").is_some()
{
opencode_config::set_provider(&provider.id, config_to_write)?;
opencode_config::set_provider_with_format(
&provider.id,
config_to_write.clone(),
format,
)?;
log::info!(
"OpenCode provider '{}' written as raw JSON to live config",
provider.id
@@ -1189,9 +1193,10 @@ pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<()
/// database with is_current set to false.
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
use crate::provider::{OpenCodeConfigFormat, OpenCodeProviderConfig};
use serde::Deserialize;
let providers = opencode_config::get_providers()?;
let providers = opencode_config::get_providers_with_format()?;
if providers.is_empty() {
return Ok(0);
}
@@ -1200,28 +1205,43 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
let mut updated = 0;
let existing_ids = state.db.get_provider_ids("opencode")?;
for (id, settings_config) in providers {
for (id, (settings_config, format)) in providers {
// Keep validation and display-name extraction separate from persistence.
// Serializing this partial type would discard fields such as api, env,
// and models.<id>.limit.input before they ever reach the database.
let config = match serde_json::from_value::<OpenCodeProviderConfig>(settings_config.clone())
{
Ok(config) => config,
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
continue;
let name = match format {
OpenCodeConfigFormat::V1 => {
match OpenCodeProviderConfig::deserialize(&settings_config) {
Ok(config) => config.name,
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
continue;
}
}
}
OpenCodeConfigFormat::V2 => settings_config
.get("name")
.and_then(Value::as_str)
.map(str::to_owned),
};
let native_format =
(format == OpenCodeConfigFormat::V2).then_some(OpenCodeConfigFormat::V2);
if existing_ids.contains(&id) {
match state.db.get_provider_by_id(&id, "opencode") {
Ok(Some(existing)) => {
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
if existing.settings_config != settings_config || existing.name != display_name
let display_name = name.clone().unwrap_or_else(|| existing.name.clone());
if existing.settings_config != settings_config
|| existing.name != display_name
|| existing.opencode_config_format() != native_format
{
let mut provider = existing;
provider.name = display_name;
provider.settings_config = settings_config;
provider
.meta
.get_or_insert_with(Default::default)
.opencode_config_format = native_format;
if let Err(e) = state.db.save_provider("opencode", &provider) {
log::warn!(
"Failed to update OpenCode provider '{id}' from live config: {e}"
@@ -1241,10 +1261,11 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
}
// Create provider
let display_name = config.name.clone().unwrap_or_else(|| id.clone());
let display_name = name.unwrap_or_else(|| id.clone());
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
opencode_config_format: native_format,
..Default::default()
});
+256 -3
View File
@@ -3586,6 +3586,245 @@ wire_api = "responses"
});
}
#[test]
fn native_opencode_common_config_excludes_provider_credentials() {
let config = json!({
"settings": {"baseURL": "https://native.example", "apiKey": "test", "timeout": 1000},
"body": {"metadata": {"keep": true}}
});
let common: Value = serde_json::from_str(
&ProviderService::extract_opencode_common_config(&config).unwrap(),
)
.unwrap();
assert_eq!(
common,
json!({"settings": {"timeout": 1000}, "body": {"metadata": {"keep": true}}})
);
}
#[test]
#[serial]
fn native_opencode_provider_keeps_source_format_through_database_and_sync() {
use crate::provider::OpenCodeConfigFormat;
for native in [
json!({}),
json!({"models": {"alias": {"modelID": "upstream", "limit": {"input": 10000}}}}),
json!({
"package": "@opencode/ai/providers/anthropic",
"settings": {"baseURL": "https://native.example", "apiKey": "test"},
"headers": {"X-Tenant": "example"},
"body": {"metadata": {"keep": true}},
"models": {"model": {"capabilities": {"tools": true, "input": ["text"], "output": ["text"]}, "variants": [
{"id": "low", "settings": {"reasoningEffort": "low"}},
{"id": "high", "settings": {"reasoningEffort": "high"}}
]}}
}),
] {
with_test_home(|state, _| {
// Also exercise upgrading an existing DB entry with no source metadata.
let existing = Provider::with_id(
"anthropic".into(),
"Existing name".into(),
native.clone(),
None,
);
state.db.save_provider("opencode", &existing).unwrap();
crate::opencode_config::set_provider_with_format(
"anthropic",
native.clone(),
OpenCodeConfigFormat::V2,
)
.unwrap();
assert_eq!(import_opencode_providers_from_live(state).unwrap(), 1);
assert_eq!(import_opencode_providers_from_live(state).unwrap(), 0);
let mut saved = state
.db
.get_provider_by_id("anthropic", "opencode")
.unwrap()
.unwrap();
assert_eq!(saved.name, "Existing name");
assert_eq!(saved.settings_config, native);
assert_eq!(
saved.meta.as_ref().unwrap().opencode_config_format,
Some(OpenCodeConfigFormat::V2)
);
// Source metadata must survive removal and re-enabling even for {}.
crate::opencode_config::remove_provider("anthropic").unwrap();
live::write_live_snapshot(&AppType::OpenCode, &saved).unwrap();
assert_eq!(
crate::opencode_config::read_opencode_config().unwrap()["providers"]
["anthropic"],
native
);
saved.settings_config["name"] = json!("Edited native provider");
let expected = saved.settings_config.clone();
ProviderService::update(state, AppType::OpenCode, None, saved).unwrap();
let config = crate::opencode_config::read_opencode_config().unwrap();
assert_eq!(config["providers"]["anthropic"], expected);
assert!(config.get("provider").is_none());
let mut full = Provider::with_id(
"anthropic".into(),
"Full config".into(),
json!({"providers": {"anthropic": expected}}),
None,
);
live::write_live_snapshot(&AppType::OpenCode, &full).unwrap();
let before =
std::fs::read(crate::opencode_config::get_opencode_config_path().unwrap())
.unwrap();
full.settings_config = json!({"providers": {"other": {}}});
assert!(live::write_live_snapshot(&AppType::OpenCode, &full).is_err());
assert_eq!(
std::fs::read(crate::opencode_config::get_opencode_config_path().unwrap())
.unwrap(),
before
);
});
}
}
#[test]
#[serial]
fn malformed_native_opencode_provider_allows_legacy_import_update_and_sync() {
use crate::provider::OpenCodeConfigFormat;
for native in [
json!({"package": false}),
json!({"models": {"m": {"variants": {}}}}),
json!({"settings": {"timeout": "1000"}}),
json!({"models": {"m": {"limit": {"input": "1000"}}}}),
json!({"models": {"m": {"capabilities": {"tools": true}}}}),
] {
for existing in [false, true] {
with_test_home(|state, _| {
let provider = opencode_provider("shared");
if existing {
state.db.save_provider("opencode", &provider).unwrap();
}
let mut expected = json!({
"provider": {"shared": provider.settings_config},
"providers": {"shared": native},
"model": "shared/gpt-4o"
});
write_json_file(
&crate::opencode_config::get_opencode_config_path().unwrap(),
&expected,
)
.unwrap();
import_opencode_providers_from_live(state).unwrap();
let mut saved = state
.db
.get_provider_by_id("shared", "opencode")
.unwrap()
.unwrap();
assert_eq!(saved.settings_config, provider.settings_config, "{native}");
assert_ne!(
saved
.meta
.as_ref()
.and_then(|meta| meta.opencode_config_format),
Some(OpenCodeConfigFormat::V2)
);
assert_eq!(import_opencode_providers_from_live(state).unwrap(), 0);
saved.settings_config["options"]["apiKey"] = json!("fake-new");
ProviderService::update(state, AppType::OpenCode, None, saved.clone()).unwrap();
expected["provider"]["shared"] = saved.settings_config.clone();
assert_eq!(
crate::opencode_config::read_opencode_config().unwrap(),
expected
);
assert_eq!(
state
.db
.get_provider_by_id("shared", "opencode")
.unwrap()
.unwrap()
.settings_config,
saved.settings_config
);
saved.settings_config["options"]["apiKey"] = json!("fake-synced");
state.db.save_provider("opencode", &saved).unwrap();
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
.unwrap();
expected["provider"]["shared"] = saved.settings_config;
assert_eq!(
crate::opencode_config::read_opencode_config().unwrap(),
expected
);
assert_eq!(import_opencode_providers_from_live(state).unwrap(), 0);
});
}
}
}
#[test]
#[serial]
fn invalid_native_opencode_edit_is_rejected_before_saving() {
use crate::provider::OpenCodeConfigFormat;
with_test_home(|state, _| {
let native = json!({"settings": {"baseURL": "https://a.example"}});
crate::opencode_config::set_provider_with_format(
"native",
native.clone(),
OpenCodeConfigFormat::V2,
)
.unwrap();
import_opencode_providers_from_live(state).unwrap();
let mut edited = state
.db
.get_provider_by_id("native", "opencode")
.unwrap()
.unwrap();
// OpenCode requires tools, input and output together.
edited.settings_config = json!({"settings": {"baseURL": "https://b.example"}, "models": {"m": {"capabilities": {"tools": true}}}});
let err = ProviderService::update(state, AppType::OpenCode, None, edited.clone())
.unwrap_err()
.to_string();
assert!(err.contains("models.m.capabilities"), "{err}");
edited.id = "added".into();
assert!(ProviderService::add(state, AppType::OpenCode, edited, true).is_err());
let saved = state
.db
.get_provider_by_id("native", "opencode")
.unwrap()
.unwrap();
assert_eq!(saved.settings_config, native);
assert!(state
.db
.get_provider_by_id("added", "opencode")
.unwrap()
.is_none());
assert_eq!(
crate::opencode_config::read_opencode_config().unwrap()["providers"]["native"],
native
);
});
}
#[test]
#[serial]
fn full_opencode_config_writes_legacy_entry_behind_invalid_native() {
with_test_home(|_, _| {
let legacy =
json!({"npm": "@ai-sdk/openai", "options": {"apiKey": "v1"}, "models": {"m": {}}});
let full = Provider::with_id(
"shared".into(),
"Full config".into(),
json!({"provider": {"shared": legacy}, "providers": {"shared": {"package": false}}}),
None,
);
live::write_live_snapshot(&AppType::OpenCode, &full).unwrap();
let config = crate::opencode_config::read_opencode_config().unwrap();
assert_eq!(config["provider"]["shared"], legacy);
assert!(config.get("providers").is_none());
});
}
#[test]
#[serial]
fn opencode_provider_roundtrip_preserves_fields_on_import_and_update() {
@@ -6744,9 +6983,11 @@ impl ProviderService {
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
for key in ["options", "settings"] {
if let Some(options) = obj.get_mut(key).and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
}
// Keep npm and models as they might be common
}
@@ -6988,6 +7229,18 @@ impl ProviderService {
"OpenCode configuration must be a JSON object",
));
}
// The row is saved before the live write, which would refuse an invalid
// native declaration only after the database already holds it.
if !matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim")) {
let (fragment, format) = crate::opencode_config::provider_fragment(
&provider.id,
&provider.settings_config,
provider.opencode_config_format(),
)?;
if format == crate::provider::OpenCodeConfigFormat::V2 {
crate::opencode_config::validate_native_provider(&provider.id, fragment)?;
}
}
}
AppType::OpenClaw => {
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
+21 -8
View File
@@ -71,6 +71,10 @@ import { APP_DISPLAY_NAME, AppGlyph } from "@/components/shell/AppGlyph";
import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import {
hasOpencodeDefinition,
isNativeOpencodeConfig,
} from "@/components/providers/forms/helpers/opencodeFormUtils";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { discardUnsavedChanges, hasUnsavedChanges } from "@/lib/unsavedChanges";
@@ -853,16 +857,25 @@ function App() {
provider.category !== "omo" &&
provider.category !== "omo-slim"
) {
const { npm, models } = provider.settingsConfig;
// A copy gets a new ID, so it cannot inherit a built-in definition.
// Native V2 declarations name their package in `package`, not `npm`.
const isNative = isNativeOpencodeConfig(
JSON.stringify(provider.settingsConfig),
provider.meta?.opencodeConfigFormat,
);
if (
typeof npm !== "string" ||
!npm.trim() ||
!models ||
typeof models !== "object" ||
Array.isArray(models) ||
Object.keys(models).length === 0
!hasOpencodeDefinition(
provider.settingsConfig,
isNative ? "package" : "npm",
)
) {
toast.error(t("opencode.duplicateRequiresDefinition"));
toast.error(
t(
isNative
? "opencode.duplicateRequiresNativeDefinition"
: "opencode.duplicateRequiresDefinition",
),
);
return;
}
}
+69 -31
View File
@@ -124,6 +124,8 @@ import {
GEMINI_DEFAULT_CONFIG,
OPENCODE_DEFAULT_CONFIG,
OPENCLAW_DEFAULT_CONFIG,
hasNativeOpencodeDefinition,
isNativeOpencodeConfig,
} from "./helpers/opencodeFormUtils";
import { HERMES_DEFAULT_CONFIG } from "./hooks/useHermesFormState";
import { resolveManagedAccountId } from "@/lib/authBinding";
@@ -1028,13 +1030,36 @@ function ProviderFormFull({
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const isNativeOpencode =
appId === "opencode" &&
!isAnyOmoCategory &&
isNativeOpencodeConfig(
form.watch("settingsConfig"),
initialData?.meta?.opencodeConfigFormat,
);
const isExistingNativeOpencodeKey =
isNativeOpencode && providerId === opencodeForm.opencodeProviderKey;
// Existing native IDs are kept exactly as OpenCode accepted them.
const isOpencodeProviderKeyInvalid =
opencodeForm.opencodeProviderKey.trim() !== "" &&
!isExistingNativeOpencodeKey &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeForm.opencodeProviderKey);
const canKeepExistingOpencodeOverride =
const keepsOpencodeProviderId =
isEditMode &&
!!providerId &&
opencodeForm.opencodeProviderKey === providerId &&
opencodeForm.opencodeProviderKey === providerId;
const canKeepExistingOpencodeOverride =
keepsOpencodeProviderId &&
isOpencodeLiveProviderIdsSuccess &&
opencodeLiveProviderIds.includes(providerId);
opencodeLiveProviderIds.includes(opencodeForm.opencodeProviderKey);
// Unlike V1, no older version copied native rows without their definition
// (copies now require one), so a stored native row keeping its ID may stay
// package-less after removal from the live config.
const canInheritOpencodeDefinition =
canKeepExistingOpencodeOverride ||
(keepsOpencodeProviderId &&
initialData?.meta?.opencodeConfigFormat === "v2");
const initialOmoSettings =
appId === "opencode" &&
@@ -1217,7 +1242,7 @@ function ProviderFormFull({
toast.error(t("opencode.providerKeyRequired"));
return;
}
if (!keyPattern.test(opencodeForm.opencodeProviderKey)) {
if (isOpencodeProviderKeyInvalid) {
toast.error(t("opencode.providerKeyInvalid"));
return;
}
@@ -1236,14 +1261,26 @@ function ProviderFormFull({
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
// Only an unchanged ID already in the live config may inherit defaults.
if (
!canKeepExistingOpencodeOverride &&
(!opencodeForm.opencodeNpm.trim() ||
Object.keys(opencodeForm.opencodeModels).length === 0)
) {
toast.error(t("opencode.customProviderRequired"));
return;
// Only an existing override keeping its ID may inherit defaults.
// Native V2 declarations name their package in `package`, not `npm`.
if (!canInheritOpencodeDefinition) {
const hasDefinition = isNativeOpencode
? hasNativeOpencodeDefinition(
form.getValues("settingsConfig"),
opencodeForm.opencodeProviderKey,
)
: !!opencodeForm.opencodeNpm.trim() &&
Object.keys(opencodeForm.opencodeModels).length > 0;
if (!hasDefinition) {
toast.error(
t(
isNativeOpencode
? "opencode.nativeCustomProviderRequired"
: "opencode.customProviderRequired",
),
);
return;
}
}
}
@@ -1782,6 +1819,7 @@ function ProviderFormFull({
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
opencodeConfigFormat: isNativeOpencode ? "v2" : undefined,
// Claude Code、Codex、Gemini CLI 的通用配置片段已冻结:沿用行里原有的标记,新增时
// 由后端写 true(兼容旧版)。
commonConfigEnabled:
@@ -2253,10 +2291,7 @@ function ProviderFormFull({
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked) ||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
))
isOpencodeProviderKeyInvalid
? "border-destructive"
: ""
}
@@ -2269,23 +2304,17 @@ function ProviderFormFull({
{t("opencode.providerKeyDuplicate")}
</p>
)}
{opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
) && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyInvalid")}
</p>
)}
{isOpencodeProviderKeyInvalid && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) && !isProviderKeyLocked
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
!isOpencodeProviderKeyInvalid && (
<p className="text-xs text-fg-2">
{isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
@@ -2620,7 +2649,7 @@ function ProviderFormFull({
/>
)}
{appId === "opencode" && !isAnyOmoCategory && (
{appId === "opencode" && !isAnyOmoCategory && !isNativeOpencode && (
<OpenCodeFormFields
allowBuiltinDefaults={canKeepExistingOpencodeOverride}
npm={opencodeForm.opencodeNpm}
@@ -2762,17 +2791,26 @@ function ProviderFormFull({
<Label htmlFor="settingsConfig">
{t("provider.configJson")}
</Label>
{isNativeOpencode && (
<p className="text-sm text-fg-2">
{t("opencode.nativeConfigHint")}
</p>
)}
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={`{
placeholder={
isNativeOpencode
? "{}"
: `{
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://your-api-endpoint.com",
"apiKey": "your-api-key-here"
},
"models": {}
}`}
}`
}
rows={3}
showValidation={true}
language="json"
@@ -1,4 +1,5 @@
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
import { isPlainObject } from "@/lib/requestOverrides";
// ── Default configs ──────────────────────────────────────────────────
@@ -68,6 +69,75 @@ export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
// ── Pure functions ───────────────────────────────────────────────────
// Keys only a V1 declaration has, and keys only a native one has. Mirrors
// provider_format in src-tauri/src/opencode_config.rs.
const OPENCODE_LEGACY_ONLY_KEYS = ["npm", "options", "api"];
const OPENCODE_NATIVE_ONLY_KEYS = [
"package",
"settings",
"headers",
"body",
"canonical",
];
function parseJson(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return undefined;
}
}
/** Keep native declarations out of the V1 structured editor. Source metadata
* disambiguates built-in overrides containing only models (or an empty object).
* A pasted full config holding `providers` counts as native too.
*/
export function isNativeOpencodeConfig(
json: string,
source?: "v1" | "v2",
): boolean {
if (source) return source === "v2";
const value = parseJson(json);
if (!isPlainObject(value)) return false;
if (OPENCODE_LEGACY_ONLY_KEYS.some((key) => key in value)) return false;
return [...OPENCODE_NATIVE_ONLY_KEYS, "providers"].some(
(key) => key in value,
);
}
/** A declaration that does not rely on a built-in definition: it names a
* package (`npm` for V1, `package` for native) and at least one model.
*/
export function hasOpencodeDefinition(
declaration: unknown,
packageKey: "npm" | "package",
): boolean {
if (!isPlainObject(declaration)) return false;
const pkg = declaration[packageKey];
const { models } = declaration;
return (
typeof pkg === "string" &&
pkg.trim() !== "" &&
isPlainObject(models) &&
Object.keys(models).length > 0
);
}
/** The native form's check: a pasted full config holds the declaration under
* `providers.<id>`, where the backend also takes it from.
*/
export function hasNativeOpencodeDefinition(
json: string,
providerId: string,
): boolean {
const value = parseJson(json);
const providers = isPlainObject(value) ? value.providers : undefined;
const declaration = isPlainObject(providers)
? (providers[providerId] ?? value)
: value;
return hasOpencodeDefinition(declaration, "package");
}
export function isKnownOpencodeOptionKey(key: string): boolean {
return OPENCODE_KNOWN_OPTION_KEYS.includes(
key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number],
+3
View File
@@ -1850,11 +1850,14 @@
},
"opencode": {
"npmPackage": "API Format",
"nativeConfigHint": "Edit native OpenCode V2 configuration as JSON below. Provider and model fields are preserved when saving.",
"selectPackage": "Select API format",
"npmPackageHint": "Select the API format for the AI service",
"builtinDefaults": "Use OpenCode built-in provider defaults",
"customProviderRequired": "A new provider ID requires an npm package and at least one model. Only an existing ID in the live config may inherit defaults.",
"duplicateRequiresDefinition": "This provider is missing an npm package or model definitions and cannot be duplicated directly. Specify an npm package and at least one model before copying it as a custom provider.",
"duplicateRequiresNativeDefinition": "This native V2 provider is missing a package or model definitions and cannot be duplicated directly. Add a package and at least one model before copying it under a new ID.",
"nativeCustomProviderRequired": "A new provider ID requires a package and at least one model. Only an existing ID in the live config may inherit defaults.",
"baseUrl": "Base URL",
"baseUrlHint": "Custom API endpoint URL",
"models": "Models",
+3
View File
@@ -1850,11 +1850,14 @@
},
"opencode": {
"npmPackage": "API フォーマット",
"nativeConfigHint": "OpenCode V2 ネイティブ設定は下の JSON で編集します。保存時にプロバイダーとモデルのフィールドを保持します。",
"selectPackage": "API フォーマットを選択",
"npmPackageHint": "AI サービスの API フォーマットを選択",
"builtinDefaults": "OpenCode 組み込みプロバイダーの既定値を使用",
"customProviderRequired": "新しいプロバイダー ID には npm パッケージと少なくとも1つのモデルが必要です。設定に既に存在する同じ ID のプロバイダーのみ既定の定義を継承できます。",
"duplicateRequiresDefinition": "npm パッケージまたはモデル定義が不足しているため、そのまま複製できません。npm パッケージと少なくとも1つのモデルを指定してから、カスタムプロバイダーとして複製してください。",
"duplicateRequiresNativeDefinition": "この V2 ネイティブプロバイダーには package またはモデル定義が不足しているため、そのまま複製できません。package と少なくとも1つのモデルを指定してから、新しい ID で複製してください。",
"nativeCustomProviderRequired": "新しいプロバイダー ID には package と少なくとも1つのモデルが必要です。設定に既に存在する同じ ID のプロバイダーのみ既定の定義を継承できます。",
"baseUrl": "Base URL",
"baseUrlHint": "カスタム API エンドポイント URL",
"models": "モデル設定",
+3
View File
@@ -1851,11 +1851,14 @@
},
"opencode": {
"npmPackage": "API 格式",
"nativeConfigHint": "請在下方以 JSON 編輯 OpenCode V2 原生設定。儲存時保留供應商和模型欄位。",
"selectPackage": "選擇 API 格式",
"npmPackageHint": "選擇 AI 服務的 API 格式",
"builtinDefaults": "使用 OpenCode 內建供應商預設值",
"customProviderRequired": "新的供應商識別碼需要填寫 npm 套件和至少一個模型;只有設定中已有的同名供應商可以沿用預設定義",
"duplicateRequiresDefinition": "此供應商缺少 npm 套件或模型定義,無法直接複製。請先補齊 npm 套件和至少一個模型,再複製為自訂供應商。",
"duplicateRequiresNativeDefinition": "此 V2 原生供應商缺少 package 或模型定義,無法直接複製。請先補齊 package 和至少一個模型,再以新的識別碼複製。",
"nativeCustomProviderRequired": "新的供應商識別碼需要填寫 package 和至少一個模型;只有設定中已有的同名供應商可以沿用預設定義",
"baseUrl": "Base URL",
"baseUrlHint": "自訂 API 端點位址",
"models": "模型設定",
+3
View File
@@ -1850,11 +1850,14 @@
},
"opencode": {
"npmPackage": "接口格式",
"nativeConfigHint": "请在下方以 JSON 编辑 OpenCode V2 原生配置。保存时保留供应商和模型字段。",
"selectPackage": "选择接口格式",
"npmPackageHint": "选择 AI 服务的 API 接口格式",
"builtinDefaults": "使用 OpenCode 内置供应商默认值",
"customProviderRequired": "新的供应商标识需要填写 npm 包和至少一个模型;只有配置中已有的同名供应商可以沿用默认定义",
"duplicateRequiresDefinition": "此供应商缺少 npm 包或模型定义,无法直接复制。请先补全 npm 包和至少一个模型,再复制为自定义供应商。",
"duplicateRequiresNativeDefinition": "此 V2 原生供应商缺少 package 或模型定义,无法直接复制。请先补全 package 和至少一个模型,再以新的标识复制。",
"nativeCustomProviderRequired": "新的供应商标识需要填写 package 和至少一个模型;只有配置中已有的同名供应商可以沿用默认定义",
"baseUrl": "Base URL",
"baseUrlHint": "自定义 API 端点地址",
"models": "模型配置",
+2
View File
@@ -228,6 +228,8 @@ export interface ProviderMeta {
localProxyRequestOverrides?: LocalProxyRequestOverrides;
// Whether this provider is currently projected into an additive app's live config.
liveConfigManaged?: boolean;
// Source format is needed for package-less OpenCode built-in overrides.
opencodeConfigFormat?: "v1" | "v2";
// 供应商类型(用于识别 Copilot 等特殊供应商)
providerType?: string;
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
@@ -0,0 +1,250 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { http, HttpResponse } from "msw";
import { ProviderForm } from "@/components/providers/forms/ProviderForm";
import { createTestQueryClient } from "../utils/testQueryClient";
import { server } from "../msw/server";
import { setLiveProviderIds, setSettings } from "../msw/state";
const toastError = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { error: toastError, success: vi.fn() } }));
vi.mock("@/components/JsonEditor", () => ({
default: ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => (
<textarea
aria-label="raw-config"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
function renderNativeForm(
config: Record<string, unknown>,
native = true,
providerId = "anthropic",
liveProviderIds = [providerId],
) {
setLiveProviderIds("opencode", liveProviderIds);
const client = createTestQueryClient();
const onSubmit = vi.fn();
const view = render(
<QueryClientProvider client={client}>
<ProviderForm
appId="opencode"
providerId={providerId}
initialData={{
name: "Anthropic override",
settingsConfig: config,
meta: native ? { opencodeConfigFormat: "v2" } : undefined,
}}
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
return { ...view, client, onSubmit };
}
describe("native OpenCode provider form", () => {
beforeEach(() => {
toastError.mockReset();
setSettings({ commonConfigConfirmed: true });
server.use(
http.post("http://tauri.local/auth_get_status", () =>
HttpResponse.json({ authenticated: false, accounts: [] }),
),
http.post("http://tauri.local/get_common_config_snippet", () =>
HttpResponse.json(""),
),
);
});
it.each([{}, { models: { alias: { modelID: "upstream" } } }])(
"saves a package-less built-in override without injecting V1 fields: %j",
async (config) => {
const { container, client, onSubmit } = renderNativeForm(config);
expect(container.querySelector("#opencode-npm")).toBeNull();
expect(screen.getByText("opencode.nativeConfigHint")).toBeInTheDocument();
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0];
expect(JSON.parse(submitted.settingsConfig)).toEqual(config);
expect(submitted.meta.opencodeConfigFormat).toBe("v2");
},
);
it("edits native JSON without applying the V1 structured editor", async () => {
const config = {
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: "https://native.example", apiKey: "test" },
headers: { "X-Tenant": "example" },
body: { metadata: { keep: true } },
models: {
model: { variants: [{ id: "low" }, { id: "high" }] },
},
};
const { container, client, onSubmit } = renderNativeForm(config, false);
expect(container.querySelector("#opencode-npm")).toBeNull();
const edited = {
...config,
settings: { ...config.settings, baseURL: "https://edited.example" },
};
fireEvent.change(screen.getByRole("textbox", { name: "raw-config" }), {
target: { value: JSON.stringify(edited) },
});
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(
edited,
);
expect(onSubmit.mock.calls[0][0].meta.opencodeConfigFormat).toBe("v2");
});
it("continues to expose the structured editor for a V1 provider", () => {
const { container } = renderNativeForm(
{
npm: "@ai-sdk/anthropic",
options: {},
models: { model: { name: "Model" } },
},
false,
);
expect(container.querySelector("#opencode-npm")).toBeInTheDocument();
expect(
screen.queryByText("opencode.nativeConfigHint"),
).not.toBeInTheDocument();
});
it("keeps an existing native provider ID even when it contains underscores", async () => {
const { client, onSubmit } = renderNativeForm({}, true, "custom_provider");
expect(
screen.queryByText("opencode.providerKeyInvalid"),
).not.toBeInTheDocument();
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].providerKey).toBe("custom_provider");
});
it("keeps a stored native override editable after removal from live", async () => {
const config = { settings: { apiKey: "edited" } };
const { client, onSubmit } = renderNativeForm(
config,
true,
"anthropic",
[],
);
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(
config,
);
expect(toastError).not.toHaveBeenCalled();
});
it.each([{}, { package: "aisdk:@ai-sdk/anthropic", models: {} }])(
"requires a package and models for a renamed native ID: %j",
async (config) => {
const { client, onSubmit } = renderNativeForm(
config,
true,
"anthropic",
[],
);
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.change(document.getElementById("opencode-key")!, {
target: { value: "anthropic-copy" },
});
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith(
"opencode.nativeCustomProviderRequired",
),
);
expect(onSubmit).not.toHaveBeenCalled();
},
);
it("does not exempt a row that only looks native", async () => {
// Without stored V2 metadata this may be an old copy missing its definition.
const { client, onSubmit } = renderNativeForm(
{ settings: { apiKey: "test" } },
false,
"opencode-go-copy",
[],
);
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith(
"opencode.nativeCustomProviderRequired",
),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it.each([
["custom", true],
["other", false],
])(
"reads the definition of a pasted full config under providers.%s",
async (providerKey, accepted) => {
setLiveProviderIds("opencode", []);
const client = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={client}>
<ProviderForm
appId="opencode"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
const full = {
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { "gpt-5": {} },
},
},
};
fireEvent.change(screen.getByRole("textbox", { name: "raw-config" }), {
target: { value: JSON.stringify(full) },
});
fireEvent.change(document.getElementById("opencode-key")!, {
target: { value: providerKey },
});
fireEvent.change(document.querySelector('input[name="name"]')!, {
target: { value: "Custom" },
});
await waitFor(() => expect(client.isFetching()).toBe(0));
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
if (accepted) {
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(
full,
);
expect(toastError).not.toHaveBeenCalled();
} else {
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith(
"opencode.nativeCustomProviderRequired",
),
);
expect(onSubmit).not.toHaveBeenCalled();
}
},
);
});
+75
View File
@@ -550,6 +550,81 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).not.toHaveBeenCalled();
});
it.each([
{ settingsConfig: { settings: { baseURL: "https://native.example" } } },
{
settingsConfig: { models: { "glm-5": { name: "GLM 5" } } },
meta: { opencodeConfigFormat: "v2" as const },
},
])(
"blocks incomplete native OpenCode copies with a V2 message: %j",
async ({ settingsConfig, meta }) => {
localStorage.setItem("cc-switch-last-app", "opencode");
setProviders("opencode", {
native: {
id: "native",
name: "Native",
settingsConfig,
meta,
sortIndex: 0,
},
});
setCurrentProviderId("opencode", "native");
setLiveProviderIds("opencode", ["native"]);
const add = vi.spyOn(providersApi, "add");
try {
const { default: App } = await import("@/App");
renderApp(App);
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"native",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith(
"opencode.duplicateRequiresNativeDefinition",
),
);
expect(add).not.toHaveBeenCalled();
} finally {
add.mockRestore();
}
},
);
it("duplicates complete native OpenCode providers using an unused ID", async () => {
localStorage.setItem("cc-switch-last-app", "opencode");
setProviders("opencode", {
native: {
id: "native",
name: "Native",
sortIndex: 0,
settingsConfig: {
package: "@opencode/ai/providers/openai",
models: { "gpt-5": { name: "GPT 5" } },
},
meta: { opencodeConfigFormat: "v2" },
},
});
setCurrentProviderId("opencode", "native");
setLiveProviderIds("opencode", ["native-copy"]);
const { default: App } = await import("@/App");
renderApp(App);
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"native",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"native-copy-2",
),
);
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("duplicates MiniMax Code providers under a generated unused key", async () => {
localStorage.setItem("cc-switch-last-app", "mcode");
const provider = (id: string, name: string) => ({