mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-22 13:33:29 +08:00
fix(hermes): prevent YAML pollution and drop of OAuth mcp auth
DeepLink Hermes import was emitting camelCase (baseUrl / apiKey / apiMode) that the Hermes runtime does not recognise, poisoning `custom_providers:` entries on activation. The MCP sync path was also stripping `auth: oauth` on round-trip, silently downgrading OAuth-type servers to unauthenticated calls. The Hermes deeplink branch now emits snake_case via a dedicated builder; `sanitize_hermes_provider_keys` runs on both `set_provider` and `get_providers` so legacy DB records heal on next access. `HERMES_EXTRA_FIELDS` preserves `auth`. The `api_mode` dropdown gains `codex_responses` (Copilot / OpenCode), and the schema-migrated warning copy no longer hard-codes "v12" (upstream `_config_version` is now 19).
This commit is contained in:
@@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink;
|
||||
///
|
||||
/// Represents a parsed ccswitch:// URL ready for processing.
|
||||
/// This struct contains all possible fields for all resource types.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeepLinkImportRequest {
|
||||
/// Protocol version (e.g., "v1")
|
||||
|
||||
@@ -146,7 +146,8 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -393,7 +394,7 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// Build settings for additive-mode apps (OpenClaw, Hermes)
|
||||
/// Build settings for OpenClaw (camelCase live config).
|
||||
/// Format: { baseUrl, apiKey, api, models }
|
||||
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
@@ -420,6 +421,42 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V
|
||||
json!(config)
|
||||
}
|
||||
|
||||
/// Build Hermes provider settings (snake_case YAML-native fields).
|
||||
///
|
||||
/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode`
|
||||
/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`).
|
||||
/// Emitting camelCase here — as the OpenClaw path does — would poison the
|
||||
/// YAML with unknown root fields the Hermes runtime ignores.
|
||||
///
|
||||
/// `api_mode` is deliberately omitted so Hermes auto-detects the protocol
|
||||
/// from the endpoint; callers can still override via the UI later.
|
||||
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) {
|
||||
config.insert("name".to_string(), json!(name));
|
||||
}
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
config.insert("base_url".to_string(), json!(endpoint));
|
||||
}
|
||||
|
||||
if let Some(api_key) = &request.api_key {
|
||||
config.insert("api_key".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
json!([{ "id": model, "name": model }]),
|
||||
);
|
||||
}
|
||||
|
||||
json!(config)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -709,3 +746,87 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hermes_request() -> DeepLinkImportRequest {
|
||||
DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("hermes".to_string()),
|
||||
name: Some("MyHermes".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
model: Some("anthropic/claude-opus-4-7".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_emits_snake_case() {
|
||||
let settings = build_hermes_settings(&hermes_request());
|
||||
let obj = settings.as_object().expect("settings must be object");
|
||||
|
||||
assert_eq!(obj.get("name").unwrap(), "MyHermes");
|
||||
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1");
|
||||
assert_eq!(obj.get("api_key").unwrap(), "sk-test");
|
||||
|
||||
// camelCase and legacy fields must NOT be present
|
||||
assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl");
|
||||
assert!(obj.get("apiKey").is_none(), "no camelCase apiKey");
|
||||
assert!(obj.get("api").is_none(), "no legacy 'api' field");
|
||||
|
||||
// models array with the deeplink model id
|
||||
let models = obj.get("models").unwrap().as_array().unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_omits_api_mode_for_auto_detect() {
|
||||
let settings = build_hermes_settings(&hermes_request());
|
||||
assert!(
|
||||
settings.as_object().unwrap().get("api_mode").is_none(),
|
||||
"api_mode must be omitted so Hermes auto-detects"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_skips_missing_optional_fields() {
|
||||
let request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("hermes".to_string()),
|
||||
name: Some("Minimal".to_string()),
|
||||
endpoint: None,
|
||||
api_key: None,
|
||||
model: None,
|
||||
..Default::default()
|
||||
};
|
||||
let settings = build_hermes_settings(&request);
|
||||
let obj = settings.as_object().unwrap();
|
||||
|
||||
assert_eq!(obj.get("name").unwrap(), "Minimal");
|
||||
assert!(obj.get("base_url").is_none());
|
||||
assert!(obj.get("api_key").is_none());
|
||||
assert!(obj.get("models").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_still_uses_camel_case() {
|
||||
// OpenClaw's live config natively uses camelCase; guard against a
|
||||
// refactor accidentally flipping it to snake_case.
|
||||
let request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("openclaw".to_string()),
|
||||
name: Some("c".to_string()),
|
||||
endpoint: Some("https://api.example.com".to_string()),
|
||||
api_key: Some("k".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let settings = build_additive_app_settings(&request);
|
||||
let obj = settings.as_object().unwrap();
|
||||
assert!(obj.contains_key("baseUrl"));
|
||||
assert!(obj.contains_key("apiKey"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,43 @@ fn models_dict_to_array(dict: serde_json::Map<String, serde_json::Value>) -> ser
|
||||
serde_json::Value::Array(out)
|
||||
}
|
||||
|
||||
/// Rewrite historical camelCase keys to Hermes' snake_case schema.
|
||||
///
|
||||
/// Older DeepLink import paths emitted `baseUrl` / `apiKey` / `apiMode` /
|
||||
/// `maxTokens` / `contextLength`, which do not belong to Hermes'
|
||||
/// `_VALID_CUSTOM_PROVIDER_FIELDS` set. Writing those raw to YAML silently
|
||||
/// poisons `custom_providers:` entries. This sanitiser runs defensively on
|
||||
/// every `set_provider` call so stored data heals on the next activation;
|
||||
/// unknown keys pass through untouched to keep forward-compat with new
|
||||
/// Hermes fields (e.g. `request_timeout_seconds`).
|
||||
fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) {
|
||||
const KEY_ALIASES: &[(&str, &str)] = &[
|
||||
("baseUrl", "base_url"),
|
||||
("apiKey", "api_key"),
|
||||
("apiMode", "api_mode"),
|
||||
("maxTokens", "max_tokens"),
|
||||
("contextLength", "context_length"),
|
||||
];
|
||||
// Legacy DeepLink emitted `api: "openai-completions"` which is neither a
|
||||
// Hermes field nor mappable to `api_mode`.
|
||||
const LEGACY_FIELDS_TO_DROP: &[&str] = &["api"];
|
||||
|
||||
let Some(obj) = config.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (from, to) in KEY_ALIASES {
|
||||
if let Some(val) = obj.remove(*from) {
|
||||
// snake_case wins when both are present; stale camelCase is dropped.
|
||||
obj.entry((*to).to_string()).or_insert(val);
|
||||
}
|
||||
}
|
||||
|
||||
for field in LEGACY_FIELDS_TO_DROP {
|
||||
obj.remove(*field);
|
||||
}
|
||||
}
|
||||
|
||||
/// If `config.models` is a JSON array, convert it in-place to the dict shape.
|
||||
/// No-op when `models` is absent or already a dict.
|
||||
fn normalize_provider_models_for_write(config: &mut serde_json::Value) {
|
||||
@@ -491,6 +528,10 @@ pub fn get_providers() -> Result<serde_json::Map<String, serde_json::Value>, App
|
||||
if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
|
||||
match yaml_to_json(item) {
|
||||
Ok(mut json_val) => {
|
||||
// Heal legacy camelCase records (from older DeepLink
|
||||
// imports) before the UI sees them, so editing doesn't
|
||||
// reveal stale `baseUrl` / `apiKey` fields.
|
||||
sanitize_hermes_provider_keys(&mut json_val);
|
||||
denormalize_provider_models_for_read(&mut json_val);
|
||||
map.insert(name.to_string(), json_val);
|
||||
}
|
||||
@@ -536,8 +577,12 @@ pub fn set_provider(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Normalize `models` from UI array to Hermes YAML dict before serializing.
|
||||
// Rewrite any historical camelCase keys (e.g. from older DeepLink imports)
|
||||
// before touching models / YAML — avoids writing non-Hermes fields back.
|
||||
let mut normalized = provider_config;
|
||||
sanitize_hermes_provider_keys(&mut normalized);
|
||||
|
||||
// Normalize `models` from UI array to Hermes YAML dict before serializing.
|
||||
normalize_provider_models_for_write(&mut normalized);
|
||||
|
||||
// Extract the first model id (now a key in the normalized dict) so we can
|
||||
@@ -819,7 +864,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
if version >= 12 && providers_dict_populated {
|
||||
warnings.push(hermes_warning(
|
||||
"schema_migrated_v12",
|
||||
"Hermes v12 moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(),
|
||||
"Hermes' newer schema moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(),
|
||||
"providers",
|
||||
));
|
||||
}
|
||||
@@ -1048,6 +1093,75 @@ mod tests {
|
||||
result
|
||||
}
|
||||
|
||||
// ---- sanitize_hermes_provider_keys tests ----
|
||||
|
||||
#[test]
|
||||
fn sanitize_rewrites_camel_case_aliases() {
|
||||
let mut v = serde_json::json!({
|
||||
"name": "test",
|
||||
"baseUrl": "https://api.example.com",
|
||||
"apiKey": "sk-123",
|
||||
"apiMode": "chat_completions",
|
||||
"maxTokens": 8192,
|
||||
"contextLength": 200000,
|
||||
});
|
||||
sanitize_hermes_provider_keys(&mut v);
|
||||
let obj = v.as_object().unwrap();
|
||||
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com");
|
||||
assert_eq!(obj.get("api_key").unwrap(), "sk-123");
|
||||
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
|
||||
assert_eq!(obj.get("max_tokens").unwrap(), 8192);
|
||||
assert_eq!(obj.get("context_length").unwrap(), 200000);
|
||||
assert!(obj.get("baseUrl").is_none());
|
||||
assert!(obj.get("apiKey").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_stale_duplicate_when_snake_case_exists() {
|
||||
let mut v = serde_json::json!({
|
||||
"baseUrl": "https://old.example.com",
|
||||
"base_url": "https://new.example.com",
|
||||
});
|
||||
sanitize_hermes_provider_keys(&mut v);
|
||||
let obj = v.as_object().unwrap();
|
||||
// snake_case wins; stale camelCase is dropped
|
||||
assert_eq!(obj.get("base_url").unwrap(), "https://new.example.com");
|
||||
assert!(obj.get("baseUrl").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_legacy_api_field() {
|
||||
let mut v = serde_json::json!({
|
||||
"base_url": "https://api.example.com",
|
||||
"api": "openai-completions",
|
||||
});
|
||||
sanitize_hermes_provider_keys(&mut v);
|
||||
let obj = v.as_object().unwrap();
|
||||
assert!(obj.get("api").is_none(), "legacy 'api' key must be removed");
|
||||
assert!(obj.get("base_url").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_unknown_fields() {
|
||||
let mut v = serde_json::json!({
|
||||
"base_url": "https://api.example.com",
|
||||
"request_timeout_seconds": 300,
|
||||
"rate_limit_delay": 1.5,
|
||||
});
|
||||
sanitize_hermes_provider_keys(&mut v);
|
||||
let obj = v.as_object().unwrap();
|
||||
// Forward-compat: Hermes' own new fields pass through untouched
|
||||
assert_eq!(obj.get("request_timeout_seconds").unwrap(), 300);
|
||||
assert_eq!(obj.get("rate_limit_delay").unwrap(), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_noop_on_non_object() {
|
||||
let mut v = serde_json::json!(["not", "an", "object"]);
|
||||
sanitize_hermes_provider_keys(&mut v);
|
||||
assert!(v.is_array());
|
||||
}
|
||||
|
||||
// ---- find_yaml_section_range tests ----
|
||||
|
||||
#[test]
|
||||
@@ -1295,6 +1409,35 @@ custom_providers:
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn get_providers_heals_legacy_camel_case_on_read() {
|
||||
// A DB may still hold records from older DeepLink imports that wrote
|
||||
// camelCase fields into `settings_config`. The read path must surface
|
||||
// them in Hermes' native snake_case so UI editors aren't lying to users.
|
||||
with_test_home(|| {
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: legacy
|
||||
baseUrl: https://legacy.example.com
|
||||
apiKey: sk-legacy
|
||||
apiMode: chat_completions
|
||||
api: openai-completions
|
||||
";
|
||||
let config_path = get_hermes_config_path();
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, yaml).unwrap();
|
||||
|
||||
let provider = get_provider("legacy").unwrap().unwrap();
|
||||
assert_eq!(provider["base_url"], "https://legacy.example.com");
|
||||
assert_eq!(provider["api_key"], "sk-legacy");
|
||||
assert_eq!(provider["api_mode"], "chat_completions");
|
||||
assert!(provider.get("baseUrl").is_none());
|
||||
assert!(provider.get("apiKey").is_none());
|
||||
assert!(provider.get("api").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Model config tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,10 @@ use super::validation::validate_server_spec;
|
||||
|
||||
/// Hermes-specific fields preserved on merge-on-write, stripped on import.
|
||||
/// Update this list when Hermes adds new per-server config fields.
|
||||
///
|
||||
/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes —
|
||||
/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades
|
||||
/// the server to unauthenticated calls.
|
||||
const HERMES_EXTRA_FIELDS: &[&str] = &[
|
||||
"enabled",
|
||||
"timeout",
|
||||
@@ -32,6 +36,7 @@ const HERMES_EXTRA_FIELDS: &[&str] = &[
|
||||
"tools",
|
||||
"sampling",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
@@ -506,6 +511,47 @@ mod tests {
|
||||
assert_eq!(merged["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_preserves_auth_field() {
|
||||
let existing = json!({
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let new_spec = json!({
|
||||
"url": "https://mcp.example.com/updated",
|
||||
"headers": { "X-Trace": "abc" },
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let merged = merge_hermes_spec(&existing, &new_spec);
|
||||
|
||||
assert_eq!(merged["url"], "https://mcp.example.com/updated");
|
||||
assert_eq!(merged["headers"]["X-Trace"], "abc");
|
||||
assert_eq!(
|
||||
merged["auth"], "oauth",
|
||||
"auth declaration must survive CC Switch round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_hermes_strips_auth_on_import() {
|
||||
let spec = json!({
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let result = convert_from_hermes_format("remote", &spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://mcp.example.com");
|
||||
assert!(
|
||||
result.get("auth").is_none(),
|
||||
"auth stays Hermes-specific; stripped from unified format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_new_server_no_existing_extra_fields() {
|
||||
let existing = json!({
|
||||
|
||||
@@ -63,7 +63,7 @@ function getWarningText(
|
||||
case "schema_migrated_v12":
|
||||
return t("hermes.health.schemaMigratedV12", {
|
||||
defaultValue:
|
||||
"Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.",
|
||||
"Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.",
|
||||
});
|
||||
default:
|
||||
return fallback;
|
||||
|
||||
@@ -45,7 +45,10 @@ export interface HermesSuggestedDefaults {
|
||||
}
|
||||
|
||||
/** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */
|
||||
export type HermesApiMode = "chat_completions" | "anthropic_messages";
|
||||
export type HermesApiMode =
|
||||
| "chat_completions"
|
||||
| "anthropic_messages"
|
||||
| "codex_responses";
|
||||
|
||||
/**
|
||||
* Form-facing value used by the API Mode dropdown.
|
||||
@@ -70,6 +73,7 @@ export const hermesApiModes: Array<{
|
||||
value: "anthropic_messages",
|
||||
labelKey: "hermes.form.apiModeAnthropicMessages",
|
||||
},
|
||||
{ value: "codex_responses", labelKey: "hermes.form.apiModeCodexResponses" },
|
||||
];
|
||||
|
||||
export interface HermesProviderPreset {
|
||||
|
||||
@@ -1653,6 +1653,7 @@
|
||||
"apiModeAuto": "Auto-detect",
|
||||
"apiModeChatCompletions": "OpenAI Chat Completions",
|
||||
"apiModeAnthropicMessages": "Anthropic Messages",
|
||||
"apiModeCodexResponses": "Codex Responses (Copilot / OpenCode)",
|
||||
"models": "Models",
|
||||
"addModel": "Add model",
|
||||
"noModels": "No models configured. Switching to this provider won't change the default model.",
|
||||
@@ -1684,7 +1685,7 @@
|
||||
"modelDefaultNotInProvider": "model.default is not in the selected provider's models list.",
|
||||
"duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.",
|
||||
"duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.",
|
||||
"schemaMigratedV12": "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI."
|
||||
"schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI."
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory",
|
||||
|
||||
@@ -1653,6 +1653,7 @@
|
||||
"apiModeAuto": "自動検出",
|
||||
"apiModeChatCompletions": "OpenAI Chat Completions",
|
||||
"apiModeAnthropicMessages": "Anthropic Messages",
|
||||
"apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。",
|
||||
@@ -1684,7 +1685,7 @@
|
||||
"modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。",
|
||||
"duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。",
|
||||
"duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。",
|
||||
"schemaMigratedV12": "Hermes は一部のプロバイダーを 'providers:' dict に移しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。"
|
||||
"schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。"
|
||||
},
|
||||
"memory": {
|
||||
"title": "メモリ",
|
||||
|
||||
@@ -1653,6 +1653,7 @@
|
||||
"apiModeAuto": "自动检测",
|
||||
"apiModeChatCompletions": "OpenAI Chat Completions",
|
||||
"apiModeAnthropicMessages": "Anthropic Messages",
|
||||
"apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。",
|
||||
@@ -1684,7 +1685,7 @@
|
||||
"modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。",
|
||||
"duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。",
|
||||
"duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。",
|
||||
"schemaMigratedV12": "Hermes 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。"
|
||||
"schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。"
|
||||
},
|
||||
"memory": {
|
||||
"title": "记忆管理",
|
||||
|
||||
Reference in New Issue
Block a user