Files
CC-Switch/src-tauri/src/commands/provider.rs
T
Jason f733def452 feat(grokbuild): add Grok Official provider with official-state import
Add a "Grok Official" preset and seed (grokbuild-official) whose empty
config represents the official login state: no custom [model.*] tables
are written, so Grok CLI falls back to its built-in xAI OAuth login and
cc-switch never touches those credentials.

Backend:
- Seed entry in OFFICIAL_SEEDS plus ensure_grokbuild_official_provider
  command for on-demand repair (the one-shot master seeding flag is
  already set for existing databases).
- Split validation into syntax-only (empty allowed) for live reads,
  writes and official snapshots, keeping the full custom-model shape
  check for non-official provider writes and imports. Backup/restore
  can now round-trip an official-state live file.
- Manual import (command layer only) recognizes an official-state live
  config and imports it as the official entry set as current, matching
  the Codex official-login import outcome. Startup auto-import keeps
  rejecting official-state live so a deleted official entry is never
  resurrected on launch: startup import only captures real user data
  as "default" and never manufactures official entries.
- Manual import also ensures the official entry before importing
  (claude-desktop precedent) and after a successful custom import, so
  first-time users end up with default + official like other apps.
- Proxy takeover guards skip or reject official-state live configs in
  all three takeover paths, consistent with the official-provider
  takeover ban.

Frontend:
- Grok Official preset entry in the GrokBuild form: official category
  hides connection fields and passes the raw config through untouched.
- Filter managed-OAuth presets out of the GrokBuild preset list; they
  were never wired for this app and produced keyless broken configs.

Tests cover seed presence, official round-trip, ensure-after-deletion,
and the four import scenarios including startup non-resurrection.
2026-07-21 16:39:34 +08:00

1234 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use indexmap::IndexMap;
use tauri::{Emitter, Manager, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
use crate::store::AppState;
use std::str::FromStr;
// 常量定义
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
const TEMPLATE_TYPE_BALANCE: &str = "balance";
const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription";
const COPILOT_UNIT_PREMIUM: &str = "requests";
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
app: String,
) -> Result<IndexMap<String, Provider>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::delete(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn switch_provider_internal(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
ProviderService::switch(state, app_type, id)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
switch_provider_internal(state, app_type, id)
}
#[tauri::command]
pub async fn switch_provider(
app_handle: tauri::AppHandle,
app: String,
id: String,
) -> Result<SwitchResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
tauri::async_runtime::spawn_blocking(move || {
let state = app_handle
.try_state::<AppState>()
.ok_or_else(|| "应用状态不可用".to_string())?;
switch_provider_internal(state.inner(), app_type, &id).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("供应商切换任务执行失败: {e}"))?
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
if matches!(app_type, AppType::GrokBuild) {
// 官方登录态(live 语法合法且无自定义模型表)+ 用户手动导入:
// 导入的正确结果是让 Grok Official 成为当前供应商,而非报错。
// 只挂在命令层 = 只有手动动作可达;启动自动导入走 service 层、
// 官方态照旧报错静默跳过,删掉的官方条目不会被重启复活
//(全项目惯例:启动自动导入只产出 default,从不产出官方条目)。
if let Ok(settings) = crate::grok_config::read_grok_live_settings() {
let config = settings
.get("config")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if crate::grok_config::is_official_live_config(config) {
state.db.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
)?;
state.db.set_current_provider(
app_type.as_str(),
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
)?;
crate::settings::set_current_provider(
&app_type,
Some(crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID),
)?;
return Ok(true);
}
}
// Safety net: 与 claude-desktop 导入同语义 —— 用户主动点导入是"重新
// 整理该表"的隐式信号,把官方入口补回来。覆盖导入必然失败的场景
//live 文件缺失 / TOML 语法错误 / 残缺的自定义配置),避免
// "报错 + 空列表"死胡同。失败只 warn,不影响导入主流程。
if let Err(e) = state.db.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
) {
log::warn!("Failed to ensure grokbuild-official seed during import: {e}");
}
}
let imported = ProviderService::import_default_config(state, app_type.clone())?;
if imported {
// Extract common config snippet (mirrors old startup logic in lib.rs)
if state
.db
.should_auto_extract_config_snippet(app_type.as_str())?
{
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
let _ = state
.db
.set_config_snippet(app_type.as_str(), Some(snippet));
let _ = state
.db
.set_config_snippet_cleared(app_type.as_str(), false);
}
_ => {}
}
}
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
}
Ok(imported)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<bool, AppError> {
import_default_config_internal(state, app_type)
}
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
#[tauri::command]
pub async fn get_claude_desktop_status(
state: State<'_, AppState>,
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
let proxy_running = state.proxy_service.is_running().await;
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_claude_desktop_default_routes(
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
crate::claude_desktop_config::default_proxy_routes()
}
#[tauri::command]
pub fn import_claude_desktop_providers_from_claude(
state: State<'_, AppState>,
) -> Result<usize, String> {
let claude_providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.map_err(|e| e.to_string())?;
let existing_ids = state
.db
.get_provider_ids(AppType::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?;
let mut imported = 0usize;
for provider in claude_providers.values() {
if existing_ids.contains(&provider.id) {
continue;
}
let mut desktop_provider = provider.clone();
desktop_provider.in_failover_queue = false;
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
&& claude_provider_models_are_claude_safe(provider)
{
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
meta.claude_desktop_model_routes = routes;
} else {
continue;
}
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.map_err(|e| e.to_string())?;
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)
}
#[tauri::command]
pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(
crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
AppType::ClaudeDesktop,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn ensure_grokbuild_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
)
.map_err(|e| e.to_string())
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())
else {
return true;
};
[
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
]
.into_iter()
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.all(crate::claude_desktop_config::is_claude_safe_model_id)
}
pub(crate) fn suggested_claude_desktop_routes(
provider: &Provider,
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
let env = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())?;
let mut routes = std::collections::HashMap::new();
let supports_1m_default = !matches!(
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref()),
Some("github_copilot") | Some("codex_oauth") | Some("xai_oauth")
);
fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>,
route_key: &str,
env_key: &str,
supports_1m_default: bool,
) {
let Some(raw_model) = env
.get(env_key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
// Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀,
// 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。
let marker = crate::claude_desktop_config::ONE_M_CONTEXT_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 explicit_label_override = env
.get(&format!("{env_key}_NAME"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let label_override = explicit_label_override.clone().or_else(|| {
(!crate::claude_desktop_config::is_claude_safe_model_id(stripped_model))
.then(|| stripped_model.to_string())
});
// 何时覆盖既有 label_override:原本为空 / 这次来的是 explicit _NAME /
// 既有值只是 stripped_model 派生的占位(被 explicit 或更具体的值挤掉)。
let should_overwrite = |existing: Option<&str>| {
existing.is_none()
|| explicit_label_override.is_some()
|| existing == Some(stripped_model)
};
let merge_into = |existing: &mut crate::provider::ClaudeDesktopModelRoute| {
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if should_overwrite(existing.label_override.as_deref()) {
existing.label_override = label_override.clone();
}
};
if let Some(existing) = routes
.values_mut()
.find(|existing| existing.model == stripped_model)
{
merge_into(existing);
return;
}
routes
.entry(route_key.to_string())
.and_modify(merge_into)
.or_insert_with(|| crate::provider::ClaudeDesktopModelRoute {
model: stripped_model.to_string(),
label_override,
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_default,
);
}
// 三个 default env_key 全空时用 ANTHROPIC_MODEL 派生兜底路由。
if routes.is_empty() {
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0].route_id;
add_route(
&mut routes,
env,
primary_route,
"ANTHROPIC_MODEL",
supports_1m_default,
);
}
(!routes.is_empty()).then_some(routes)
}
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// inner 可能以两种形式失败:
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 确定性失败(401、脚本
// 报错、未知供应商等)。写进 UsageCache 并刷新托盘,让
// format_script_summary 的 success 守卫生效、suffix 自然消失。
// 2) 返回 Err(String) —— 瞬时传输失败(网络/超时)及 DB/Copilot fetch 等。
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
// 回 query 缓存,抹掉 reject 本该保留的旧值。
let inner =
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
if let Ok(snapshot) = &inner {
let payload = serde_json::json!({
"kind": "script",
"appType": app_type.as_str(),
"providerId": &providerId,
"data": snapshot,
});
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
log::error!("emit usage-cache-updated (script) 失败: {e}");
}
state
.usage_cache
.put_script(app_type, providerId, snapshot.clone());
crate::tray::schedule_tray_refresh(&app_handle);
}
inner
}
/// Resolve `(base_url, api_key)` for native usage queries, delegating to the
/// per-app resolver on `Provider`. Missing provider → empty credentials.
fn resolve_native_credentials(app_type: &AppType, provider: Option<&Provider>) -> (String, String) {
provider
.map(|p| p.resolve_usage_credentials(app_type))
.unwrap_or_default()
}
fn resolve_coding_plan_credentials(
app_type: &AppType,
provider: Option<&Provider>,
usage_script: Option<&crate::provider::UsageScript>,
) -> (String, String) {
let is_zenmux = usage_script
.and_then(|s| s.coding_plan_provider.as_deref())
.map(|provider| provider.eq_ignore_ascii_case("zenmux"))
.unwrap_or(false);
if !is_zenmux {
return resolve_native_credentials(app_type, provider);
}
let script_base_url = usage_script
.and_then(|s| s.base_url.as_deref())
.unwrap_or("")
.trim_end_matches('/')
.to_string();
let script_api_key = usage_script
.and_then(|s| s.api_key.as_deref())
.unwrap_or("")
.to_string();
if !script_base_url.is_empty() && !script_api_key.is_empty() {
return (script_base_url, script_api_key);
}
let native = resolve_native_credentials(app_type, provider);
if !native.0.is_empty() && !native.1.is_empty() {
native
} else {
(script_base_url, script_api_key)
}
}
async fn query_provider_usage_inner(
state: &AppState,
copilot_state: &CopilotAuthState,
app_type: AppType,
provider_id: &str,
) -> Result<crate::provider::UsageResult, String> {
// 从数据库读取供应商信息,检查特殊模板类型
let providers = state
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(provider_id);
let usage_script = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.usage_script.as_ref());
let template_type = usage_script
.and_then(|s| s.template_type.as_deref())
.unwrap_or("");
// ── GitHub Copilot 专用路径 ──
if template_type == TEMPLATE_TYPE_GITHUB_COPILOT {
let copilot_account_id = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
let auth_manager = copilot_state.0.read().await;
let usage = match copilot_account_id.as_deref() {
Some(account_id) => auth_manager
.fetch_usage_for_account(account_id)
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
None => auth_manager
.fetch_usage()
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
};
let premium = &usage.quota_snapshots.premium_interactions;
let used = premium.entitlement - premium.remaining;
return Ok(crate::provider::UsageResult {
success: true,
data: Some(vec![crate::provider::UsageData {
plan_name: Some(usage.copilot_plan),
remaining: Some(premium.remaining as f64),
total: Some(premium.entitlement as f64),
used: Some(used as f64),
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
is_valid: Some(true),
invalid_message: None,
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
}]),
error: None,
});
}
// ── Coding Plan 专用路径 ──
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
let (base_url, api_key) =
resolve_coding_plan_credentials(&app_type, provider, usage_script);
// 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离);
// 其他供应商为 Noneservice 层沿用 api_key。
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
// 智谱团队版:显式 provider 标识 + 组织/项目 ID(与个人版智谱 base_url 相同,
// 靠 coding_plan_provider == "zhipu_team" 在 service 层路由)。
let coding_plan_provider = usage_script.and_then(|s| s.coding_plan_provider.clone());
let team_organization_id = usage_script.and_then(|s| s.team_organization_id.clone());
let team_project_id = usage_script.and_then(|s| s.team_project_id.clone());
let quota = crate::services::coding_plan::get_coding_plan_quota(
&base_url,
&api_key,
access_key_id.as_deref(),
secret_access_key.as_deref(),
coding_plan_provider.as_deref(),
team_organization_id.as_deref(),
team_project_id.as_deref(),
)
.await
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
// 将 SubscriptionQuota 转换为 UsageResult
if !quota.success {
return Ok(crate::provider::UsageResult {
success: false,
data: None,
error: quota.error,
});
}
// ZenMux 的 tier 携带 USD 额度信息,需要编码为 JSON extra
let has_usd = quota
.tiers
.first()
.map(|t| t.used_value_usd.is_some())
.unwrap_or(false);
let plan_label = quota
.credential_message
.as_deref()
.and_then(|msg| msg.split(' ').next())
.map(|tier| format!("ZenMux·{}", tier.to_uppercase()));
let mut first_tier = true;
let data: Vec<crate::provider::UsageData> = quota
.tiers
.iter()
.map(|tier| {
let total = 100.0;
let used = tier.utilization;
let remaining = total - used;
let extra = if has_usd {
let mut extra_json = serde_json::json!({
"resetsAt": tier.resets_at,
});
if let Some(v) = tier.used_value_usd {
extra_json["usedValueUsd"] = serde_json::json!(v);
}
if let Some(v) = tier.max_value_usd {
extra_json["maxValueUsd"] = serde_json::json!(v);
}
if first_tier {
if let Some(ref label) = plan_label {
extra_json["planLabel"] = serde_json::json!(label);
}
first_tier = false;
}
Some(extra_json.to_string())
} else {
tier.resets_at.clone()
};
crate::provider::UsageData {
plan_name: Some(tier.name.clone()),
remaining: Some(remaining),
total: Some(total),
used: Some(used),
unit: Some("%".to_string()),
is_valid: Some(true),
invalid_message: None,
extra,
}
})
.collect();
return Ok(crate::provider::UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: None,
});
}
// ── 官方余额查询路径 ──
if template_type == TEMPLATE_TYPE_BALANCE {
// 按 app 区分的凭据存储格式提取 Base URL 与 API Key
let (base_url, api_key) = resolve_native_credentials(&app_type, provider);
return crate::services::balance::get_balance(&base_url, &api_key)
.await
.map_err(|e| format!("Failed to query balance: {e}"));
}
// ── 官方订阅额度查询路径 ──
if template_type == TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION {
if !usage_script.map(|s| s.enabled).unwrap_or(false) {
return Ok(crate::provider::UsageResult {
success: false,
data: None,
error: Some("Usage query is disabled".to_string()),
});
}
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
.await
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
if !quota.success {
return Ok(crate::provider::UsageResult {
success: false,
data: None,
error: quota.error.or(quota.credential_message),
});
}
let data: Vec<crate::provider::UsageData> = quota
.tiers
.iter()
.map(|tier| crate::provider::UsageData {
plan_name: Some(tier.name.clone()),
remaining: Some(100.0 - tier.utilization),
total: Some(100.0),
used: Some(tier.utilization),
unit: Some("%".to_string()),
is_valid: Some(true),
invalid_message: None,
extra: tier.resets_at.clone(),
})
.collect();
return Ok(crate::provider::UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: None,
});
}
// ── 通用 JS 脚本路径 ──
ProviderService::query_usage(state, app_type, provider_id)
.await
.map_err(|e| e.to_string())
}
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn testUsageScript(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String,
app: String,
#[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>,
#[allow(non_snake_case)] apiKey: Option<String>,
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
state.inner(),
app_type,
&providerId,
&scriptCode,
timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
SpeedtestService::test_endpoints(urls, timeoutSecs)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
app: String,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::AppHandle;
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
pub action: String,
pub id: String,
}
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_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");
let r = routes.get("claude-sonnet-5").expect("sonnet 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.label_override, None);
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-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
// 默认 provider_type 缺省 → supports_1m_default = true
assert_eq!(r.supports_1m, Some(true));
}
#[test]
fn route_uses_claude_code_model_name_as_label_override() {
let p = make_provider(
json!({
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Kimi K2",
}),
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("Kimi K2"));
}
#[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-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("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-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("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-sonnet-5").expect("merged route present");
assert_eq!(r.model, "MiniMax-M2");
assert_eq!(r.label_override.as_deref(), Some("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-sonnet-5").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-sonnet-5").unwrap().model, "GLM-4.6");
assert_eq!(routes.get("claude-opus-4-8").unwrap().model, "GLM-4-Air");
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
assert_eq!(
routes
.get("claude-sonnet-5")
.unwrap()
.label_override
.as_deref(),
Some("GLM-4.6")
);
}
#[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-sonnet-5")
.expect("fallback route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("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");
assert!(routes.contains_key("claude-sonnet-5"));
assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929"));
assert_eq!(
routes.get("claude-sonnet-5").expect("route").label_override,
None
);
}
}
#[cfg(test)]
mod native_query_credentials_tests {
use super::{resolve_coding_plan_credentials, resolve_native_credentials};
use crate::app_config::AppType;
use crate::provider::{Provider, UsageScript};
use serde_json::json;
fn usage_script(
coding_plan_provider: Option<&str>,
base_url: Option<&str>,
api_key: Option<&str>,
) -> UsageScript {
UsageScript {
enabled: true,
language: "javascript".to_string(),
code: String::new(),
timeout: Some(10),
api_key: api_key.map(str::to_string),
base_url: base_url.map(str::to_string),
access_token: None,
user_id: None,
template_type: Some("token_plan".to_string()),
auto_query_interval: None,
coding_plan_provider: coding_plan_provider.map(str::to_string),
access_key_id: None,
secret_access_key: None,
team_organization_id: None,
team_project_id: None,
}
}
#[test]
fn delegates_to_provider_for_codex() {
let provider = Provider::with_id(
"test".to_string(),
"Test".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-codex" },
"config": "model_provider = \"deepseek\"\n\
[model_providers.deepseek]\n\
base_url = \"https://api.deepseek.com\"\n",
}),
None,
);
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, Some(&provider));
assert_eq!(base_url, "https://api.deepseek.com");
assert_eq!(api_key, "sk-codex");
}
#[test]
fn missing_provider_yields_empty() {
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, None);
assert!(base_url.is_empty());
assert!(api_key.is_empty());
}
#[test]
fn zenmux_coding_plan_uses_script_credentials_first() {
let provider = Provider::with_id(
"test".to_string(),
"Test".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-provider"
}
}),
None,
);
let script = usage_script(
Some("zenmux"),
Some("https://script.zenmux.example/api/usage/"),
Some("sk-script"),
);
let (base_url, api_key) =
resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script));
assert_eq!(base_url, "https://script.zenmux.example/api/usage");
assert_eq!(api_key, "sk-script");
}
#[test]
fn zenmux_coding_plan_falls_back_to_provider_credentials() {
let provider = Provider::with_id(
"test".to_string(),
"Test".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-provider"
}
}),
None,
);
let script = usage_script(Some("zenmux"), Some("https://script.zenmux.example"), None);
let (base_url, api_key) =
resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script));
assert_eq!(base_url, "https://provider.zenmux.example/v1");
assert_eq!(api_key, "sk-provider");
}
}