Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import'

This commit is contained in:
elky
2026-05-31 20:52:26 +08:00
23 changed files with 1720 additions and 48 deletions
+4
View File
@@ -4,6 +4,10 @@
# 应用端口(默认 8084
APP_PORT=8084
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
# 示例:
# APP_IMAGE=ghcr.io/fawney19/aether:latest
@@ -195,6 +195,9 @@ fn select_primary_credential(
if signature.starts_with("openai:") {
return select_openai_credential(bundle);
}
if signature.starts_with("aether:") {
return select_openai_credential(bundle);
}
select_generic_credential(bundle)
}
@@ -826,8 +826,9 @@ async fn build_data_backed_auth_context(
.map(|(provider, _)| provider)
.unwrap_or(auth_endpoint_signature)
.trim();
let requested_provider_allowed =
auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
let identity_only = auth_gate_identity_only(auth_endpoint_signature);
let requested_provider_allowed = identity_only
|| auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
let local_rejection = if invalid_api_key {
Some(GatewayLocalAuthRejection::InvalidApiKey)
} else if locked_api_key {
@@ -845,14 +846,15 @@ async fn build_data_backed_auth_context(
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
provider: requested_provider.to_string(),
})
} else if snapshot
.effective_allowed_api_formats()
.is_some_and(|allowed| {
!contains_api_format_or_alias(
allowed,
auth_gate_api_format(auth_endpoint_signature).as_str(),
)
})
} else if !identity_only
&& snapshot
.effective_allowed_api_formats()
.is_some_and(|allowed| {
!contains_api_format_or_alias(
allowed,
auth_gate_api_format(auth_endpoint_signature).as_str(),
)
})
{
Some(GatewayLocalAuthRejection::ApiFormatNotAllowed {
api_format: auth_endpoint_signature.to_string(),
@@ -896,6 +898,13 @@ fn auth_gate_api_format(auth_endpoint_signature: &str) -> String {
}
}
fn auth_gate_identity_only(auth_endpoint_signature: &str) -> bool {
matches!(
auth_endpoint_signature.trim().to_ascii_lowercase().as_str(),
"aether:ccswitch_usage"
)
}
fn api_format_matches(left: &str, right: &str) -> bool {
aether_scheduler_core::api_format_matches_allowed_value(left, right)
}
@@ -505,6 +505,19 @@ pub(super) fn classify_public_support_route(
"public:payment",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
"/api/ccswitch/usage" | "/api/ccswitch/usage/"
)
{
Some(classified(
"public_support",
"ccswitch",
"usage",
"aether:ccswitch_usage",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
@@ -517,6 +530,7 @@ pub(super) fn classify_public_support_route(
| "/api/users/me/usage/heatmap"
| "/api/users/me/providers"
| "/api/users/me/available-models"
| "/api/users/me/client-config"
| "/api/users/me/endpoint-status"
| "/api/users/me/preferences"
| "/api/users/me/referral"
@@ -533,6 +547,7 @@ pub(super) fn classify_public_support_route(
"/api/users/me/usage/heatmap" => "usage_heatmap",
"/api/users/me/providers" => "providers",
"/api/users/me/available-models" => "available_models",
"/api/users/me/client-config" => "client_config",
"/api/users/me/endpoint-status" => "endpoint_status",
"/api/users/me/preferences" => "preferences",
"/api/users/me/referral" => "referral",
@@ -463,6 +463,23 @@ fn classifies_users_me_routes_as_public_support_route() {
}
}
#[test]
fn classifies_ccswitch_usage_as_api_key_public_support_route() {
let headers = headers(&[]);
let uri: Uri = "/api/ccswitch/usage".parse().expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("ccswitch"));
assert_eq!(decision.route_kind.as_deref(), Some("usage"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("aether:ccswitch_usage")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn user_api_key_install_session_create_buffers_request_body() {
let headers = headers(&[]);
@@ -29,6 +29,8 @@ mod support_announcements;
mod support_auth;
#[path = "support/billing.rs"]
mod support_billing;
#[path = "support/ccswitch.rs"]
mod support_ccswitch;
#[path = "support/dashboard.rs"]
mod support_dashboard;
#[path = "support/install.rs"]
@@ -66,10 +68,11 @@ use self::support_auth::{
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
};
use self::support_billing::maybe_build_local_billing_response;
use self::support_ccswitch::maybe_build_local_ccswitch_response;
use self::support_dashboard::maybe_build_local_dashboard_response;
pub(crate) use self::support_install::{
build_api_key_install_session_response, build_proxy_node_install_session_response,
CreateApiKeyInstallSessionRequest,
base_url_from_request, build_api_key_install_session_response,
build_proxy_node_install_session_response, CreateApiKeyInstallSessionRequest,
};
use self::support_install::{
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
@@ -84,7 +87,10 @@ use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
direct_gateway_channels, maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, direct_gateway_channels,
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field,
};
@@ -171,6 +177,13 @@ pub(crate) async fn maybe_build_local_public_support_response(
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("ccswitch") {
if let Some(response) = maybe_build_local_ccswitch_response(state, request_context).await {
return Some(response);
}
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("users_me") {
return maybe_build_local_users_me_response(state, request_context, headers, request_body)
.await;
@@ -0,0 +1,176 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::control::GatewayLocalAuthRejection;
use crate::handlers::shared::round_to;
use super::{
build_auth_error_response, build_wallet_balance_payload_for_auth_scope,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, AppState, GatewayPublicRequestContext,
};
fn ccswitch_usage_auth_error_response(
rejection: Option<&GatewayLocalAuthRejection>,
) -> Response<Body> {
match rejection {
Some(GatewayLocalAuthRejection::InvalidApiKey) | None => {
build_auth_error_response(http::StatusCode::UNAUTHORIZED, "无效的 API Key", false)
}
Some(GatewayLocalAuthRejection::LockedApiKey) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 已被锁定", false)
}
Some(GatewayLocalAuthRejection::ProviderNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ApiFormatNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ModelNotAllowed { .. })
| Some(GatewayLocalAuthRejection::IpNotAllowed { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 无权查询用量", false)
}
Some(GatewayLocalAuthRejection::WalletUnavailable) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"钱包数据暂不可用",
false,
),
Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 余额不足", false)
}
}
}
fn json_f64(value: &serde_json::Value, key: &str) -> Option<f64> {
value.get(key).and_then(serde_json::Value::as_f64)
}
fn json_bool(value: &serde_json::Value, key: &str) -> bool {
value
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn format_usd(value: f64) -> String {
format!("${:.4}", round_to(value, 4))
}
fn build_ccswitch_usage_extra(
wallet_payload: &serde_json::Value,
today_payload: Option<&serde_json::Value>,
) -> String {
let mut parts = Vec::new();
if let Some(today_cost) = today_payload.and_then(|payload| json_f64(payload, "total_cost")) {
parts.push(format!("今日消耗 {}", format_usd(today_cost)));
}
if let Some(wallet_balance) = json_f64(wallet_payload, "wallet_balance") {
parts.push(format!("钱包 {}", format_usd(wallet_balance)));
}
if let Some(package_balance) = json_f64(wallet_payload, "package_balance") {
if package_balance > 0.0 {
parts.push(format!("套餐 {}", format_usd(package_balance)));
}
}
parts.join(" · ")
}
pub(super) async fn maybe_build_local_ccswitch_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("ccswitch") {
return None;
}
if decision.route_kind.as_deref() != Some("usage")
|| request_context.request_path.trim_end_matches('/') != "/api/ccswitch/usage"
{
return None;
}
let auth_context = match decision.auth_context.as_ref() {
Some(auth_context) if !auth_context.user_id.trim().is_empty() => auth_context,
_ => {
return Some(ccswitch_usage_auth_error_response(
decision.local_auth_rejection.as_ref(),
))
}
};
match auth_context.local_rejection.as_ref() {
None | Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {}
rejection => return Some(ccswitch_usage_auth_error_response(rejection)),
}
let wallet = match state
.read_wallet_snapshot_for_auth(
&auth_context.user_id,
&auth_context.api_key_id,
auth_context.api_key_is_standalone,
)
.await
{
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("ccswitch usage wallet lookup failed: {err:?}"),
false,
))
}
};
let wallet_payload = build_wallet_balance_payload_for_auth_scope(
state,
&auth_context.user_id,
auth_context.api_key_is_standalone,
wallet.as_ref(),
)
.await;
let today_payload = match if auth_context.api_key_is_standalone {
build_wallet_live_today_usage_payload_for_api_key(state, &auth_context.api_key_id).await
} else {
build_wallet_live_today_usage_payload_for_user(state, &auth_context.user_id).await
} {
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
err,
false,
))
}
};
let unlimited = json_bool(&wallet_payload, "unlimited");
let remaining = json_f64(&wallet_payload, "total_available_balance")
.or_else(|| json_f64(&wallet_payload, "wallet_balance"));
let used_today = today_payload
.as_ref()
.and_then(|payload| json_f64(payload, "total_cost"))
.unwrap_or(0.0);
let mut extra = build_ccswitch_usage_extra(&wallet_payload, today_payload.as_ref());
if extra.is_empty() && unlimited {
extra = "无限额度".to_string();
}
Some(
Json(json!({
"is_valid": true,
"plan_name": if unlimited { "Aether Unlimited" } else { "Aether" },
"remaining": remaining.map(|value| round_to(value.max(0.0), 6)),
"used": round_to(used_today.max(0.0), 6),
"unit": wallet_payload
.get("currency")
.and_then(serde_json::Value::as_str)
.unwrap_or("USD"),
"extra": extra,
"unlimited": unlimited,
"wallet": wallet_payload,
"today": today_payload,
}))
.into_response(),
)
}
@@ -1,6 +1,7 @@
use super::{
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
build_auth_wallet_summary_payload, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, handle_auth_me,
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
resolve_authenticated_local_user, sanitize_public_model_config_for_user, unix_secs_to_rfc3339,
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
@@ -12,8 +12,9 @@ use crate::handlers::shared::{
};
use super::{
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
validate_auth_register_password, AppState, GatewayPublicRequestContext,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
resolve_authenticated_local_user, validate_auth_register_password, AppState,
GatewayPublicRequestContext,
};
const USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL: &str = "用户资料存储暂不可用";
@@ -40,6 +41,30 @@ fn normalize_users_me_optional_non_empty_string(value: Option<String>) -> Option
value.filter(|value| !value.is_empty())
}
pub(super) async fn handle_users_me_client_config_get(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if let Err(response) = resolve_authenticated_local_user(state, request_context, headers).await {
return response;
}
let site_name = state
.read_system_config_json_value("site_name")
.await
.ok()
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "Aether".to_string());
Json(json!({
"base_url": base_url_from_request(headers, request_context),
"site_name": site_name,
}))
.into_response()
}
pub(super) async fn handle_users_me_detail_put(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -7,20 +7,20 @@ use super::{
handle_users_me_api_key_install_session_create, handle_users_me_api_key_patch,
handle_users_me_api_key_providers_put, handle_users_me_api_key_update,
handle_users_me_api_keys_get, handle_users_me_available_models,
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
handle_users_me_management_token_create, handle_users_me_management_token_delete,
handle_users_me_management_token_detail_get, handle_users_me_management_token_regenerate,
handle_users_me_management_token_toggle, handle_users_me_management_token_update,
handle_users_me_management_tokens_list, handle_users_me_model_capabilities_get,
handle_users_me_model_capabilities_put, handle_users_me_password_patch,
handle_users_me_preferences_get, handle_users_me_preferences_put,
handle_users_me_providers_get, handle_users_me_referral_get, handle_users_me_sessions_get,
handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get,
handle_users_me_usage_heatmap_get, handle_users_me_usage_interval_timeline_get,
users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches,
users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches,
users_me_management_token_detail_path_matches,
handle_users_me_client_config_get, handle_users_me_delete_other_sessions,
handle_users_me_delete_session, handle_users_me_detail_put,
handle_users_me_endpoint_status_get, handle_users_me_management_token_create,
handle_users_me_management_token_delete, handle_users_me_management_token_detail_get,
handle_users_me_management_token_regenerate, handle_users_me_management_token_toggle,
handle_users_me_management_token_update, handle_users_me_management_tokens_list,
handle_users_me_model_capabilities_get, handle_users_me_model_capabilities_put,
handle_users_me_password_patch, handle_users_me_preferences_get,
handle_users_me_preferences_put, handle_users_me_providers_get, handle_users_me_referral_get,
handle_users_me_sessions_get, handle_users_me_update_session, handle_users_me_usage_active_get,
handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches,
users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches,
users_me_management_token_regenerate_path_matches,
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
@@ -220,6 +220,9 @@ pub(crate) async fn maybe_build_local_users_me_response(
{
Some(handle_users_me_available_models(state, request_context, headers).await)
}
Some("client_config") if request_context.request_path == "/api/users/me/client-config" => {
Some(handle_users_me_client_config_get(state, request_context, headers).await)
}
Some("model_capabilities")
if request_context.request_path == "/api/users/me/model-capabilities" =>
{
@@ -28,12 +28,16 @@ mod redeem;
#[path = "wallet/refunds.rs"]
mod refunds;
use self::flow::handle_wallet_flow;
pub(in crate::handlers::public::support) use self::reads::build_wallet_balance_payload_for_user;
pub(in crate::handlers::public::support) use self::reads::{
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user,
};
use self::reads::{
build_wallet_daily_usage_payload, build_wallet_live_today_usage_payload_for_user,
build_wallet_payload, build_wallet_zero_today_entry, handle_wallet_balance,
handle_wallet_today_cost, handle_wallet_transactions, parse_wallet_limit, parse_wallet_offset,
wallet_fixed_offset, wallet_transaction_payload_from_record,
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,
parse_wallet_limit, parse_wallet_offset, wallet_fixed_offset,
wallet_transaction_payload_from_record,
};
pub(crate) use self::recharge::{direct_gateway_channels, sanitize_wallet_gateway_response};
use self::recharge::{
@@ -53,16 +53,41 @@ pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_f
state: &AppState,
user_id: &str,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
build_wallet_balance_payload_for_quota_user(state, Some(user_id), wallet).await
}
pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_for_auth_scope(
state: &AppState,
user_id: &str,
api_key_is_standalone: bool,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let quota_user_id = if api_key_is_standalone {
None
} else {
Some(user_id)
};
build_wallet_balance_payload_for_quota_user(state, quota_user_id, wallet).await
}
async fn build_wallet_balance_payload_for_quota_user(
state: &AppState,
quota_user_id: Option<&str>,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let mut payload = build_wallet_balance_payload(wallet);
let wallet_balance = wallet
.map(|value| value.balance + value.gift_balance)
.unwrap_or(0.0);
let daily_quota = state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten();
let daily_quota = match quota_user_id {
Some(user_id) => state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten(),
None => None,
};
let (has_active_daily_quota, total_quota_usd, used_usd, remaining_usd, allow_wallet_overage) =
daily_quota
.map(|quota| {
@@ -213,9 +238,10 @@ pub(super) fn build_wallet_zero_today_entry() -> serde_json::Value {
)
}
pub(super) async fn build_wallet_live_today_usage_payload_for_user(
async fn build_wallet_live_today_usage_payload_for_auth_scope(
state: &AppState,
user_id: &str,
user_id: Option<&str>,
api_key_id: Option<&str>,
) -> Result<Option<serde_json::Value>, String> {
if !state.has_usage_data_reader() {
return Ok(None);
@@ -225,7 +251,8 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
.summarize_usage_settled_cost(&UsageSettledCostSummaryQuery {
created_from_unix_secs: start_unix_secs,
created_until_unix_secs: end_unix_secs,
user_id: Some(user_id.to_string()),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: api_key_id.map(ToOwned::to_owned),
})
.await
.map_err(|err| format!("wallet today cost lookup failed: {err:?}"))?;
@@ -250,6 +277,20 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
)))
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_user(
state: &AppState,
user_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, Some(user_id), None).await
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_api_key(
state: &AppState,
api_key_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, None, Some(api_key_id)).await
}
pub(super) fn wallet_transaction_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> serde_json::Value {
@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
hash_api_key, sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
sample_public_catalog_model, sample_public_global_model,
sample_public_global_model_with_capabilities, sample_request_candidate,
InMemoryAnnouncementReadRepository, InMemoryGlobalModelReadRepository,
@@ -2372,6 +2372,28 @@ fn sample_auth_wallet(user_id: &str, now: chrono::DateTime<chrono::Utc>) -> Stor
.expect("wallet should build")
}
fn sample_standalone_auth_wallet(
api_key_id: &str,
now: chrono::DateTime<chrono::Utc>,
) -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
"wallet-standalone-1".to_string(),
None,
Some(api_key_id.to_string()),
2.0,
0.5,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
5.0,
2.5,
0.0,
0.0,
now.timestamp(),
)
.expect("standalone wallet should build")
}
fn wallet_today_usage_test_time() -> chrono::DateTime<chrono::Utc> {
let offset =
chrono::FixedOffset::east_opt(8 * 3600).expect("Asia/Shanghai test offset should be valid");
@@ -4389,6 +4411,147 @@ async fn gateway_handles_wallet_balance_locally_without_proxying_upstream() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_with_api_key_without_proxying_upstream() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-ccswitch-1",
"req-ccswitch-1",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
),
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-usage")),
sample_usage_auth_snapshot("api-key-user-1", "user-auth-1", "ccswitch"),
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-usage")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["is_valid"], true);
assert_eq!(payload["plan_name"], "Aether");
assert_eq!(payload["remaining"], 15.5);
assert_eq!(payload["used"], 1.25);
assert_eq!(payload["unit"], "USD");
assert_eq!(payload["wallet"]["wallet_balance"], 15.5);
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_for_standalone_key_without_owner_usage() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let mut owner_usage = sample_user_usage_audit(
"usage-ccswitch-owner",
"req-ccswitch-owner",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
owner_usage.api_key_id = Some("api-key-user-1".to_string());
owner_usage.total_cost_usd = 1.25;
owner_usage.actual_total_cost_usd = 1.25;
let mut standalone_usage = sample_user_usage_audit(
"usage-ccswitch-standalone",
"req-ccswitch-standalone",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
standalone_usage.api_key_id = Some("api-key-standalone-1".to_string());
standalone_usage.api_key_name = Some("standalone".to_string());
standalone_usage.total_cost_usd = 0.5;
standalone_usage.actual_total_cost_usd = 0.5;
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
owner_usage,
standalone_usage,
]));
let mut snapshot =
sample_usage_auth_snapshot("api-key-standalone-1", "user-auth-1", "standalone");
snapshot.api_key_is_standalone = true;
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-standalone")),
snapshot,
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![
sample_auth_wallet("user-auth-1", now),
sample_standalone_auth_wallet("api-key-standalone-1", now),
])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-standalone")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["is_valid"], true);
assert_eq!(payload["remaining"], 2.5);
assert_eq!(payload["used"], 0.5);
assert_eq!(payload["wallet"]["wallet"]["id"], "wallet-standalone-1");
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
let auth_now = Utc::now();
@@ -6403,6 +6566,70 @@ async fn gateway_handles_users_me_api_keys_locally_without_proxying_upstream() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_client_config_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let _public_base_url_guard =
set_test_env_var("AETHER_PUBLIC_BASE_URL", "https://aether.example.com/");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-client-config"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_system_config_values_for_tests(vec![(
"site_name".to_string(),
json!("Aether Local"),
)]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-client-config",
"device-users-me-client-config",
"refresh-token-users-me-client-config",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/client-config"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-client-config")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["base_url"], "https://aether.example.com");
assert_eq!(payload["site_name"], "Aether Local");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_api_key_detail_path_as_local_not_found_without_hitting_upstream(
) {
@@ -851,6 +851,7 @@ pub struct UsageSettledCostSummaryQuery {
pub created_from_unix_secs: u64,
pub created_until_unix_secs: u64,
pub user_id: Option<String>,
pub api_key_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
@@ -1423,6 +1423,11 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
continue;
}
}
if let Some(api_key_id) = query.api_key_id.as_deref() {
if item.api_key_id.as_deref() != Some(api_key_id) {
continue;
}
}
if item.billing_status != "settled" || item.total_cost_usd <= 0.0 {
continue;
}
@@ -3536,6 +3536,13 @@ FROM usage_billing_facts AS "usage"
.push("\"usage\".user_id = ")
.push_bind(user_id.to_string());
}
if let Some(api_key_id) = query.api_key_id.as_deref() {
builder.push(if has_where { " AND " } else { " WHERE " });
has_where = true;
builder
.push("\"usage\".api_key_id = ")
.push_bind(api_key_id.to_string());
}
builder.push(if has_where { " AND " } else { " WHERE " });
has_where = true;
builder.push("\"usage\".billing_status = 'settled'");
@@ -3678,6 +3685,7 @@ WHERE hour_utc >= $1
created_from_unix_secs: dashboard_utc_to_unix_secs(start_utc),
created_until_unix_secs: dashboard_utc_to_unix_secs(end_utc),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: None,
})
.await;
};
@@ -3689,6 +3697,7 @@ WHERE hour_utc >= $1
created_from_unix_secs: dashboard_utc_to_unix_secs(start_utc),
created_until_unix_secs: dashboard_utc_to_unix_secs(end_utc),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: None,
})
.await;
};
@@ -3701,6 +3710,7 @@ WHERE hour_utc >= $1
created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start),
created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: None,
})
.await?,
);
@@ -3723,6 +3733,7 @@ WHERE hour_utc >= $1
created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start),
created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: None,
})
.await?,
);
@@ -3737,6 +3748,9 @@ WHERE hour_utc >= $1
) -> Result<StoredUsageSettledCostSummary, DataLayerError> {
let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs);
let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs);
if query.api_key_id.is_some() {
return self.summarize_usage_settled_cost_raw(query).await;
}
let user_id = query.user_id.as_deref();
let Some(cutoff_utc) = self.read_stats_daily_cutoff_date().await? else {
return self
@@ -2036,6 +2036,12 @@ FROM "usage"
"user_id",
query.user_id.as_deref(),
);
push_sqlite_usage_optional_text_filter(
&mut builder,
&mut has_where,
"api_key_id",
query.api_key_id.as_deref(),
);
push_sqlite_usage_where(&mut builder, &mut has_where);
builder.push("billing_status = 'settled' AND COALESCE(total_cost_usd, 0) > 0");
let row = builder.build().fetch_one(&self.pool).await.map_sql_err()?;
+10
View File
@@ -199,6 +199,11 @@ export interface ApiKeyInstallSession {
powershell_command: string
}
export interface UserClientConfig {
base_url: string
site_name?: string
}
// 不再需要 ProviderBinding 接口
export interface ChangePasswordRequest {
@@ -278,6 +283,11 @@ export const meApi = {
return response.data
},
async getClientConfig(): Promise<UserClientConfig> {
const response = await apiClient.get<UserClientConfig>('/api/users/me/client-config')
return response.data
},
async deleteApiKey(keyId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/api-keys/${keyId}`)
return response.data
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import {
base64UrlEncodeUtf8,
buildCcSwitchProviderImportUrl,
type CcSwitchTargetApp,
} from '@/features/api-keys/utils/ccswitchImport'
function decodeBase64UrlJson(value: string): unknown {
const padded = `${value}${'='.repeat((4 - (value.length % 4)) % 4)}`
.replace(/-/g, '+')
.replace(/_/g, '/')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
}
function parseImportUrl(url: string) {
const parsed = new URL(url)
const params = parsed.searchParams
const config = params.get('config')
if (!config) throw new Error('missing config')
return {
parsed,
params,
config: decodeBase64UrlJson(config) as Record<string, unknown>,
}
}
const baseInput = {
baseUrl: 'https://aether.example.com',
apiKey: 'sk-user-live-1',
apiKeyName: 'primary',
siteName: 'Aether Local',
modelId: 'gpt-5',
}
describe('ccswitchImport', () => {
it('encodes UTF-8 text as base64url without padding', () => {
const encoded = base64UrlEncodeUtf8('Aether 中文+/=')
expect(encoded).not.toContain('+')
expect(encoded).not.toContain('/')
expect(encoded).not.toContain('=')
expect(new TextDecoder().decode(Uint8Array.from(atob(`${encoded}${'='.repeat((4 - (encoded.length % 4)) % 4)}`.replace(/-/g, '+').replace(/_/g, '/')), char => char.charCodeAt(0)))).toBe('Aether 中文+/=')
})
it('builds a Claude Code import URL with separate Claude model env fields', () => {
const { params, config } = parseImportUrl(buildCcSwitchProviderImportUrl({
...baseInput,
targetApp: 'claude',
modelIds: {
haiku: 'gpt-5.4-mini',
sonnet: 'gpt-5',
opus: 'gpt-5.1-pro',
},
}))
expect(params.get('resource')).toBe('provider')
expect(params.get('app')).toBe('claude')
expect(params.get('name')).toBe('Aether Local')
expect(params.get('icon')).toBe('claude')
expect(params.get('enabled')).toBe('true')
expect(params.get('configFormat')).toBe('json')
expect(params.get('usageEnabled')).toBe('true')
expect(params.get('usageBaseUrl')).toBe('https://aether.example.com')
expect(params.get('usageApiKey')).toBe('sk-user-live-1')
expect(params.get('usageAutoInterval')).toBe('30')
expect(params.get('endpoint')).toBe('https://aether.example.com')
expect(params.get('apiKey')).toBe('sk-user-live-1')
expect(params.get('model')).toBe('gpt-5')
expect(params.get('haikuModel')).toBe('gpt-5.4-mini')
expect(params.get('sonnetModel')).toBe('gpt-5')
expect(params.get('opusModel')).toBe('gpt-5.1-pro')
const usageScript = params.get('usageScript') || ''
expect(usageScript).not.toBe('')
expect(usageScript).not.toContain('(')
expect(usageScript).not.toContain('+')
expect(usageScript).not.toContain('/')
const paddedUsageScript = `${usageScript}${'='.repeat((4 - (usageScript.length % 4)) % 4)}`
.replace(/-/g, '+')
.replace(/_/g, '/')
const decodedUsageScript = new TextDecoder().decode(
Uint8Array.from(atob(paddedUsageScript), char => char.charCodeAt(0)),
)
expect(decodedUsageScript).toContain('/api/ccswitch/usage')
expect(decodedUsageScript).toContain('Authorization')
expect(config).toEqual({
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-user-live-1',
ANTHROPIC_BASE_URL: 'https://aether.example.com',
ANTHROPIC_MODEL: 'gpt-5',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.1-pro',
},
})
})
it('builds a Codex import URL with auth and TOML config', () => {
const { params, config } = parseImportUrl(buildCcSwitchProviderImportUrl({
...baseInput,
targetApp: 'codex',
}))
expect(params.get('app')).toBe('codex')
expect(params.get('icon')).toBe('openai')
expect(params.get('endpoint')).toBe('https://aether.example.com/v1')
expect(params.get('apiKey')).toBe('sk-user-live-1')
expect(params.get('model')).toBe('gpt-5')
expect(config.auth).toEqual({ OPENAI_API_KEY: 'sk-user-live-1' })
expect(config.config).toContain('model_provider = "aether"')
expect(config.config).toContain('model = "gpt-5"')
expect(config.config).toContain('base_url = "https://aether.example.com/v1"')
expect(config.config).toContain('wire_api = "responses"')
})
it.each([
['gemini', {
GEMINI_API_KEY: 'sk-user-live-1',
GOOGLE_GEMINI_BASE_URL: 'https://aether.example.com',
GEMINI_MODEL: 'gpt-5',
}],
['opencode', {
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'https://aether.example.com/v1',
apiKey: 'sk-user-live-1',
},
models: {
'gpt-5': { name: 'gpt-5' },
},
}],
['openclaw', {
baseUrl: 'https://aether.example.com/v1',
apiKey: 'sk-user-live-1',
api: 'openai-completions',
models: [{ id: 'gpt-5', name: 'gpt-5' }],
}],
['hermes', {
name: 'Aether Local',
base_url: 'https://aether.example.com/v1',
api_key: 'sk-user-live-1',
api_mode: 'chat_completions',
models: [{ id: 'gpt-5', name: 'gpt-5' }],
}],
] satisfies Array<[CcSwitchTargetApp, Record<string, unknown>]>)('builds %s import config', (targetApp, expectedConfig) => {
const { params, config } = parseImportUrl(buildCcSwitchProviderImportUrl({
...baseInput,
targetApp,
}))
expect(params.get('app')).toBe(targetApp)
expect(params.get('endpoint')).toBe(
targetApp === 'gemini'
? 'https://aether.example.com'
: 'https://aether.example.com/v1',
)
expect(params.get('apiKey')).toBe('sk-user-live-1')
expect(params.get('model')).toBe('gpt-5')
expect(config).toEqual(expectedConfig)
})
})
@@ -0,0 +1,237 @@
export type CcSwitchTargetApp = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'hermes'
export interface CcSwitchTargetOption {
value: CcSwitchTargetApp
label: string
icon: string
}
export interface BuildCcSwitchProviderImportUrlInput {
targetApp: CcSwitchTargetApp
baseUrl: string
apiKey: string
apiKeyName: string
siteName?: string
modelId?: string
modelIds?: CcSwitchModelIds
providerName?: string
enabled?: boolean
}
export interface CcSwitchModelIds {
default?: string
haiku?: string
sonnet?: string
opus?: string
}
interface NormalizedCcSwitchModelIds {
default: string
haiku: string
sonnet: string
opus: string
}
export const CC_SWITCH_TARGET_OPTIONS: CcSwitchTargetOption[] = [
{ value: 'claude', label: 'Claude Code', icon: 'claude' },
{ value: 'codex', label: 'Codex CLI', icon: 'openai' },
{ value: 'gemini', label: 'Gemini CLI', icon: 'gemini' },
{ value: 'opencode', label: 'OpenCode', icon: 'opencode' },
{ value: 'openclaw', label: 'OpenClaw', icon: 'openclaw' },
{ value: 'hermes', label: 'Hermes', icon: 'hermes' },
]
const AETHER_USAGE_QUERY_SCRIPT = [
'({',
' request: {',
' url: "{{baseUrl}}/api/ccswitch/usage",',
' method: "GET",',
' headers: {',
' "Authorization": "Bearer {{apiKey}}",',
' "Accept": "application/json",',
' "User-Agent": "cc-switch/1.0"',
' }',
' },',
' extractor: function(response) {',
' if (response && response.is_valid === false) {',
' return {',
' isValid: false,',
' invalidMessage: response.invalid_message || "查询失败"',
' };',
' }',
'',
' return {',
' isValid: true,',
' planName: response.plan_name || "Aether",',
' remaining: response.remaining,',
' used: response.used,',
' total: response.total,',
' unit: response.unit || "USD",',
' extra: response.extra',
' };',
' }',
'})',
].join('\n')
export function base64UrlEncodeUtf8(text: string): string {
const bytes = new TextEncoder().encode(text)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
function normalizedBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/g, '')
}
function aetherV1BaseUrl(baseUrl: string): string {
return `${normalizedBaseUrl(baseUrl)}/v1`
}
function ccSwitchEndpointForTarget(targetApp: CcSwitchTargetApp, baseUrl: string): string {
if (targetApp === 'claude' || targetApp === 'gemini') {
return normalizedBaseUrl(baseUrl)
}
return aetherV1BaseUrl(baseUrl)
}
function quoteTomlString(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
export function ccSwitchTargetLabel(targetApp: CcSwitchTargetApp): string {
return CC_SWITCH_TARGET_OPTIONS.find(option => option.value === targetApp)?.label || targetApp
}
export function defaultCcSwitchProviderName(
siteName?: string,
): string {
return siteName?.trim() || 'Aether'
}
function buildCodexToml(baseUrl: string, modelId: string): string {
return [
'model_provider = "aether"',
`model = ${quoteTomlString(modelId)}`,
'model_reasoning_effort = "high"',
'disable_response_storage = true',
'',
'[model_providers.aether]',
'name = "Aether"',
`base_url = ${quoteTomlString(aetherV1BaseUrl(baseUrl))}`,
'wire_api = "responses"',
'requires_openai_auth = true',
'',
].join('\n')
}
function normalizeCcSwitchModelIds(input: BuildCcSwitchProviderImportUrlInput): NormalizedCcSwitchModelIds {
const defaultModel = input.modelIds?.default?.trim() || input.modelId?.trim() || ''
const sonnet = input.modelIds?.sonnet?.trim() || defaultModel
const haiku = input.modelIds?.haiku?.trim() || sonnet
const opus = input.modelIds?.opus?.trim() || sonnet
return {
default: defaultModel,
haiku,
sonnet,
opus,
}
}
function buildCcSwitchConfig(input: BuildCcSwitchProviderImportUrlInput): Record<string, unknown> {
const baseUrl = normalizedBaseUrl(input.baseUrl)
const modelIds = normalizeCcSwitchModelIds(input)
const providerName = input.providerName?.trim() || defaultCcSwitchProviderName(input.siteName)
switch (input.targetApp) {
case 'claude':
return {
env: {
ANTHROPIC_AUTH_TOKEN: input.apiKey,
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_MODEL: modelIds.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelIds.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: modelIds.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: modelIds.opus,
},
}
case 'codex':
return {
auth: {
OPENAI_API_KEY: input.apiKey,
},
config: buildCodexToml(baseUrl, modelIds.default),
}
case 'gemini':
return {
GEMINI_API_KEY: input.apiKey,
GOOGLE_GEMINI_BASE_URL: baseUrl,
GEMINI_MODEL: modelIds.default,
}
case 'opencode':
return {
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: aetherV1BaseUrl(baseUrl),
apiKey: input.apiKey,
},
models: {
[modelIds.default]: {
name: modelIds.default,
},
},
}
case 'openclaw':
return {
baseUrl: aetherV1BaseUrl(baseUrl),
apiKey: input.apiKey,
api: 'openai-completions',
models: [{ id: modelIds.default, name: modelIds.default }],
}
case 'hermes':
return {
name: providerName,
base_url: aetherV1BaseUrl(baseUrl),
api_key: input.apiKey,
api_mode: 'chat_completions',
models: [{ id: modelIds.default, name: modelIds.default }],
}
}
}
export function buildCcSwitchProviderImportUrl(input: BuildCcSwitchProviderImportUrlInput): string {
const providerName = input.providerName?.trim() || defaultCcSwitchProviderName(input.siteName)
const icon = CC_SWITCH_TARGET_OPTIONS.find(option => option.value === input.targetApp)?.icon
const modelIds = normalizeCcSwitchModelIds(input)
const params = new URLSearchParams()
params.set('resource', 'provider')
params.set('app', input.targetApp)
params.set('name', providerName)
params.set('endpoint', ccSwitchEndpointForTarget(input.targetApp, input.baseUrl))
params.set('apiKey', input.apiKey)
params.set('model', input.targetApp === 'claude' ? modelIds.sonnet : modelIds.default)
if (input.targetApp === 'claude') {
params.set('haikuModel', modelIds.haiku)
params.set('sonnetModel', modelIds.sonnet)
params.set('opusModel', modelIds.opus)
}
params.set('configFormat', 'json')
params.set('config', base64UrlEncodeUtf8(JSON.stringify(buildCcSwitchConfig(input))))
params.set('enabled', input.enabled === false ? 'false' : 'true')
params.set('usageEnabled', 'true')
params.set('usageBaseUrl', normalizedBaseUrl(input.baseUrl))
params.set('usageApiKey', input.apiKey)
params.set('usageAutoInterval', '30')
params.set('usageScript', base64UrlEncodeUtf8(AETHER_USAGE_QUERY_SCRIPT))
if (icon) params.set('icon', icon)
return `ccswitch://v1/import?${params.toString()}`
}
+11
View File
@@ -881,6 +881,17 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse(MOCK_USER_API_KEYS)
},
'GET /api/users/me/client-config': async () => {
await delay()
const baseUrl = typeof window !== 'undefined'
? window.location.origin
: 'https://demo.aether.local'
return createMockResponse({
base_url: baseUrl,
site_name: 'Aether Demo',
})
},
'POST /api/users/me/api-keys': async (config) => {
await delay()
const body = JSON.parse(config.data || '{}')
+463 -3
View File
@@ -194,6 +194,16 @@
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
:data-testid="`ccswitch-open-${apiKey.id}`"
variant="ghost"
size="icon"
class="h-8 w-8"
title="导入到 CC Switch"
@click="openCcSwitchImportDialog(apiKey)"
>
<Download class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -285,6 +295,16 @@
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
:data-testid="`ccswitch-open-mobile-${apiKey.id}`"
variant="ghost"
size="icon"
class="h-7 w-7"
title="导入到 CC Switch"
@click="openCcSwitchImportDialog(apiKey)"
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -593,6 +613,16 @@
</div>
<template #footer>
<Button
data-testid="ccswitch-open-created-key"
variant="outline"
class="h-10 px-5 gap-2"
:disabled="!createdApiKey || !newKeyValue"
@click="openCcSwitchImportDialogForCreatedKey"
>
<Download class="h-4 w-4" />
导入 CC Switch
</Button>
<Button
class="h-10 px-5"
@click="closeCreatedKeyDialog"
@@ -602,6 +632,154 @@
</template>
</Dialog>
<!-- 导入 CC Switch 对话框 -->
<Dialog
v-model="showCcSwitchDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Download class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
导入到 CC Switch
</h3>
<p class="text-xs text-muted-foreground truncate">
当前密钥{{ selectedCcSwitchApiKey?.name || '未选择' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-5">
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
选择目标客户端和模型 ID点击导入后浏览器会请求打开 CC Switch本页面不会展示或保存包含 API Key 的链接
</div>
<div class="space-y-2">
<Label class="text-sm font-semibold">目标客户端</Label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
v-for="option in ccSwitchTargetOptions"
:key="option.value"
:data-testid="`ccswitch-target-${option.value}`"
:variant="ccSwitchTargetApp === option.value ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="selectCcSwitchTarget(option.value)"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="space-y-2">
<Label
for="ccswitch-provider-name"
class="text-sm font-semibold"
>站点名称</Label>
<Input
id="ccswitch-provider-name"
:model-value="ccSwitchProviderName"
class="h-11 border-border/60"
autocomplete="off"
data-testid="ccswitch-provider-name"
@update:model-value="updateCcSwitchProviderName"
/>
</div>
<div class="space-y-3">
<Label class="text-sm font-semibold">
{{ ccSwitchTargetApp === 'claude' ? '模型 ID 选择' : '模型 ID' }}
</Label>
<div class="space-y-3">
<div
v-for="field in ccSwitchModelFields"
:key="field.key"
class="space-y-1.5"
>
<Label
:for="`ccswitch-model-${field.key}`"
class="text-xs font-medium text-muted-foreground"
>
{{ field.label }}
</Label>
<Select
v-model="ccSwitchModelIds[field.key]"
:disabled="!ccSwitchHasModelOptions"
>
<SelectTrigger
:id="`ccswitch-model-${field.key}`"
:data-testid="`ccswitch-model-select-${field.key}`"
class="h-11 rounded-2xl border-border/60 bg-card/80 font-mono text-xs"
>
<SelectValue placeholder="选择模型 ID" />
</SelectTrigger>
<SelectContent
class="max-w-[min(26rem,calc(100vw-4rem))] max-h-[22rem]"
search-placeholder="搜索模型 ID..."
:search-threshold="4"
>
<SelectItem
v-for="model in ccSwitchModelOptions"
:key="model"
:value="model"
:text-value="model"
class="font-mono"
>
{{ model }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ ccSwitchModelHelpText }}
</p>
<p
v-if="!ccSwitchPreparing && !ccSwitchHasModelOptions"
class="text-xs text-destructive"
>
暂无可用模型请联系管理员配置可用模型后再导入
</p>
</div>
<div
v-if="ccSwitchPreparing"
class="text-xs text-muted-foreground"
>
正在加载导入配置...
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showCcSwitchDialog = false"
>
关闭
</Button>
<Button
data-testid="ccswitch-confirm"
class="h-10 px-5 shadow-lg shadow-primary/20"
:disabled="ccSwitchConfirmDisabled"
@click="confirmCcSwitchImport"
>
<Loader2
v-if="ccSwitchLoading"
class="animate-spin h-4 w-4 mr-2"
/>
{{ ccSwitchLoading ? '准备中...' : '导入到 CC Switch' }}
</Button>
</template>
</Dialog>
<!-- 一键安装并配置 CLI 对话框 -->
<Dialog
v-model="showInstallDialog"
@@ -734,7 +912,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed, watch, reactive } from 'vue'
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
@@ -742,7 +920,15 @@ import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import Switch from '@/components/ui/switch.vue'
import { Dialog, Pagination } from '@/components/ui'
import {
Dialog,
Pagination,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
Table,
@@ -753,7 +939,7 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal, Download } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
@@ -765,6 +951,13 @@ import {
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import {
CC_SWITCH_TARGET_OPTIONS,
buildCcSwitchProviderImportUrl,
defaultCcSwitchProviderName,
type CcSwitchModelIds,
type CcSwitchTargetApp,
} from '@/features/api-keys/utils/ccswitchImport'
const { success, error: showError } = useToast()
@@ -780,6 +973,14 @@ const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: st
{ value: 'windows', label: 'Windows' }
]
const ccSwitchTargetOptions = CC_SWITCH_TARGET_OPTIONS
type CcSwitchModelFieldKey = keyof Required<CcSwitchModelIds>
interface CcSwitchModelField {
key: CcSwitchModelFieldKey
label: string
}
const apiKeys = ref<ApiKey[]>([])
const loading = ref(false)
const creating = ref(false)
@@ -798,6 +999,7 @@ const showCreateDialog = ref(false)
const showKeyDialog = ref(false)
const showDeleteDialog = ref(false)
const showInstallDialog = ref(false)
const showCcSwitchDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
@@ -807,6 +1009,7 @@ const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
const newKeyRedactionEnabled = ref(false)
const newKeyRedactionInjectNotice = ref(true)
const newKeyValue = ref('')
const createdApiKey = ref<ApiKey | null>(null)
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
const selectedInstallApiKey = ref<ApiKey | null>(null)
@@ -817,6 +1020,22 @@ const installSession = ref<ApiKeyInstallSession | null>(null)
const installLoading = ref(false)
const installCopied = ref(false)
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
const selectedCcSwitchApiKey = ref<ApiKey | null>(null)
const ccSwitchPlainApiKey = ref('')
const ccSwitchTargetApp = ref<CcSwitchTargetApp>('claude')
const ccSwitchProviderName = ref('')
const ccSwitchProviderNameDirty = ref(false)
const ccSwitchSiteName = ref('Aether')
const ccSwitchModelIds = reactive<Record<CcSwitchModelFieldKey, string>>({
default: '',
haiku: '',
sonnet: '',
opus: '',
})
const ccSwitchBaseUrl = ref('')
const ccSwitchAvailableModels = ref<string[]>([])
const ccSwitchPreparing = ref(false)
const ccSwitchLoading = ref(false)
const installCommand = computed(() => {
if (!installSession.value) return ''
@@ -832,6 +1051,42 @@ const installCommandHint = computed(() => {
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
})
const ccSwitchModelOptions = computed(() => ccSwitchAvailableModels.value)
const ccSwitchHasModelOptions = computed(() => ccSwitchModelOptions.value.length > 0)
const ccSwitchConfirmDisabled = computed(() =>
ccSwitchLoading.value || ccSwitchPreparing.value || !ccSwitchHasModelOptions.value,
)
const ccSwitchModelFields = computed<CcSwitchModelField[]>(() => {
if (ccSwitchTargetApp.value === 'claude') {
return [
{
key: 'haiku',
label: 'Haiku 模型 ID',
},
{
key: 'sonnet',
label: 'Sonnet 模型 ID',
},
{
key: 'opus',
label: 'Opus 模型 ID',
},
]
}
return [
{
key: 'default',
label: '默认模型 ID',
},
]
})
const ccSwitchModelHelpText = computed(() =>
ccSwitchTargetApp.value === 'claude'
? 'Claude Code 会分别写入 Haiku、Sonnet、OpusSonnet 同时作为默认模型;模型多时可在下拉中搜索并滚动选择。'
: '从 Aether 可用模型中选择,模型多时可在下拉中搜索并滚动选择。',
)
onMounted(() => {
installSystem.value = detectCurrentSystem()
loadApiKeys()
@@ -900,6 +1155,7 @@ function openEditApiKeyDialog(apiKey: ApiKey) {
function openCreateApiKeyDialog() {
editingApiKey.value = null
createdApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
@@ -968,10 +1224,210 @@ async function copyInstallCommand() {
}, 2000)
}
function uniqueModelNames(models: Array<{ name?: string | null; display_name?: string | null }>): string[] {
const names = new Set<string>()
for (const model of models) {
const name = String(model.name || '').trim()
if (name) names.add(name)
}
return Array.from(names)
}
function findRecommendedModel(
models: string[],
predicate: (model: string) => boolean,
): string | undefined {
return models.find(model => predicate(model.toLowerCase()))
}
function recommendedDefaultCcSwitchModel(targetApp: CcSwitchTargetApp, models: string[]): string {
const findModel = (predicate: (model: string) => boolean) =>
findRecommendedModel(models, predicate)
if (targetApp === 'claude') {
return findModel(model => model.includes('claude') && model.includes('sonnet'))
|| findModel(model => model.includes('claude'))
|| findModel(model => model.startsWith('gpt') || model.includes('gpt-'))
|| models[0]
|| ''
}
if (targetApp === 'gemini') {
return findModel(model => model.includes('gemini')) || models[0] || ''
}
return findModel(model => model.startsWith('gpt') || model.includes('gpt-'))
|| models[0]
|| ''
}
function recommendedCcSwitchModelIds(targetApp: CcSwitchTargetApp, models: string[]): Record<CcSwitchModelFieldKey, string> {
const defaultModel = recommendedDefaultCcSwitchModel(targetApp, models)
if (targetApp !== 'claude') {
return {
default: defaultModel,
haiku: '',
sonnet: '',
opus: '',
}
}
const gptFallback = findRecommendedModel(
models,
model => model.startsWith('gpt') || model.includes('gpt-'),
) || defaultModel
const sonnet = findRecommendedModel(
models,
model => model.includes('sonnet'),
) || findRecommendedModel(
models,
model => model.includes('claude'),
) || gptFallback
return {
default: sonnet,
haiku: findRecommendedModel(models, model => model.includes('haiku')) || gptFallback,
sonnet,
opus: findRecommendedModel(models, model => model.includes('opus')) || sonnet,
}
}
function applyRecommendedCcSwitchModelIds(targetApp: CcSwitchTargetApp) {
const recommended = recommendedCcSwitchModelIds(targetApp, ccSwitchAvailableModels.value)
for (const field of ccSwitchModelFields.value) {
ccSwitchModelIds[field.key] = recommended[field.key]
}
}
function updateCcSwitchProviderName(value: string) {
ccSwitchProviderName.value = value
ccSwitchProviderNameDirty.value = true
}
function selectedCcSwitchModelIds(): CcSwitchModelIds {
if (ccSwitchTargetApp.value === 'claude') {
return {
default: ccSwitchModelIds.sonnet.trim(),
haiku: ccSwitchModelIds.haiku.trim(),
sonnet: ccSwitchModelIds.sonnet.trim(),
opus: ccSwitchModelIds.opus.trim(),
}
}
return {
default: ccSwitchModelIds.default.trim(),
}
}
async function prepareCcSwitchDialog() {
ccSwitchPreparing.value = true
try {
const [clientConfig, modelsResponse] = await Promise.all([
meApi.getClientConfig(),
meApi.getAvailableModels({ limit: 1000 }),
])
ccSwitchBaseUrl.value = clientConfig.base_url
ccSwitchSiteName.value = clientConfig.site_name?.trim() || 'Aether'
if (!ccSwitchProviderNameDirty.value) {
ccSwitchProviderName.value = defaultCcSwitchProviderName(ccSwitchSiteName.value)
}
ccSwitchAvailableModels.value = uniqueModelNames(modelsResponse.models || [])
applyRecommendedCcSwitchModelIds(ccSwitchTargetApp.value)
} catch (error) {
log.error('加载 CC Switch 导入配置失败:', error)
showError(parseApiError(error, '加载 CC Switch 导入配置失败'))
} finally {
ccSwitchPreparing.value = false
}
}
async function openCcSwitchImportDialog(apiKey: ApiKey, plainApiKey = '') {
selectedCcSwitchApiKey.value = apiKey
ccSwitchPlainApiKey.value = plainApiKey
ccSwitchTargetApp.value = 'claude'
ccSwitchProviderNameDirty.value = false
ccSwitchProviderName.value = defaultCcSwitchProviderName(ccSwitchSiteName.value)
ccSwitchBaseUrl.value = ''
ccSwitchAvailableModels.value = []
for (const key of Object.keys(ccSwitchModelIds) as CcSwitchModelFieldKey[]) {
ccSwitchModelIds[key] = ''
}
showCcSwitchDialog.value = true
await prepareCcSwitchDialog()
}
async function openCcSwitchImportDialogForCreatedKey() {
if (!createdApiKey.value || !newKeyValue.value) return
pendingFirstInstallApiKey.value = null
showKeyDialog.value = false
await openCcSwitchImportDialog(createdApiKey.value, newKeyValue.value)
}
function selectCcSwitchTarget(value: CcSwitchTargetApp) {
ccSwitchTargetApp.value = value
applyRecommendedCcSwitchModelIds(value)
}
function isMissingFullApiKeyError(message: string): boolean {
return message.includes('没有存储完整密钥信息') || message.includes('缺少完整密钥')
}
async function confirmCcSwitchImport() {
if (!selectedCcSwitchApiKey.value) return
if (!ccSwitchHasModelOptions.value) {
showError('暂无可用模型,请联系管理员配置可用模型后再导入')
return
}
const missingModelField = ccSwitchModelFields.value.find(field => !ccSwitchModelIds[field.key].trim())
if (missingModelField) {
showError(`请填写${missingModelField.label}`)
return
}
ccSwitchLoading.value = true
try {
const apiKey = ccSwitchPlainApiKey.value
|| (await meApi.getFullApiKey(selectedCcSwitchApiKey.value.id)).key
const baseUrl = ccSwitchBaseUrl.value || (await meApi.getClientConfig()).base_url
const importUrl = buildCcSwitchProviderImportUrl({
targetApp: ccSwitchTargetApp.value,
baseUrl,
apiKey,
apiKeyName: selectedCcSwitchApiKey.value.name,
siteName: ccSwitchSiteName.value,
modelIds: selectedCcSwitchModelIds(),
providerName: ccSwitchProviderName.value,
})
if (importUrl.length > 8000) {
showError('CC Switch 导入链接过长,请减少模型配置后重试')
return
}
window.location.href = importUrl
success('如果浏览器询问是否打开 CC Switch,请选择允许')
showCcSwitchDialog.value = false
} catch (error) {
log.error('生成 CC Switch 导入链接失败:', error)
const message = parseApiError(error, '生成 CC Switch 导入链接失败')
showError(
isMissingFullApiKeyError(message)
? '该密钥缺少完整密钥信息,请重新创建 API Key'
: message,
)
} finally {
ccSwitchLoading.value = false
}
}
function closeCreatedKeyDialog() {
showKeyDialog.value = false
const pending = pendingFirstInstallApiKey.value
pendingFirstInstallApiKey.value = null
createdApiKey.value = null
if (pending) {
void openInstallDialog(pending)
}
@@ -980,6 +1436,9 @@ function closeCreatedKeyDialog() {
function closeApiKeyDialog() {
showCreateDialog.value = false
editingApiKey.value = null
if (!showKeyDialog.value) {
createdApiKey.value = null
}
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
@@ -1029,6 +1488,7 @@ async function saveApiKey() {
: {}),
})
newKeyValue.value = newKey.key || ''
createdApiKey.value = newKey
if (isCreatingFirstApiKey) {
pendingFirstInstallApiKey.value = newKey
}
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import MyApiKeys from '../MyApiKeys.vue'
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}))
const meApiMock = vi.hoisted(() => ({
getApiKeys: vi.fn(),
createApiKey: vi.fn(),
getFullApiKey: vi.fn(),
getClientConfig: vi.fn(),
getAvailableModels: vi.fn(),
createApiKeyInstallSession: vi.fn(),
updateApiKey: vi.fn(),
deleteApiKey: vi.fn(),
toggleApiKey: vi.fn(),
}))
vi.mock('@/api/me', () => ({
meApi: meApiMock,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMock,
}))
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return {
LoadingState: defineComponent({
props: { message: String },
setup: props => () => h('div', props.message || 'loading'),
}),
EmptyState: defineComponent({
props: { title: String, description: String, icon: [Object, Function] },
setup: (props, { slots }) => () => h('div', [
h('div', props.title || ''),
h('div', props.description || ''),
slots.actions?.(),
]),
}),
AlertDialog: defineComponent({
emits: ['confirm', 'cancel'],
setup: () => () => null,
}),
}
})
vi.mock('@/utils/logger', () => ({
log: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function apiKey(overrides: Record<string, unknown> = {}) {
return {
id: 'user-key-1',
name: 'primary',
key_display: 'sk-user...live',
is_active: true,
is_locked: false,
created_at: '2026-05-29T00:00:00+00:00',
total_requests: 0,
total_cost_usd: 0,
rate_limit: 0,
concurrent_limit: 0,
ip_rules: null,
...overrides,
}
}
async function flushPromises() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountMyApiKeys() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(MyApiKeys)
app.mount(root)
mountedApps.push({ app, root })
await flushPromises()
return root
}
beforeEach(() => {
vi.clearAllMocks()
meApiMock.getClientConfig.mockResolvedValue({
base_url: 'https://aether.example.com',
site_name: 'Aether Local',
})
meApiMock.getAvailableModels.mockResolvedValue({
models: [
{ id: 'gm-1', name: 'claude-haiku-4', display_name: 'Claude Haiku 4', is_active: true },
{ id: 'gm-2', name: 'claude-sonnet-4', display_name: 'Claude Sonnet 4', is_active: true },
{ id: 'gm-3', name: 'claude-opus-4', display_name: 'Claude Opus 4', is_active: true },
{ id: 'gm-4', name: 'gpt-5', display_name: 'GPT 5', is_active: true },
],
total: 4,
})
meApiMock.createApiKeyInstallSession.mockResolvedValue({
install_code: 'install-code',
expires_at_unix_secs: 1,
expires_in_seconds: 900,
target_cli: 'claude_code',
target_cli_label: 'Claude Code',
target_system: 'linux',
target_system_label: 'Linux',
unix_command: 'curl install',
powershell_command: 'irm install',
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('MyApiKeys CC Switch import', () => {
it('opens the import dialog for an existing key without fetching the full key immediately', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('导入到 CC Switch')
expect(document.querySelector<HTMLInputElement>('[data-testid="ccswitch-provider-name"]')?.value).toBe('Aether Local')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')?.textContent).toContain('claude-haiku-4')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-sonnet"]')?.textContent).toContain('claude-sonnet-4')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-opus"]')?.textContent).toContain('claude-opus-4')
})
it('switches non-Claude targets to a single default model without changing the site provider name', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-target-codex"]')?.click()
await flushPromises()
expect(document.querySelector<HTMLInputElement>('[data-testid="ccswitch-provider-name"]')?.value).toBe('Aether Local')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-default"]')?.textContent).toContain('gpt-5')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')).toBeNull()
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-sonnet"]')).toBeNull()
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-opus"]')).toBeNull()
})
it('shows a specific message when an existing key cannot return full key material', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.getFullApiKey.mockRejectedValue({
response: { data: { detail: '该密钥没有存储完整密钥信息' } },
})
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-confirm"]')?.click()
await flushPromises()
expect(toastMock.error).toHaveBeenCalledWith('该密钥缺少完整密钥信息,请重新创建 API Key')
})
it('does not fall back to an unlisted model when no available models are returned', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.getAvailableModels.mockResolvedValueOnce({
models: [],
total: 0,
})
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
const confirmButton = document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-confirm"]')
expect(document.body.textContent).toContain('暂无可用模型,请联系管理员配置可用模型后再导入。')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')?.textContent).not.toContain('gpt-5')
expect(confirmButton?.disabled).toBe(true)
confirmButton?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
})
it('can open CC Switch import from the newly created key dialog without refetching the key', async () => {
const createdKey = apiKey({ id: 'created-key-1', name: 'new key', key: 'sk-created-live' })
meApiMock.getApiKeys.mockResolvedValueOnce([]).mockResolvedValue([createdKey])
meApiMock.createApiKey.mockResolvedValue(createdKey)
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="创建新 API Key"]')?.click()
await flushPromises()
const nameInput = document.querySelector<HTMLInputElement>('#key-name')
nameInput!.value = 'new key'
nameInput!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.trim() === '创建')
?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-created-key"]')?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('导入到 CC Switch')
})
})