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:
Jason
2026-05-12 21:31:30 +08:00
parent 60a3628360
commit c12364a940
8 changed files with 840 additions and 361 deletions
+92 -2
View File
@@ -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");
}
}
+228 -23
View File
@@ -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"));
}
}
+134
View File
@@ -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 模型菜单里的可见模型 ID1M 只控制是否向 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 模型菜单里的可见模型 ID1M 只控制是否向 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">
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "ルーティング必要",
+2 -1
View File
@@ -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": "需要路由",