mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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:
1 parent
f68fe30fd9
commit
3af7482317
14 files changed
+1592
-94
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "モデル設定",
|
||||
|
||||
@@ -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": "模型設定",
|
||||
|
||||
@@ -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": "模型配置",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user