mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
f733def452
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.
1234 lines
43 KiB
Rust
1234 lines
43 KiB
Rust
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 分离);
|
||
// 其他供应商为 None,service 层沿用 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");
|
||
}
|
||
}
|