mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import'
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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 || '{}')
|
||||
|
||||
@@ -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、Opus,Sonnet 同时作为默认模型;模型多时可在下拉中搜索并滚动选择。'
|
||||
: '从 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user