mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
feat(claude-desktop): rework Claude Code import flow
- Derive route keys from the upstream model name (pass-through style) instead of fixed Claude aliases, and translate the legacy [1M] suffix into the supports1m field at the import boundary. Three Claude aliases mapped to the same upstream now collapse to a single route (e.g. MiniMax-M2 across SONNET/OPUS/HAIKU env produces one claude-MiniMax-M2 -> MiniMax-M2 row), with [1M] OR-aggregated. - Add an import-time safety net that rebuilds claude-desktop-official when missing, so users who deleted it can recover via the normal import button without losing customizations on other providers. - Hide API key and endpoint URL inputs in the official provider edit form to mirror Claude Code's behavior and prevent user confusion. - Reword the empty-state import button label for clarity.
This commit is contained in:
@@ -22,6 +22,13 @@ const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token";
|
||||
const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop";
|
||||
const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z";
|
||||
|
||||
/// Claude Desktop 模型菜单识别的 route ID 前缀。
|
||||
pub const CLAUDE_ROUTE_PREFIX: &str = "claude-";
|
||||
/// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。
|
||||
pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
|
||||
/// cc-switch 历史约定的 1M 上下文标记(ASCII 小写形式,匹配时用 `eq_ignore_ascii_case`)。
|
||||
pub const LEGACY_ONE_M_MARKER: &str = "[1m]";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeDesktopDefaultRoute {
|
||||
@@ -210,8 +217,49 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode {
|
||||
|
||||
pub fn is_claude_safe_model_id(model: &str) -> bool {
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
(normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-"))
|
||||
&& !normalized.contains("[1m]")
|
||||
(normalized.starts_with(CLAUDE_ROUTE_PREFIX)
|
||||
|| normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX))
|
||||
&& !normalized.contains(LEGACY_ONE_M_MARKER)
|
||||
}
|
||||
|
||||
/// 把上游 model 名标准化为 Claude Desktop 路由表 key(即 UI 左列内容)。
|
||||
/// 复现前端 `desktopRouteIdFromModel` + `normalizeDesktopRouteSuffix` 的规则:
|
||||
/// 1. trim
|
||||
/// 2. 剥离已有 `anthropic/claude-` 或 `claude-` 前缀(长前缀优先匹配)
|
||||
/// 3. 去 `models/` 前缀(Gemini 风格 `models/gemini-2.0-flash`)
|
||||
/// 4. 把空格 / 制表 / 正反斜杠替换成 `-`
|
||||
/// 5. 拼上 `claude-` 前缀
|
||||
///
|
||||
/// 示例:
|
||||
/// "MiniMax-M2" → "claude-MiniMax-M2"
|
||||
/// "models/gemini-2.0-flash" → "claude-gemini-2.0-flash"
|
||||
/// "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5-20250929"(剥后再拼,不双重)
|
||||
/// "anthropic/claude-3-5-sonnet" → "claude-3-5-sonnet"(与前端一致:anthropic/claude- 被替换为 claude-)
|
||||
pub fn derive_desktop_route_id(upstream_model: &str) -> String {
|
||||
let trimmed = upstream_model.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
let suffix = if lower.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX) {
|
||||
&trimmed[ANTHROPIC_CLAUDE_ROUTE_PREFIX.len()..]
|
||||
} else if lower.starts_with(CLAUDE_ROUTE_PREFIX) {
|
||||
&trimmed[CLAUDE_ROUTE_PREFIX.len()..]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let without_models_prefix = suffix
|
||||
.strip_prefix("models/")
|
||||
.or_else(|| suffix.strip_prefix("Models/"))
|
||||
.unwrap_or(suffix);
|
||||
let normalized: String = without_models_prefix
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if matches!(c, ' ' | '\t' | '/' | '\\') {
|
||||
'-'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{CLAUDE_ROUTE_PREFIX}{normalized}")
|
||||
}
|
||||
|
||||
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
|
||||
@@ -1386,4 +1434,46 @@ mod tests {
|
||||
);
|
||||
assert!(!is_compatible_direct_provider(&missing_bearer));
|
||||
}
|
||||
|
||||
// === derive_desktop_route_id ===
|
||||
|
||||
#[test]
|
||||
fn derive_basic_adds_prefix() {
|
||||
assert_eq!(derive_desktop_route_id("MiniMax-M2"), "claude-MiniMax-M2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_strips_gemini_prefix() {
|
||||
assert_eq!(
|
||||
derive_desktop_route_id("models/gemini-2.0-flash"),
|
||||
"claude-gemini-2.0-flash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_keeps_existing_claude_prefix() {
|
||||
assert_eq!(
|
||||
derive_desktop_route_id("claude-sonnet-4-5-20250929"),
|
||||
"claude-sonnet-4-5-20250929"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_strips_anthropic_claude_prefix_uses_claude() {
|
||||
// 与前端 normalizeDesktopRouteSuffix 一致:剥离 anthropic/claude- 前缀后用 claude- 替代
|
||||
assert_eq!(
|
||||
derive_desktop_route_id("anthropic/claude-3-5-sonnet"),
|
||||
"claude-3-5-sonnet"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_replaces_whitespace_and_slash() {
|
||||
assert_eq!(derive_desktop_route_id("my model/v2"), "claude-my-model-v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_preserves_case_and_dot() {
|
||||
assert_eq!(derive_desktop_route_id("GLM-4.6"), "claude-GLM-4.6");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,16 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
// Safety net: 用户可能手动删除过 claude-desktop-official seed。
|
||||
// 用户主动点 import 是"重新整理 ClaudeDesktop 表"的隐式信号,把官方入口补回来。
|
||||
// 失败只 warn,不影响 imported 主流程;imported 计数语义保持纯净。
|
||||
if let Err(e) = state.db.ensure_official_seed_by_id(
|
||||
crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop,
|
||||
) {
|
||||
log::warn!("Failed to ensure claude-desktop-official seed during import: {e}");
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -231,7 +241,7 @@ fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
.all(crate::claude_desktop_config::is_claude_safe_model_id)
|
||||
}
|
||||
|
||||
fn suggested_claude_desktop_routes(
|
||||
pub(crate) fn suggested_claude_desktop_routes(
|
||||
provider: &Provider,
|
||||
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
||||
let env = provider
|
||||
@@ -239,7 +249,7 @@ fn suggested_claude_desktop_routes(
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())?;
|
||||
let mut routes = std::collections::HashMap::new();
|
||||
let supports_1m = !matches!(
|
||||
let supports_1m_default = !matches!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -250,39 +260,54 @@ fn suggested_claude_desktop_routes(
|
||||
fn add_route(
|
||||
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
|
||||
env: &serde_json::Map<String, serde_json::Value>,
|
||||
route_id: &str,
|
||||
env_key: &str,
|
||||
supports_1m: bool,
|
||||
supports_1m_default: bool,
|
||||
) {
|
||||
if let Some(model) = env
|
||||
let Some(raw_model) = env
|
||||
.get(env_key)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
routes.insert(
|
||||
route_id.to_string(),
|
||||
crate::provider::ClaudeDesktopModelRoute {
|
||||
model: model.to_string(),
|
||||
supports_1m: Some(supports_1m),
|
||||
},
|
||||
);
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀,
|
||||
// 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。
|
||||
let marker = crate::claude_desktop_config::LEGACY_ONE_M_MARKER.as_bytes();
|
||||
let raw_bytes = raw_model.as_bytes();
|
||||
let has_1m_marker = raw_bytes.len() >= marker.len()
|
||||
&& raw_bytes[raw_bytes.len() - marker.len()..].eq_ignore_ascii_case(marker);
|
||||
let stripped_model: &str = if has_1m_marker {
|
||||
raw_model[..raw_model.len() - marker.len()].trim_end()
|
||||
} else {
|
||||
raw_model
|
||||
};
|
||||
if stripped_model.is_empty() {
|
||||
return;
|
||||
}
|
||||
let effective_supports_1m = supports_1m_default || has_1m_marker;
|
||||
let route_key = crate::claude_desktop_config::derive_desktop_route_id(stripped_model);
|
||||
|
||||
routes
|
||||
.entry(route_key)
|
||||
.and_modify(|existing| {
|
||||
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
|
||||
existing.supports_1m = Some(merged);
|
||||
})
|
||||
.or_insert_with(|| crate::provider::ClaudeDesktopModelRoute {
|
||||
model: stripped_model.to_string(),
|
||||
supports_1m: Some(effective_supports_1m),
|
||||
});
|
||||
}
|
||||
|
||||
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
|
||||
add_route(&mut routes, env, spec.route_id, spec.env_key, supports_1m);
|
||||
add_route(&mut routes, env, spec.env_key, supports_1m_default);
|
||||
}
|
||||
|
||||
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
|
||||
if !routes.contains_key(primary_route.route_id) {
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
primary_route.route_id,
|
||||
"ANTHROPIC_MODEL",
|
||||
supports_1m,
|
||||
);
|
||||
// 三个 default env_key 全空时用 ANTHROPIC_MODEL 派生兜底路由。
|
||||
if routes.is_empty() {
|
||||
add_route(&mut routes, env, "ANTHROPIC_MODEL", supports_1m_default);
|
||||
}
|
||||
|
||||
(!routes.is_empty()).then_some(routes)
|
||||
@@ -672,3 +697,183 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
// ============================================================================
|
||||
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_claude_desktop_tests {
|
||||
use super::suggested_claude_desktop_routes;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use serde_json::json;
|
||||
|
||||
fn make_provider(env: serde_json::Value, provider_type: Option<&str>) -> Provider {
|
||||
let mut p = Provider::with_id(
|
||||
"test-claude".to_string(),
|
||||
"Test".to_string(),
|
||||
json!({ "env": env }),
|
||||
None,
|
||||
);
|
||||
if let Some(pt) = provider_type {
|
||||
p.meta = Some(ProviderMeta {
|
||||
provider_type: Some(pt.to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_strips_1m_suffix_and_sets_supports_1m() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929[1M]",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
// env 已带 claude- 前缀,派生 key 不重复加前缀
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-5-20250929")
|
||||
.expect("derived route present");
|
||||
assert_eq!(r.model, "claude-sonnet-4-5-20250929");
|
||||
assert!(
|
||||
!r.model.to_ascii_lowercase().contains("[1m]"),
|
||||
"model must not contain [1m] suffix"
|
||||
);
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_preserves_model_without_suffix() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes.get("claude-kimi-k2").expect("derived route present");
|
||||
assert_eq!(r.model, "kimi-k2");
|
||||
// 默认 provider_type 缺省 → supports_1m_default = true
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_1m_suffix_overrides_provider_type_default() {
|
||||
// github_copilot 默认 supports_1m_default = false,但 [1M] 后缀应强制 true
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex[1M]",
|
||||
}),
|
||||
Some("github_copilot"),
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-gpt-5-codex")
|
||||
.expect("derived route present");
|
||||
assert_eq!(r.model, "gpt-5-codex");
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_github_copilot_without_suffix_keeps_false() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex",
|
||||
}),
|
||||
Some("github_copilot"),
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-gpt-5-codex")
|
||||
.expect("derived route present");
|
||||
assert_eq!(r.model, "gpt-5-codex");
|
||||
assert_eq!(r.supports_1m, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_upstream_across_three_aliases_merges_to_one_route() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1, "three aliases → one merged route");
|
||||
let r = routes
|
||||
.get("claude-MiniMax-M2")
|
||||
.expect("merged route present");
|
||||
assert_eq!(r.model, "MiniMax-M2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_upstream_with_partial_1m_marker_takes_or_aggregation() {
|
||||
// sonnet 带 [1M],opus/haiku 不带 → 合并后 supports_1m == Some(true)
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2[1M]",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1);
|
||||
let r = routes
|
||||
.get("claude-MiniMax-M2")
|
||||
.expect("merged route present");
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_upstream_models_produce_separate_routes() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "GLM-4.6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "GLM-4-Air",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "GLM-4-Flash",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 3);
|
||||
assert_eq!(routes.get("claude-GLM-4.6").unwrap().model, "GLM-4.6");
|
||||
assert_eq!(routes.get("claude-GLM-4-Air").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(
|
||||
routes.get("claude-GLM-4-Flash").unwrap().model,
|
||||
"GLM-4-Flash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_model_fallback_only_triggers_when_empty() {
|
||||
// 三个 default env_key 都不填,仅 ANTHROPIC_MODEL
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_MODEL": "kimi-k2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1);
|
||||
let r = routes
|
||||
.get("claude-kimi-k2")
|
||||
.expect("fallback route present");
|
||||
assert_eq!(r.model, "kimi-k2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_claude_prefix_not_duplicated() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
// key 应是 "claude-sonnet-4-5-20250929",不出现双重 claude- 前缀
|
||||
assert!(routes.contains_key("claude-sonnet-4-5-20250929"));
|
||||
assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,4 +649,138 @@ impl Database {
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// 按 id 兜底插入单条 official seed(仅当目标表中该 id 不存在时插入)。
|
||||
///
|
||||
/// 与 `init_default_official_providers` 不同:
|
||||
/// - 不触碰 `official_providers_seeded` 全局 flag,是 on-demand 修复
|
||||
/// - 只处理一条 seed,由调用方决定 id + app_type
|
||||
/// - 已存在则尊重用户自定义,不覆盖
|
||||
///
|
||||
/// 返回 Ok(true) 表示插入了新行,Ok(false) 表示已存在被跳过。
|
||||
pub fn ensure_official_seed_by_id(
|
||||
&self,
|
||||
seed_id: &str,
|
||||
app_type: crate::app_config::AppType,
|
||||
) -> Result<bool, AppError> {
|
||||
use crate::database::dao::providers_seed::OFFICIAL_SEEDS;
|
||||
|
||||
let seed = OFFICIAL_SEEDS
|
||||
.iter()
|
||||
.find(|s| s.id == seed_id && s.app_type == app_type)
|
||||
.ok_or_else(|| {
|
||||
AppError::Database(format!(
|
||||
"unknown official seed: id={seed_id}, app_type={}",
|
||||
app_type.as_str()
|
||||
))
|
||||
})?;
|
||||
|
||||
let app_type_str = seed.app_type.as_str();
|
||||
|
||||
if self.get_provider_by_id(seed_id, app_type_str)?.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings_config: serde_json::Value = serde_json::from_str(seed.settings_config_json)
|
||||
.map_err(|e| {
|
||||
AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id))
|
||||
})?;
|
||||
|
||||
let next_sort_index = self.next_sort_index_for_app(app_type_str)?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
seed.id.to_string(),
|
||||
seed.name.to_string(),
|
||||
settings_config,
|
||||
Some(seed.website_url.to_string()),
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
provider.icon = Some(seed.icon.to_string());
|
||||
provider.icon_color = Some(seed.icon_color.to_string());
|
||||
provider.sort_index = Some(next_sort_index);
|
||||
provider.created_at = Some(now_ms);
|
||||
|
||||
self.save_provider(app_type_str, &provider)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ensure_official_seed_tests {
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
|
||||
|
||||
#[test]
|
||||
fn ensure_inserts_when_missing() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let inserted = db
|
||||
.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop)
|
||||
.expect("ensure ok");
|
||||
assert!(inserted, "should insert when missing");
|
||||
|
||||
let provider = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("provider exists after ensure");
|
||||
|
||||
assert_eq!(provider.id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID);
|
||||
assert_eq!(provider.name, "Claude Desktop Official");
|
||||
assert_eq!(provider.category.as_deref(), Some("official"));
|
||||
assert_eq!(provider.icon.as_deref(), Some("anthropic"));
|
||||
assert_eq!(provider.icon_color.as_deref(), Some("#D4915D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_skips_when_present_and_preserves_customization() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
db.init_default_official_providers().expect("seed");
|
||||
|
||||
let mut renamed = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("seed present");
|
||||
renamed.name = "My Custom Backup".to_string();
|
||||
db.save_provider(AppType::ClaudeDesktop.as_str(), &renamed)
|
||||
.expect("save customization");
|
||||
|
||||
let inserted = db
|
||||
.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop)
|
||||
.expect("ensure ok");
|
||||
assert!(!inserted, "should skip when present");
|
||||
|
||||
let after = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("still present");
|
||||
assert_eq!(
|
||||
after.name, "My Custom Backup",
|
||||
"customization must not be overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_rejects_unknown_seed() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let result = db.ensure_official_seed_by_id("nonexistent-id", AppType::ClaudeDesktop);
|
||||
assert!(result.is_err(), "unknown seed id should be Err");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_rejects_seed_app_type_mismatch() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let result =
|
||||
db.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::Claude);
|
||||
assert!(result.is_err(), "(id, app_type) mismatch should be Err");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ProviderEmptyState({
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{appId === "claude-desktop"
|
||||
? t("provider.importFromClaude", {
|
||||
defaultValue: "从 Claude 导入兼容供应商",
|
||||
defaultValue: "将 Claude Code 中已有的供应商导入",
|
||||
})
|
||||
: t("provider.importCurrent")}
|
||||
</Button>
|
||||
|
||||
@@ -239,6 +239,7 @@ export function ClaudeDesktopProviderForm({
|
||||
showButtons = true,
|
||||
}: ClaudeDesktopProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isOfficial = initialData?.category === "official";
|
||||
const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct";
|
||||
const [mode, setMode] = useState<"direct" | "proxy">(initialMode);
|
||||
const needsModelMapping = mode === "proxy";
|
||||
@@ -508,6 +509,30 @@ export function ClaudeDesktopProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isOfficial) {
|
||||
// 官方供应商使用 Claude Desktop 内置 1P 模式,保持空 env 占位;
|
||||
// 不写 claudeDesktopMode / claudeDesktopModelRoutes / apiFormat,
|
||||
// 与启动 seed 的 OFFICIAL_SEEDS 占位语义一致。
|
||||
const settingsConfig = clonePlainRecord(initialData?.settingsConfig);
|
||||
settingsConfig.env = {};
|
||||
const meta: ProviderMeta = { ...(initialData?.meta ?? {}) };
|
||||
delete meta.claudeDesktopMode;
|
||||
delete meta.claudeDesktopModelRoutes;
|
||||
delete meta.apiFormat;
|
||||
delete meta.endpointAutoSelect;
|
||||
delete meta.isFullUrl;
|
||||
await onSubmit({
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||
notes: values.notes?.trim() ?? "",
|
||||
settingsConfig: JSON.stringify(settingsConfig, null, 2),
|
||||
meta,
|
||||
presetId: activePreset?.id,
|
||||
presetCategory: "official",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!baseUrl.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.fetchModelsNeedEndpoint", {
|
||||
@@ -688,361 +713,383 @@ export function ClaudeDesktopProviderForm({
|
||||
|
||||
<BasicFormFields form={form} />
|
||||
|
||||
{usesManagedOAuth ? (
|
||||
<div className="rounded-lg border border-border-default bg-muted/20 p-3">
|
||||
{activeProviderType === "github_copilot" ? (
|
||||
<CopilotAuthSection
|
||||
selectedAccountId={selectedGitHubAccountId}
|
||||
onAccountSelect={setSelectedGitHubAccountId}
|
||||
/>
|
||||
) : (
|
||||
<CodexOAuthSection
|
||||
selectedAccountId={selectedCodexAccountId}
|
||||
onAccountSelect={setSelectedCodexAccountId}
|
||||
fastModeEnabled={codexFastMode}
|
||||
onFastModeChange={setCodexFastMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label>{"API Key"}</Label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
{isOfficial && (
|
||||
<div className="rounded-lg border border-border-default bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
{t("claudeDesktop.officialNotice", {
|
||||
defaultValue:
|
||||
"Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EndpointField
|
||||
id="baseUrl"
|
||||
label={t("providerForm.apiEndpoint")}
|
||||
value={baseUrl}
|
||||
onChange={(v) => setBaseUrl(v)}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||
hint={
|
||||
needsModelMapping && apiFormat === "openai_responses"
|
||||
? t("providerForm.apiHintResponses")
|
||||
: needsModelMapping && apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: needsModelMapping && apiFormat === "gemini_native"
|
||||
? t("providerForm.apiHintGeminiNative")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
showManageButton={false}
|
||||
/>
|
||||
{!isOfficial && (
|
||||
<>
|
||||
{usesManagedOAuth ? (
|
||||
<div className="rounded-lg border border-border-default bg-muted/20 p-3">
|
||||
{activeProviderType === "github_copilot" ? (
|
||||
<CopilotAuthSection
|
||||
selectedAccountId={selectedGitHubAccountId}
|
||||
onAccountSelect={setSelectedGitHubAccountId}
|
||||
/>
|
||||
) : (
|
||||
<CodexOAuthSection
|
||||
selectedAccountId={selectedCodexAccountId}
|
||||
onAccountSelect={setSelectedCodexAccountId}
|
||||
fastModeEnabled={codexFastMode}
|
||||
onFastModeChange={setCodexFastMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label>{"API Key"}</Label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
</Label>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsModelMapping
|
||||
? t("claudeDesktop.modelMappingOnHint", {
|
||||
defaultValue:
|
||||
"Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
})
|
||||
: t("claudeDesktop.modelMappingOffHint", {
|
||||
defaultValue:
|
||||
"适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsModelMapping}
|
||||
onCheckedChange={handleModelMappingChange}
|
||||
aria-label={t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
<EndpointField
|
||||
id="baseUrl"
|
||||
label={t("providerForm.apiEndpoint")}
|
||||
value={baseUrl}
|
||||
onChange={(v) => setBaseUrl(v)}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||
hint={
|
||||
needsModelMapping && apiFormat === "openai_responses"
|
||||
? t("providerForm.apiHintResponses")
|
||||
: needsModelMapping && apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: needsModelMapping && apiFormat === "gemini_native"
|
||||
? t("providerForm.apiHintGeminiNative")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
showManageButton={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsModelMapping && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue:
|
||||
"Gemini Native generateContent (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1 border-t border-border-default pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("claudeDesktop.routeMapTitle", {
|
||||
defaultValue: "模型映射",
|
||||
{t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
</Label>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
nextRouteRow(current, defaultProxyRouteRows),
|
||||
]),
|
||||
t("claudeDesktop.addRoute", { defaultValue: "添加模型" }),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.routeMapHint", {
|
||||
defaultValue:
|
||||
"左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-cols-[1fr_1fr_116px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("claudeDesktop.routeModelLabel", {
|
||||
defaultValue: "Desktop 显示模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.upstreamModelLabel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.supports1mLabel", {
|
||||
defaultValue: "声明支持 1M",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={route.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_116px_36px]"
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="inline-flex h-9 items-center rounded-l-md border border-r-0 border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{CLAUDE_ROUTE_PREFIX}
|
||||
</span>
|
||||
<Input
|
||||
value={desktopRouteInputValue(route.route)}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, {
|
||||
route: desktopRouteIdFromInput(event.target.value),
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsModelMapping
|
||||
? t("claudeDesktop.modelMappingOnHint", {
|
||||
defaultValue:
|
||||
"Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
})
|
||||
}
|
||||
placeholder="gpt-5.5"
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.model}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { model: event.target.value })
|
||||
}
|
||||
placeholder="kimi-k2 / deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
updateRoute(index, {
|
||||
model: id,
|
||||
route: route.route || desktopRouteIdFromModel(id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRoute(index, { supports1m: checked === true })
|
||||
}
|
||||
/>
|
||||
{t("claudeDesktop.supports1mShort", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
: t("claudeDesktop.modelMappingOffHint", {
|
||||
defaultValue:
|
||||
"适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsModelMapping}
|
||||
onCheckedChange={handleModelMappingChange}
|
||||
aria-label={t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsModelMapping && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue:
|
||||
"Gemini Native generateContent (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!needsModelMapping && (
|
||||
<Collapsible
|
||||
open={directModelsExpanded}
|
||||
onOpenChange={setDirectModelsExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{directModelsExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("claudeDesktop.directModelListTitle", {
|
||||
defaultValue:
|
||||
"手动指定 Claude Desktop 模型列表(高级,可选)",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!directModelsExpanded && (
|
||||
<p className="ml-1 mt-1 text-xs text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListCollapsedHint", {
|
||||
defaultValue:
|
||||
"原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="flex-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListHint", {
|
||||
defaultValue:
|
||||
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。",
|
||||
})}
|
||||
</p>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
createRouteRow({
|
||||
route: "",
|
||||
model: "",
|
||||
supports1m: false,
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1 border-t border-border-default pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
{t("claudeDesktop.routeMapTitle", {
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</Label>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
nextRouteRow(current, defaultProxyRouteRows),
|
||||
]),
|
||||
t("claudeDesktop.addRoute", {
|
||||
defaultValue: "添加模型",
|
||||
}),
|
||||
]),
|
||||
t("claudeDesktop.addModel", { defaultValue: "添加模型" }),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.routeMapHint", {
|
||||
defaultValue:
|
||||
"左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={route.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_116px_36px]"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.route}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { route: event.target.value })
|
||||
}
|
||||
placeholder="claude-deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
updateRoute(index, { route: id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
<div className="hidden grid-cols-[1fr_1fr_116px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("claudeDesktop.routeModelLabel", {
|
||||
defaultValue: "Desktop 显示模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.upstreamModelLabel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.supports1mLabel", {
|
||||
defaultValue: "声明支持 1M",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={route.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_116px_36px]"
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="inline-flex h-9 items-center rounded-l-md border border-r-0 border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{CLAUDE_ROUTE_PREFIX}
|
||||
</span>
|
||||
<Input
|
||||
value={desktopRouteInputValue(route.route)}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, {
|
||||
route: desktopRouteIdFromInput(
|
||||
event.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="gpt-5.5"
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.model}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, { model: event.target.value })
|
||||
}
|
||||
placeholder="kimi-k2 / deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
updateRoute(index, {
|
||||
supports1m: checked === true,
|
||||
model: id,
|
||||
route:
|
||||
route.route || desktopRouteIdFromModel(id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t("claudeDesktop.supports1mShort", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRoute(index, { supports1m: checked === true })
|
||||
}
|
||||
/>
|
||||
{t("claudeDesktop.supports1mShort", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<input type="hidden" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!needsModelMapping && (
|
||||
<Collapsible
|
||||
open={directModelsExpanded}
|
||||
onOpenChange={setDirectModelsExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{directModelsExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("claudeDesktop.directModelListTitle", {
|
||||
defaultValue:
|
||||
"手动指定 Claude Desktop 模型列表(高级,可选)",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!directModelsExpanded && (
|
||||
<p className="ml-1 mt-1 text-xs text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListCollapsedHint", {
|
||||
defaultValue:
|
||||
"原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="flex-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.directModelListHint", {
|
||||
defaultValue:
|
||||
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。",
|
||||
})}
|
||||
</p>
|
||||
{renderActionButtons(
|
||||
() =>
|
||||
setRoutes((current) => [
|
||||
...current,
|
||||
createRouteRow({
|
||||
route: "",
|
||||
model: "",
|
||||
supports1m: false,
|
||||
}),
|
||||
]),
|
||||
t("claudeDesktop.addModel", {
|
||||
defaultValue: "添加模型",
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={route.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_116px_36px]"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.route}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, {
|
||||
route: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="claude-deepseek-chat"
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
updateRoute(index, { route: id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={route.supports1m}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRoute(index, {
|
||||
supports1m: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t("claudeDesktop.supports1mShort", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setRoutes((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<input type="hidden" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showButtons && (
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider",
|
||||
"noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers",
|
||||
"importCurrent": "Import Current Config",
|
||||
"importFromClaude": "Import Compatible Providers from Claude",
|
||||
"importFromClaude": "Import existing providers from Claude Code",
|
||||
"importCurrentDescription": "Import current live config as default provider",
|
||||
"currentlyUsing": "Currently Using",
|
||||
"enable": "Enable",
|
||||
@@ -174,6 +174,7 @@
|
||||
}
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop official providers use the built-in 1P sign-in. No API key or endpoint URL is required.",
|
||||
"mode": "Model handling",
|
||||
"modeDirect": "Direct",
|
||||
"modeProxy": "Requires routing",
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます",
|
||||
"noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます",
|
||||
"importCurrent": "現在の設定をインポート",
|
||||
"importFromClaude": "Claude から互換プロバイダーをインポート",
|
||||
"importFromClaude": "Claude Code の既存プロバイダーをインポート",
|
||||
"importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート",
|
||||
"currentlyUsing": "現在使用中",
|
||||
"enable": "有効化",
|
||||
@@ -174,6 +174,7 @@
|
||||
}
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop の公式プロバイダーは内蔵の 1P サインインを使用します。API キーやエンドポイントの設定は不要です。",
|
||||
"mode": "モデル処理方式",
|
||||
"modeDirect": "直結",
|
||||
"modeProxy": "ルーティング必要",
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中",
|
||||
"noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享",
|
||||
"importCurrent": "导入当前配置",
|
||||
"importFromClaude": "从 Claude 导入兼容供应商",
|
||||
"importFromClaude": "将 Claude Code 中已有的供应商导入",
|
||||
"importCurrentDescription": "将当前正在使用的配置导入为默认供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
"enable": "启用",
|
||||
@@ -174,6 +174,7 @@
|
||||
}
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。",
|
||||
"mode": "模型处理方式",
|
||||
"modeDirect": "直连",
|
||||
"modeProxy": "需要路由",
|
||||
|
||||
Reference in New Issue
Block a user