mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -177,6 +177,17 @@ pub(super) fn classify_admin_endpoints_family_route(
|
||||
"admin:endpoints_manage",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& normalized_path.starts_with("/api/admin/endpoints/providers/")
|
||||
&& normalized_path.ends_with("/key-balance")
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"endpoints_manage",
|
||||
"query_key_balance",
|
||||
"admin:endpoints_manage",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& normalized_path.starts_with("/api/admin/endpoints/providers/")
|
||||
&& normalized_path.ends_with("/keys")
|
||||
|
||||
@@ -380,6 +380,20 @@ fn classifies_admin_refresh_provider_quota_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_query_provider_key_balance_as_admin_proxy_route() {
|
||||
let headers = http::HeaderMap::new();
|
||||
let uri: Uri = "/api/admin/endpoints/providers/provider-newapi/key-balance"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
|
||||
.expect("decision should resolve");
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("endpoints_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some("query_key_balance"));
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
|
||||
let headers = headers(&[]);
|
||||
@@ -399,6 +413,25 @@ fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_query_provider_key_balance_buffers_request_body_for_key_secret() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/admin/endpoints/providers/provider-newapi/key-balance"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
|
||||
.expect("decision should resolve");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-key-balance",
|
||||
&http::Method::POST,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_default_body_rules_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod balance;
|
||||
mod mutations;
|
||||
mod quota;
|
||||
mod reads;
|
||||
@@ -18,6 +19,10 @@ pub(crate) async fn maybe_build_local_admin_endpoints_keys_response(
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) = balance::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) = mutations::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,68 @@ pub(super) fn admin_provider_ops_is_valid_action_type(action_type: &str) -> bool
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_ops_saved_connector_credentials(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
admin_provider_ops_decrypted_credentials(
|
||||
state,
|
||||
admin_provider_ops_config_object(provider)
|
||||
.and_then(admin_provider_ops_connector_object)
|
||||
.and_then(|connector| connector.get("credentials")),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_provider_ops_query_balance_response_for_credentials(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
architecture_id: &str,
|
||||
base_url: &str,
|
||||
provider_ops_config: &serde_json::Map<String, serde_json::Value>,
|
||||
connector_config: &serde_json::Map<String, serde_json::Value>,
|
||||
credentials: &serde_json::Map<String, serde_json::Value>,
|
||||
request_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> serde_json::Value {
|
||||
let architecture_id = normalize_architecture_id(architecture_id);
|
||||
let Some(architecture) = get_architecture(architecture_id) else {
|
||||
return responses::admin_provider_ops_action_not_supported(
|
||||
"query_balance",
|
||||
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
|
||||
);
|
||||
};
|
||||
let headers = match build_headers(architecture.architecture_id, connector_config, credentials) {
|
||||
Ok(headers) => headers,
|
||||
Err(message) => {
|
||||
return responses::admin_provider_ops_action_not_configured("query_balance", message);
|
||||
}
|
||||
};
|
||||
let Some(action_config) = resolve_action_config(
|
||||
architecture_id,
|
||||
provider_ops_config,
|
||||
"query_balance",
|
||||
request_config,
|
||||
) else {
|
||||
return responses::admin_provider_ops_action_not_supported(
|
||||
"query_balance",
|
||||
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
|
||||
);
|
||||
};
|
||||
|
||||
query_balance::admin_provider_ops_run_query_balance_action(
|
||||
state,
|
||||
provider_id,
|
||||
provider,
|
||||
&architecture,
|
||||
base_url,
|
||||
&action_config,
|
||||
&headers,
|
||||
credentials,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_provider_ops_local_action_response(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
|
||||
@@ -111,11 +111,14 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
|
||||
|
||||
if status != http::StatusCode::OK {
|
||||
let cookie_auth = architecture.query_balance_cookie_auth_errors;
|
||||
let new_api_token_auth = architecture.architecture_id == "new_api";
|
||||
return match status {
|
||||
http::StatusCode::UNAUTHORIZED => admin_provider_ops_action_error(
|
||||
"auth_failed",
|
||||
"query_balance",
|
||||
if cookie_auth {
|
||||
if new_api_token_auth {
|
||||
"访问令牌无效,请使用 New API 个人安全设置里的访问令牌"
|
||||
} else if cookie_auth {
|
||||
"Cookie 已失效,请重新配置"
|
||||
} else {
|
||||
"认证失败"
|
||||
@@ -125,7 +128,9 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
|
||||
http::StatusCode::FORBIDDEN => admin_provider_ops_action_error(
|
||||
"auth_failed",
|
||||
"query_balance",
|
||||
if cookie_auth {
|
||||
if new_api_token_auth {
|
||||
"访问令牌无效或无权限,请使用 New API 个人安全设置里的访问令牌"
|
||||
} else if cookie_auth {
|
||||
"Cookie 已失效或无权限"
|
||||
} else {
|
||||
"无权限访问"
|
||||
|
||||
@@ -8,7 +8,9 @@ use super::super::responses::{
|
||||
};
|
||||
use super::super::support::admin_provider_ops_json_object_map;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use aether_admin::provider::ops::parse_sub2api_balance_payload;
|
||||
use aether_admin::provider::ops::{
|
||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
||||
};
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
use serde_json::{json, Value};
|
||||
@@ -24,6 +26,23 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
|
||||
proxy_snapshot: Option<&ProxySnapshot>,
|
||||
) -> serde_json::Value {
|
||||
let start = std::time::Instant::now();
|
||||
if let Some(api_key) = credentials
|
||||
.get("api_key")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return admin_provider_ops_sub2api_api_key_balance_payload(
|
||||
state,
|
||||
provider_id,
|
||||
base_url,
|
||||
action_config,
|
||||
api_key,
|
||||
proxy_snapshot,
|
||||
start,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (access_token, updated_credentials, _frontend_updated_credentials) =
|
||||
match admin_provider_ops_sub2api_exchange_token(
|
||||
state,
|
||||
@@ -191,6 +210,117 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
|
||||
)
|
||||
}
|
||||
|
||||
async fn admin_provider_ops_sub2api_api_key_balance_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
base_url: &str,
|
||||
action_config: &serde_json::Map<String, serde_json::Value>,
|
||||
api_key: &str,
|
||||
proxy_snapshot: Option<&ProxySnapshot>,
|
||||
start: std::time::Instant,
|
||||
) -> serde_json::Value {
|
||||
let usage_endpoint = action_config
|
||||
.get("api_key_usage_endpoint")
|
||||
.or_else(|| action_config.get("usage_endpoint"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("/v1/usage");
|
||||
let usage_url = admin_provider_ops_sub2api_request_url(base_url, usage_endpoint);
|
||||
let auth_value = match reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return admin_provider_ops_action_error(
|
||||
"parse_error",
|
||||
"query_balance",
|
||||
"API Key 格式无效",
|
||||
Some(start.elapsed().as_millis() as u64),
|
||||
);
|
||||
}
|
||||
};
|
||||
let auth_headers = reqwest::header::HeaderMap::from_iter([
|
||||
(reqwest::header::AUTHORIZATION, auth_value),
|
||||
(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
),
|
||||
]);
|
||||
let request_id = format!("provider-ops-action:sub2api:usage:{provider_id}");
|
||||
let result = admin_provider_ops_execute_json_request(
|
||||
state,
|
||||
&request_id,
|
||||
reqwest::Method::GET,
|
||||
&usage_url,
|
||||
&auth_headers,
|
||||
None,
|
||||
proxy_snapshot,
|
||||
)
|
||||
.await;
|
||||
let response_time_ms = Some(start.elapsed().as_millis() as u64);
|
||||
let (status, response_json) = match result {
|
||||
Ok(result) => result,
|
||||
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
|
||||
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
|
||||
return admin_provider_ops_action_error(
|
||||
"network_error",
|
||||
"query_balance",
|
||||
network_error_message(&message),
|
||||
response_time_ms,
|
||||
);
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
status,
|
||||
http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN
|
||||
) {
|
||||
return admin_provider_ops_action_error(
|
||||
"auth_failed",
|
||||
"query_balance",
|
||||
"认证失败,请检查 API Key",
|
||||
response_time_ms,
|
||||
);
|
||||
}
|
||||
if status != http::StatusCode::OK {
|
||||
return admin_provider_ops_action_error(
|
||||
"unknown_error",
|
||||
"query_balance",
|
||||
format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Unknown")
|
||||
),
|
||||
response_time_ms,
|
||||
);
|
||||
}
|
||||
|
||||
let data = match parse_sub2api_api_key_usage_payload(action_config, &response_json) {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
return admin_provider_ops_action_error(
|
||||
if message.contains("无效") {
|
||||
"auth_failed"
|
||||
} else if message == "响应格式无效" {
|
||||
"parse_error"
|
||||
} else {
|
||||
"unknown_error"
|
||||
},
|
||||
"query_balance",
|
||||
message,
|
||||
response_time_ms,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
admin_provider_ops_action_response(
|
||||
"success",
|
||||
"query_balance",
|
||||
data,
|
||||
None,
|
||||
response_time_ms,
|
||||
86400,
|
||||
)
|
||||
}
|
||||
|
||||
fn network_error_message(error: &str) -> String {
|
||||
let normalized = error.trim();
|
||||
let lower = normalized.to_ascii_lowercase();
|
||||
|
||||
@@ -250,6 +250,9 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
payload: AdminProviderOpsSaveConfigRequest,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let architecture_id =
|
||||
admin_provider_ops_pure::normalize_architecture_id(payload.architecture_id.as_str())
|
||||
.to_string();
|
||||
let auth_type = payload.connector.auth_type.trim().to_string();
|
||||
if auth_type.is_empty() || !admin_provider_ops_is_supported_auth_type(auth_type.as_str()) {
|
||||
return Err("connector.auth_type 必须是合法的认证类型".to_string());
|
||||
@@ -257,7 +260,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
|
||||
|
||||
let merged_credentials = admin_provider_ops_merge_credentials(
|
||||
state,
|
||||
payload.architecture_id.as_str(),
|
||||
architecture_id.as_str(),
|
||||
provider,
|
||||
payload.connector.credentials,
|
||||
);
|
||||
@@ -278,7 +281,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>();
|
||||
|
||||
Ok(json!({
|
||||
"architecture_id": payload.architecture_id,
|
||||
"architecture_id": architecture_id,
|
||||
"base_url": payload.base_url,
|
||||
"connector": {
|
||||
"auth_type": auth_type,
|
||||
@@ -328,14 +331,16 @@ pub(super) fn build_admin_provider_ops_config_payload(
|
||||
});
|
||||
};
|
||||
let connector = admin_provider_ops_connector_object(provider_ops_config);
|
||||
let architecture_id = provider_ops_config
|
||||
.get("architecture_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(admin_provider_ops_pure::normalize_architecture_id)
|
||||
.unwrap_or("generic_api");
|
||||
|
||||
json!({
|
||||
"provider_id": provider_id,
|
||||
"is_configured": true,
|
||||
"architecture_id": provider_ops_config
|
||||
.get("architecture_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("generic_api"),
|
||||
"architecture_id": architecture_id,
|
||||
"base_url": resolve_admin_provider_ops_base_url(
|
||||
provider,
|
||||
endpoints,
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::handlers::admin::provider::ops::providers::config::persist_admin_prov
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use aether_admin::provider::ops::{
|
||||
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_verify_failure,
|
||||
parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
|
||||
admin_provider_ops_verify_success, admin_provider_ops_verify_user_payload,
|
||||
parse_sub2api_api_key_usage_payload, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
|
||||
};
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
@@ -21,6 +22,21 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
|
||||
credentials: &Map<String, Value>,
|
||||
proxy_snapshot: Option<&ProxySnapshot>,
|
||||
) -> Value {
|
||||
if let Some(api_key) = credentials
|
||||
.get("api_key")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return admin_provider_ops_local_sub2api_api_key_verify_response(
|
||||
state,
|
||||
base_url,
|
||||
api_key,
|
||||
proxy_snapshot,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (access_token, updated_credentials, frontend_updated_credentials) =
|
||||
match admin_provider_ops_sub2api_exchange_token(
|
||||
state,
|
||||
@@ -93,6 +109,78 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
|
||||
)
|
||||
}
|
||||
|
||||
async fn admin_provider_ops_local_sub2api_api_key_verify_response(
|
||||
state: &AdminAppState<'_>,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
proxy_snapshot: Option<&ProxySnapshot>,
|
||||
) -> Value {
|
||||
let usage_url = admin_provider_ops_sub2api_request_url(base_url, "/v1/usage");
|
||||
let auth_value = match reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return admin_provider_ops_verify_failure("API Key 格式无效"),
|
||||
};
|
||||
let auth_headers = reqwest::header::HeaderMap::from_iter([
|
||||
(reqwest::header::AUTHORIZATION, auth_value),
|
||||
(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
),
|
||||
]);
|
||||
let auth_headers =
|
||||
admin_provider_ops_headers_with_transport_controls(&auth_headers, None, true);
|
||||
let (status, response_json) = match admin_provider_ops_execute_json_request(
|
||||
state,
|
||||
"provider-ops-verify:sub2api:api-key",
|
||||
reqwest::Method::GET,
|
||||
&usage_url,
|
||||
&auth_headers,
|
||||
None,
|
||||
proxy_snapshot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
|
||||
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
|
||||
return admin_provider_ops_verify_failure(
|
||||
admin_provider_ops_verify_execution_error_message(&message),
|
||||
);
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
status,
|
||||
http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN
|
||||
) {
|
||||
return admin_provider_ops_verify_failure("认证失败:API Key 无效或已过期");
|
||||
}
|
||||
if status != http::StatusCode::OK {
|
||||
return admin_provider_ops_verify_failure(format!("验证失败:HTTP {}", status.as_u16()));
|
||||
}
|
||||
|
||||
let payload = match parse_sub2api_api_key_usage_payload(&Map::new(), &response_json) {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => return admin_provider_ops_verify_failure(message),
|
||||
};
|
||||
let quota = payload.get("total_available").and_then(Value::as_f64);
|
||||
let extra = payload
|
||||
.get("extra")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
admin_provider_ops_verify_success(
|
||||
admin_provider_ops_verify_user_payload(
|
||||
Some("Sub2API API Key".to_string()),
|
||||
Some("Sub2API API Key".to_string()),
|
||||
None,
|
||||
quota,
|
||||
Some(extra),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
// 对齐 Python httpx.AsyncClient(base_url=...) 的行为:
|
||||
// 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。
|
||||
pub(in super::super) fn admin_provider_ops_sub2api_request_url(
|
||||
|
||||
@@ -12,6 +12,13 @@ pub(crate) fn admin_provider_id_for_refresh_quota(request_path: &str) -> Option<
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_id_for_key_balance(request_path: &str) -> Option<String> {
|
||||
request_path
|
||||
.strip_prefix("/api/admin/endpoints/providers/")?
|
||||
.strip_suffix("/key-balance")
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn admin_reveal_key_id(request_path: &str) -> Option<String> {
|
||||
request_path
|
||||
.strip_prefix("/api/admin/endpoints/keys/")?
|
||||
|
||||
@@ -15,9 +15,9 @@ pub(crate) use self::crud::{
|
||||
is_admin_providers_root,
|
||||
};
|
||||
pub(crate) use self::endpoint_keys::{
|
||||
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
|
||||
admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id,
|
||||
admin_update_key_id,
|
||||
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_key_balance,
|
||||
admin_provider_id_for_keys, admin_provider_id_for_refresh_quota,
|
||||
admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_update_key_id,
|
||||
};
|
||||
pub(crate) use self::oauth::{
|
||||
admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path,
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) async fn build_admin_provider_summary_payload(
|
||||
active_global_model_ids_result,
|
||||
) = tokio::join!(
|
||||
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
|
||||
state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
|
||||
state.list_provider_catalog_keys_by_provider_ids(&provider_ids),
|
||||
state.read_provider_quota_snapshot(provider_id),
|
||||
state.list_provider_model_stats(&provider_ids),
|
||||
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
||||
@@ -197,7 +197,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
||||
} else {
|
||||
let (endpoints_result, keys_result, model_stats_result, active_global_model_refs_result) = tokio::join!(
|
||||
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
|
||||
state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
|
||||
state.list_provider_catalog_keys_by_provider_ids(&provider_ids),
|
||||
state.list_provider_model_stats(&provider_ids),
|
||||
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ use aether_data_contracts::repository::candidates::{
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn json_truthy(value: &serde_json::Value) -> bool {
|
||||
@@ -27,6 +27,80 @@ fn endpoint_timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_js
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn finite_json_number(value: Option<&Value>) -> Option<f64> {
|
||||
match value {
|
||||
Some(Value::Number(number)) => number.as_f64().filter(|value| value.is_finite()),
|
||||
Some(Value::String(value)) => value
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|value| value.is_finite()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_json_u64(value: Option<&Value>) -> Option<u64> {
|
||||
finite_json_number(value).and_then(|value| {
|
||||
if value >= 0.0 {
|
||||
Some(value as u64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn latest_key_balance_summary(keys: &[StoredProviderCatalogKey]) -> Value {
|
||||
let mut selected: Option<(u64, &StoredProviderCatalogKey, &Map<String, Value>)> = None;
|
||||
|
||||
for key in keys {
|
||||
let Some(balance) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("balance_query"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(updated_at) = finite_json_u64(balance.get("updated_at")) else {
|
||||
continue;
|
||||
};
|
||||
let has_balance_value = ["total_available", "total_used", "total_granted"]
|
||||
.into_iter()
|
||||
.any(|field| finite_json_number(balance.get(field)).is_some());
|
||||
if !has_balance_value {
|
||||
continue;
|
||||
}
|
||||
|
||||
if selected
|
||||
.as_ref()
|
||||
.is_none_or(|(selected_updated_at, _, _)| updated_at > *selected_updated_at)
|
||||
{
|
||||
selected = Some((updated_at, key, balance));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((updated_at, key, balance)) = selected else {
|
||||
return Value::Null;
|
||||
};
|
||||
|
||||
json!({
|
||||
"key_id": key.id.clone(),
|
||||
"key_name": key.name.clone(),
|
||||
"updated_at": updated_at,
|
||||
"architecture_id": balance.get("architecture_id").cloned().unwrap_or(Value::Null),
|
||||
"status": balance.get("status").cloned().unwrap_or_else(|| json!("success")),
|
||||
"executed_at": balance.get("executed_at").cloned().unwrap_or(Value::Null),
|
||||
"response_time_ms": balance.get("response_time_ms").cloned().unwrap_or(Value::Null),
|
||||
"total_available": balance.get("total_available").cloned().unwrap_or(Value::Null),
|
||||
"total_used": balance.get("total_used").cloned().unwrap_or(Value::Null),
|
||||
"total_granted": balance.get("total_granted").cloned().unwrap_or(Value::Null),
|
||||
"currency": balance.get("currency").cloned().unwrap_or_else(|| json!("USD")),
|
||||
"plan_name": balance.get("plan_name").cloned().unwrap_or(Value::Null),
|
||||
"query_config": balance.get("query_config").cloned().unwrap_or(Value::Null),
|
||||
"extra": balance.get("extra").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_provider_summary_value(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
@@ -158,6 +232,7 @@ pub(crate) fn build_admin_provider_summary_value(
|
||||
.and_then(|quota| quota.quota_expires_at_unix_secs)
|
||||
.or(provider.quota_expires_at_unix_secs)
|
||||
.and_then(unix_secs_to_rfc3339);
|
||||
let key_balance_summary = latest_key_balance_summary(keys);
|
||||
|
||||
json!({
|
||||
"id": provider.id.clone(),
|
||||
@@ -196,6 +271,7 @@ pub(crate) fn build_admin_provider_summary_value(
|
||||
"endpoint_health_details": endpoint_health_details,
|
||||
"ops_configured": ops_configured,
|
||||
"ops_architecture_id": ops_architecture_id,
|
||||
"key_balance_summary": key_balance_summary,
|
||||
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
|
||||
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
|
||||
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
|
||||
|
||||
@@ -177,6 +177,21 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.update_provider_catalog_key(key).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
upstream_metadata: Option<&serde_json::Value>,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
self.app
|
||||
.update_provider_catalog_key_upstream_metadata(
|
||||
key_id,
|
||||
upstream_metadata,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_provider_catalog_key(
|
||||
&self,
|
||||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||||
|
||||
@@ -21,6 +21,7 @@ const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
|
||||
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
|
||||
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
|
||||
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
|
||||
const BALANCE_QUERY_SECRET_CIPHERTEXT_KEY: &str = "secret_ciphertext";
|
||||
|
||||
pub(crate) fn provider_catalog_key_supports_format(
|
||||
key: &StoredProviderCatalogKey,
|
||||
@@ -173,6 +174,31 @@ pub(crate) fn parse_catalog_auth_config_json(
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn sanitized_admin_upstream_metadata(upstream_metadata: Option<&Value>) -> Value {
|
||||
let Some(mut metadata) = upstream_metadata.cloned() else {
|
||||
return Value::Null;
|
||||
};
|
||||
let Some(balance_query) = metadata
|
||||
.as_object_mut()
|
||||
.and_then(|metadata| metadata.get_mut("balance_query"))
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
return metadata;
|
||||
};
|
||||
let has_saved_secret = balance_query
|
||||
.remove(BALANCE_QUERY_SECRET_CIPHERTEXT_KEY)
|
||||
.is_some();
|
||||
if has_saved_secret {
|
||||
let query_config = balance_query
|
||||
.entry("query_config".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if let Some(query_config) = query_config.as_object_mut() {
|
||||
query_config.insert("has_saved_secret".to_string(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
|
||||
json!({
|
||||
"oauth": {
|
||||
@@ -1937,7 +1963,7 @@ pub(crate) fn build_admin_provider_key_response(
|
||||
);
|
||||
payload.insert(
|
||||
"upstream_metadata".to_string(),
|
||||
json!(key.upstream_metadata),
|
||||
sanitized_admin_upstream_metadata(key.upstream_metadata.as_ref()),
|
||||
);
|
||||
payload.insert("proxy".to_string(), json!(key.proxy));
|
||||
payload.insert("fingerprint".to_string(), json!(key.fingerprint));
|
||||
|
||||
@@ -221,6 +221,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("endpoints_manage"), http::Method::POST, Some("create_endpoint"))
|
||||
| (Some("endpoints_manage"), http::Method::POST, Some("batch_delete_keys"))
|
||||
| (Some("endpoints_manage"), http::Method::POST, Some("refresh_quota"))
|
||||
| (Some("endpoints_manage"), http::Method::POST, Some("query_key_balance"))
|
||||
| (Some("endpoints_manage"), http::Method::PUT, Some("update_key"))
|
||||
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
|
||||
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))
|
||||
|
||||
@@ -1490,7 +1490,24 @@ async fn gateway_verifies_admin_provider_ops_locally_for_new_api_with_trusted_ad
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("session=foo")
|
||||
);
|
||||
assert!(headers.contains_key("sec-ch-ua"));
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("cc-switch/1.0")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder
|
||||
.write_all(
|
||||
|
||||
13
apps/aether-proxy/install.ps1
Normal file
13
apps/aether-proxy/install.ps1
Normal file
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($env:AETHER_PROXY_AETHER_URL -and -not $env:AETHER_TUNNEL_AETHER_URL) {
|
||||
$env:AETHER_TUNNEL_AETHER_URL = $env:AETHER_PROXY_AETHER_URL
|
||||
}
|
||||
if ($env:AETHER_PROXY_MANAGEMENT_TOKEN -and -not $env:AETHER_TUNNEL_MANAGEMENT_TOKEN) {
|
||||
$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = $env:AETHER_PROXY_MANAGEMENT_TOKEN
|
||||
}
|
||||
if ($env:AETHER_PROXY_NODE_NAME -and -not $env:AETHER_TUNNEL_NODE_NAME) {
|
||||
$env:AETHER_TUNNEL_NODE_NAME = $env:AETHER_PROXY_NODE_NAME
|
||||
}
|
||||
|
||||
irm 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1' | iex
|
||||
21
apps/aether-proxy/install.sh
Normal file
21
apps/aether-proxy/install.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -n "${AETHER_PROXY_AETHER_URL:-}" ] && [ -z "${AETHER_TUNNEL_AETHER_URL:-}" ]; then
|
||||
export AETHER_TUNNEL_AETHER_URL="${AETHER_PROXY_AETHER_URL}"
|
||||
fi
|
||||
if [ -n "${AETHER_PROXY_MANAGEMENT_TOKEN:-}" ] && [ -z "${AETHER_TUNNEL_MANAGEMENT_TOKEN:-}" ]; then
|
||||
export AETHER_TUNNEL_MANAGEMENT_TOKEN="${AETHER_PROXY_MANAGEMENT_TOKEN}"
|
||||
fi
|
||||
if [ -n "${AETHER_PROXY_NODE_NAME:-}" ] && [ -z "${AETHER_TUNNEL_NODE_NAME:-}" ]; then
|
||||
export AETHER_TUNNEL_NODE_NAME="${AETHER_PROXY_NODE_NAME}"
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh' | sh
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh' | sh
|
||||
else
|
||||
printf '%s\n' "[Aether Tunnel] 需要 curl 或 wget 下载安装脚本" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,6 +10,10 @@ AETHER_TUNNEL_NODE_NAME=jp-proxy-01
|
||||
# Maximum request body buffered for 307/308 replay (supports K/M/G, 0 disables body replay buffering)
|
||||
AETHER_TUNNEL_REDIRECT_REPLAY_BUDGET_BYTES=5M
|
||||
|
||||
# Optional tunnel TCP address-family restriction (set at most one to true)
|
||||
AETHER_TUNNEL_IPV4_ONLY=false
|
||||
AETHER_TUNNEL_IPV6_ONLY=false
|
||||
|
||||
# Logging
|
||||
AETHER_TUNNEL_LOG_LEVEL=info
|
||||
AETHER_TUNNEL_LOG_DESTINATION=stdout
|
||||
|
||||
@@ -126,6 +126,8 @@ sudo aether-tunnel uninstall
|
||||
| `--tunnel-max-streams` | `AETHER_TUNNEL_MAX_STREAMS` | 自动(硬件估算) | 单连接最大并发 stream 数 |
|
||||
| `--tunnel-ping-interval-ms` | `AETHER_TUNNEL_PING_INTERVAL_MS` | `10000` | WebSocket ping 周期(毫秒) |
|
||||
| `--tunnel-connect-timeout-ms` | `AETHER_TUNNEL_CONNECT_TIMEOUT_MS` | `3000` | tunnel 建连超时(毫秒) |
|
||||
| `--tunnel-ipv4-only` | `AETHER_TUNNEL_IPV4_ONLY` | `false` | 仅使用 IPv4 地址建立直连 WebSocket tunnel;配置 `aether_outbound_proxy_url` 时仅限制代理端点解析 |
|
||||
| `--tunnel-ipv6-only` | `AETHER_TUNNEL_IPV6_ONLY` | `false` | 仅使用 IPv6 地址建立直连 WebSocket tunnel;配置 `aether_outbound_proxy_url` 时仅限制代理端点解析 |
|
||||
| `--tunnel-stale-timeout-ms` | `AETHER_TUNNEL_STALE_TIMEOUT_MS` | `30000` | 无入站数据断连阈值(毫秒) |
|
||||
| `--tunnel-scale-check-interval-ms` | `AETHER_TUNNEL_SCALE_CHECK_INTERVAL_MS` | `1000` | autoscale 采样周期(毫秒) |
|
||||
| `--tunnel-scale-up-threshold-percent` | `AETHER_TUNNEL_SCALE_UP_THRESHOLD_PERCENT` | `50` | 单 tunnel 占用率超过该值时扩容 |
|
||||
@@ -138,6 +140,8 @@ sudo aether-tunnel uninstall
|
||||
|
||||
省略 `tunnel_connections` 时,tunnel 会按设备能力自动计算一个基线值和偏单机上限的扩容上限:默认至少保留 2 条常驻 tunnel,并会更早触发扩容;如果显式设置了 `tunnel_connections` 但没有设置 `tunnel_connections_max`,则保持固定连接池,不自动扩缩。
|
||||
|
||||
`tunnel_ipv4_only` / `tunnel_ipv6_only` 只能二选一。它们只改变 WebSocket tunnel 回连的 TCP 地址选择:直连 Aether 时过滤 Aether 域名的 DNS 结果;配置 `aether_outbound_proxy_url` 时过滤代理服务器端点的 DNS 结果,Host/SNI 仍使用原始 WebSocket URL。该选项不会影响 provider 上游请求;如需限制 provider 上游流量,请在 `upstream_proxy_url` 或系统网络层处理。对于 Cloudflare 等边缘 IP 会变化的域名,优先使用该选项而不是固定 `/etc/hosts`。
|
||||
|
||||
#### 上游 HTTP 请求
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -1356,6 +1356,8 @@ mod tests {
|
||||
tunnel_ping_interval_ms: 1_000,
|
||||
tunnel_max_streams: Some(8),
|
||||
tunnel_connect_timeout_ms: 2_000,
|
||||
tunnel_ipv4_only: false,
|
||||
tunnel_ipv6_only: false,
|
||||
tunnel_tcp_keepalive_secs: 30,
|
||||
tunnel_tcp_nodelay: true,
|
||||
tunnel_stale_timeout_ms: 5_000,
|
||||
|
||||
@@ -571,6 +571,30 @@ pub struct Config {
|
||||
)]
|
||||
pub tunnel_connect_timeout_ms: u64,
|
||||
|
||||
/// Force direct WebSocket tunnel TCP connects, or Aether outbound proxy endpoint connects, to IPv4 addresses only.
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_IPV4_ONLY",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::Set,
|
||||
default_missing_value = "true",
|
||||
num_args = 0..=1,
|
||||
require_equals = true
|
||||
)]
|
||||
pub tunnel_ipv4_only: bool,
|
||||
|
||||
/// Force direct WebSocket tunnel TCP connects, or Aether outbound proxy endpoint connects, to IPv6 addresses only.
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_IPV6_ONLY",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::Set,
|
||||
default_missing_value = "true",
|
||||
num_args = 0..=1,
|
||||
require_equals = true
|
||||
)]
|
||||
pub tunnel_ipv6_only: bool,
|
||||
|
||||
/// WebSocket tunnel TCP keepalive in seconds (0 disables)
|
||||
#[arg(long, env = "AETHER_TUNNEL_TCP_KEEPALIVE", default_value_t = 30)]
|
||||
pub tunnel_tcp_keepalive_secs: u64,
|
||||
@@ -655,6 +679,9 @@ impl Config {
|
||||
if tunnel_connect_timeout.is_zero() {
|
||||
anyhow::bail!("effective tunnel connect timeout must be > 0");
|
||||
}
|
||||
if self.tunnel_ipv4_only && self.tunnel_ipv6_only {
|
||||
anyhow::bail!("tunnel_ipv4_only and tunnel_ipv6_only cannot both be enabled");
|
||||
}
|
||||
let tunnel_ping_interval = self.tunnel_ping_interval()?;
|
||||
if tunnel_ping_interval.is_zero() {
|
||||
anyhow::bail!("effective tunnel ping interval must be > 0");
|
||||
@@ -763,6 +790,16 @@ impl Config {
|
||||
Ok(Duration::from_millis(self.tunnel_connect_timeout_ms))
|
||||
}
|
||||
|
||||
pub fn tunnel_ip_family(&self) -> crate::egress_proxy::IpFamily {
|
||||
if self.tunnel_ipv4_only {
|
||||
crate::egress_proxy::IpFamily::Ipv4Only
|
||||
} else if self.tunnel_ipv6_only {
|
||||
crate::egress_proxy::IpFamily::Ipv6Only
|
||||
} else {
|
||||
crate::egress_proxy::IpFamily::Any
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tunnel_stale_timeout(&self) -> anyhow::Result<Duration> {
|
||||
Ok(Duration::from_millis(self.tunnel_stale_timeout_ms))
|
||||
}
|
||||
@@ -959,6 +996,10 @@ pub struct ConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_connect_timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_ipv4_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_ipv6_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_tcp_keepalive_secs: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tunnel_tcp_nodelay: Option<bool>,
|
||||
@@ -1147,6 +1188,8 @@ impl ConfigFile {
|
||||
TUNNEL_CONNECT_TIMEOUT_MS_ENV,
|
||||
self.tunnel_connect_timeout_ms
|
||||
);
|
||||
set!("AETHER_TUNNEL_IPV4_ONLY", self.tunnel_ipv4_only);
|
||||
set!("AETHER_TUNNEL_IPV6_ONLY", self.tunnel_ipv6_only);
|
||||
set!(
|
||||
"AETHER_TUNNEL_TCP_KEEPALIVE",
|
||||
self.tunnel_tcp_keepalive_secs
|
||||
@@ -1338,6 +1381,20 @@ mod tests {
|
||||
assert_eq!(cfg.allow_private_targets, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_deserializes_tunnel_ip_family_flags() {
|
||||
let cfg: ConfigFile = toml::from_str(
|
||||
r#"
|
||||
tunnel_ipv4_only = true
|
||||
tunnel_ipv6_only = false
|
||||
"#,
|
||||
)
|
||||
.expect("tunnel IP-family TOML");
|
||||
|
||||
assert_eq!(cfg.tunnel_ipv4_only, Some(true));
|
||||
assert_eq!(cfg.tunnel_ipv6_only, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_deserializes_upstream_proxy_url() {
|
||||
let cfg: ConfigFile = toml::from_str("upstream_proxy_url = \"http://proxy.example:8080\"")
|
||||
@@ -1531,6 +1588,131 @@ node_name = "tunnel-test"
|
||||
assert!(config.allow_private_targets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_defaults_tunnel_ip_family_to_any() {
|
||||
let config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
]);
|
||||
|
||||
assert!(!config.tunnel_ipv4_only);
|
||||
assert!(!config.tunnel_ipv6_only);
|
||||
assert_eq!(
|
||||
config.tunnel_ip_family(),
|
||||
crate::egress_proxy::IpFamily::Any
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_tunnel_ipv4_only() {
|
||||
let config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
"--tunnel-ipv4-only",
|
||||
]);
|
||||
|
||||
assert!(config.tunnel_ipv4_only);
|
||||
assert_eq!(
|
||||
config.tunnel_ip_family(),
|
||||
crate::egress_proxy::IpFamily::Ipv4Only
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_tunnel_ipv6_only() {
|
||||
let config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
"--tunnel-ipv6-only",
|
||||
]);
|
||||
|
||||
assert!(config.tunnel_ipv6_only);
|
||||
assert_eq!(
|
||||
config.tunnel_ip_family(),
|
||||
crate::egress_proxy::IpFamily::Ipv6Only
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_conflicting_tunnel_ip_family_flags_before_validation() {
|
||||
let config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
"--tunnel-ipv4-only",
|
||||
"--tunnel-ipv6-only",
|
||||
]);
|
||||
|
||||
assert!(config.tunnel_ipv4_only);
|
||||
assert!(config.tunnel_ipv6_only);
|
||||
let error = config
|
||||
.validate()
|
||||
.expect_err("conflicting tunnel IP-family flags should fail validation");
|
||||
assert!(error.to_string().contains("tunnel_ipv4_only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_explicit_false_tunnel_ip_family_flags() {
|
||||
let config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
"--tunnel-ipv4-only=false",
|
||||
"--tunnel-ipv6-only=false",
|
||||
]);
|
||||
|
||||
assert!(!config.tunnel_ipv4_only);
|
||||
assert!(!config.tunnel_ipv6_only);
|
||||
config
|
||||
.validate()
|
||||
.expect("explicit false family flags should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_conflicting_toml_tunnel_ip_family_flags() {
|
||||
let config = Config {
|
||||
tunnel_ipv4_only: true,
|
||||
tunnel_ipv6_only: true,
|
||||
..Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
])
|
||||
};
|
||||
|
||||
let error = config
|
||||
.validate()
|
||||
.expect_err("conflicting TOML-injected tunnel family flags should fail validation");
|
||||
assert!(error.to_string().contains("tunnel_ipv4_only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunnel_fast_recovery_defaults_use_millisecond_values() {
|
||||
let config = Config::parse_from([
|
||||
|
||||
@@ -8,6 +8,31 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum IpFamily {
|
||||
Any,
|
||||
Ipv4Only,
|
||||
Ipv6Only,
|
||||
}
|
||||
|
||||
impl IpFamily {
|
||||
pub(crate) fn allows(self, addr: SocketAddr) -> bool {
|
||||
match self {
|
||||
Self::Any => true,
|
||||
Self::Ipv4Only => addr.is_ipv4(),
|
||||
Self::Ipv6Only => addr.is_ipv6(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn no_address_message(self, context: &str) -> String {
|
||||
match self {
|
||||
Self::Any => format!("{context} DNS returned no addresses"),
|
||||
Self::Ipv4Only => format!("{context} DNS returned no IPv4 addresses"),
|
||||
Self::Ipv6Only => format!("{context} DNS returned no IPv6 addresses"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UpstreamProxyScheme {
|
||||
Http,
|
||||
@@ -126,6 +151,7 @@ pub(crate) struct ProxyConnectOptions {
|
||||
pub connect_timeout: Duration,
|
||||
pub tcp_nodelay: bool,
|
||||
pub tcp_keepalive: Option<Duration>,
|
||||
pub ip_family: IpFamily,
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_target_via_proxy(
|
||||
@@ -139,6 +165,7 @@ pub(crate) async fn connect_target_via_proxy(
|
||||
options.connect_timeout,
|
||||
options.tcp_nodelay,
|
||||
options.tcp_keepalive,
|
||||
options.ip_family,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -159,6 +186,7 @@ pub(crate) async fn connect_proxy_tcp(
|
||||
connect_timeout: Duration,
|
||||
tcp_nodelay: bool,
|
||||
tcp_keepalive: Option<Duration>,
|
||||
ip_family: IpFamily,
|
||||
) -> io::Result<TcpStream> {
|
||||
let resolved = tokio::time::timeout(
|
||||
connect_timeout,
|
||||
@@ -169,7 +197,7 @@ pub(crate) async fn connect_proxy_tcp(
|
||||
.map_err(|err| io::Error::other(format!("proxy DNS failed: {err}")))?;
|
||||
|
||||
let mut last_error = None;
|
||||
for addr in resolved {
|
||||
for addr in resolved.filter(|addr| ip_family.allows(*addr)) {
|
||||
match tokio::time::timeout(connect_timeout, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => {
|
||||
configure_tcp_stream(&stream, tcp_nodelay, tcp_keepalive)?;
|
||||
@@ -185,7 +213,7 @@ pub(crate) async fn connect_proxy_tcp(
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| io::Error::other("proxy DNS returned no addresses")))
|
||||
Err(last_error.unwrap_or_else(|| io::Error::other(ip_family.no_address_message("proxy"))))
|
||||
}
|
||||
|
||||
fn configure_tcp_stream(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! WebSocket tunnel client: connect, authenticate, and run the tunnel.
|
||||
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -10,7 +12,9 @@ use tokio_tungstenite::tungstenite::http;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::egress_proxy::{connect_target_via_proxy, ProxyConnectOptions, UpstreamProxyConfig};
|
||||
use crate::egress_proxy::{
|
||||
connect_target_via_proxy, IpFamily, ProxyConnectOptions, UpstreamProxyConfig,
|
||||
};
|
||||
use crate::state::{AppState, ServerContext};
|
||||
use aether_contracts::tunnel::{CURRENT_TUNNEL_PROTOCOL_VERSION, TUNNEL_PROTOCOL_VERSION_HEADER};
|
||||
|
||||
@@ -325,6 +329,7 @@ async fn connect_tunnel_tcp(
|
||||
tcp_nodelay: state.config.tunnel_tcp_nodelay,
|
||||
tcp_keepalive: (state.config.tunnel_tcp_keepalive_secs > 0)
|
||||
.then(|| Duration::from_secs(state.config.tunnel_tcp_keepalive_secs)),
|
||||
ip_family: state.config.tunnel_ip_family(),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -338,15 +343,54 @@ async fn connect_tunnel_tcp(
|
||||
.map_err(anyhow::Error::from);
|
||||
}
|
||||
|
||||
tokio::time::timeout(connect_timeout, TcpStream::connect((host, port)))
|
||||
let ip_family = state.config.tunnel_ip_family();
|
||||
tokio::time::timeout(
|
||||
connect_timeout,
|
||||
connect_direct_tunnel_tcp(host, port, ip_family),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"tunnel TCP connect timeout ({}ms)",
|
||||
connect_timeout.as_millis()
|
||||
)
|
||||
})?
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn connect_direct_tunnel_tcp(
|
||||
host: &str,
|
||||
port: u16,
|
||||
ip_family: IpFamily,
|
||||
) -> io::Result<TcpStream> {
|
||||
let resolved = tokio::net::lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"tunnel TCP connect timeout ({}ms)",
|
||||
connect_timeout.as_millis()
|
||||
)
|
||||
})?
|
||||
.map_err(anyhow::Error::from)
|
||||
.map_err(|err| io::Error::other(format!("tunnel DNS failed: {err}")))?;
|
||||
let addrs = filter_socket_addrs(resolved, ip_family);
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(io::Error::other(ip_family.no_address_message("tunnel")));
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for addr in addrs {
|
||||
match TcpStream::connect(addr).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| io::Error::other("tunnel DNS returned no addresses")))
|
||||
}
|
||||
|
||||
fn filter_socket_addrs(
|
||||
addrs: impl IntoIterator<Item = SocketAddr>,
|
||||
ip_family: IpFamily,
|
||||
) -> Vec<SocketAddr> {
|
||||
addrs
|
||||
.into_iter()
|
||||
.filter(|addr| ip_family.allows(*addr))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Configure TCP keepalive and NODELAY on an established socket.
|
||||
@@ -392,3 +436,40 @@ fn build_tunnel_url(server: &ServerContext) -> String {
|
||||
};
|
||||
format!("{}/api/internal/proxy-tunnel", ws_base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mixed_addrs() -> Vec<SocketAddr> {
|
||||
vec![
|
||||
SocketAddr::from((Ipv6Addr::LOCALHOST, 443)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 443)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_socket_addrs_keeps_all_addresses_by_default() {
|
||||
let addrs = filter_socket_addrs(mixed_addrs(), IpFamily::Any);
|
||||
|
||||
assert_eq!(addrs.len(), 2);
|
||||
assert!(addrs[0].is_ipv6());
|
||||
assert!(addrs[1].is_ipv4());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_socket_addrs_keeps_only_ipv4_addresses() {
|
||||
let addrs = filter_socket_addrs(mixed_addrs(), IpFamily::Ipv4Only);
|
||||
|
||||
assert_eq!(addrs, vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 443))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_socket_addrs_keeps_only_ipv6_addresses() {
|
||||
let addrs = filter_socket_addrs(mixed_addrs(), IpFamily::Ipv6Only);
|
||||
|
||||
assert_eq!(addrs, vec![SocketAddr::from((Ipv6Addr::LOCALHOST, 443))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +541,8 @@ mod tests {
|
||||
tunnel_ping_interval_ms: 1_000,
|
||||
tunnel_max_streams: Some(8),
|
||||
tunnel_connect_timeout_ms: 2_000,
|
||||
tunnel_ipv4_only: false,
|
||||
tunnel_ipv6_only: false,
|
||||
tunnel_tcp_keepalive_secs: 30,
|
||||
tunnel_tcp_nodelay: true,
|
||||
tunnel_stale_timeout_ms: 5_000,
|
||||
|
||||
@@ -2556,6 +2556,8 @@ mod tests {
|
||||
tunnel_ping_interval_ms: 15_000,
|
||||
tunnel_max_streams: Some(8),
|
||||
tunnel_connect_timeout_ms: 15_000,
|
||||
tunnel_ipv4_only: false,
|
||||
tunnel_ipv6_only: false,
|
||||
tunnel_tcp_keepalive_secs: 30,
|
||||
tunnel_tcp_nodelay: true,
|
||||
tunnel_stale_timeout_ms: 45_000,
|
||||
|
||||
@@ -281,6 +281,7 @@ impl Service<Uri> for InstrumentedConnector {
|
||||
connect_timeout: self.connect_timeout,
|
||||
tcp_nodelay: self.tcp_nodelay,
|
||||
tcp_keepalive: self.tcp_keepalive,
|
||||
ip_family: crate::egress_proxy::IpFamily::Any,
|
||||
};
|
||||
let connect_start = std::time::Instant::now();
|
||||
return Box::pin(async move {
|
||||
@@ -347,6 +348,7 @@ async fn connect_via_proxy(
|
||||
options.connect_timeout,
|
||||
options.tcp_nodelay,
|
||||
options.tcp_keepalive,
|
||||
options.ip_family,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::architectures::normalize_architecture_id;
|
||||
use super::verify::admin_provider_ops_value_as_f64;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -13,7 +14,7 @@ pub fn parse_query_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
match architecture_id {
|
||||
match normalize_architecture_id(architecture_id) {
|
||||
"generic_api" | "new_api" | "anyrouter" | "done_hub" => {
|
||||
parse_new_api_balance_payload(action_config, response_json)
|
||||
}
|
||||
@@ -113,6 +114,73 @@ pub fn parse_sub2api_balance_payload(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_sub2api_api_key_usage_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let usage_data = sub2api_usage_response_object(response_json)?;
|
||||
let is_valid = bool_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
&["is_active", "data.is_active", "isValid", "data.isValid"],
|
||||
)
|
||||
.unwrap_or(true);
|
||||
if !is_valid {
|
||||
return Err(response_json
|
||||
.get("invalidMessage")
|
||||
.or_else(|| response_json.get("message"))
|
||||
.or_else(|| usage_data.get("invalidMessage"))
|
||||
.or_else(|| usage_data.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("API Key 已禁用或无效")
|
||||
.to_string());
|
||||
}
|
||||
|
||||
let remaining = balance_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
action_config,
|
||||
&["remaining_path", "available_path", "balance_path"],
|
||||
&[
|
||||
"remaining",
|
||||
"data.remaining",
|
||||
"quota.remaining",
|
||||
"data.quota.remaining",
|
||||
"balance",
|
||||
"data.balance",
|
||||
],
|
||||
)
|
||||
.ok_or_else(|| "响应格式无效".to_string())?;
|
||||
let currency = string_value_from_candidates(
|
||||
response_json,
|
||||
usage_data,
|
||||
&["unit", "data.unit", "quota.unit", "data.quota.unit"],
|
||||
)
|
||||
.or_else(|| {
|
||||
action_config
|
||||
.get("currency")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let mut extra = Map::new();
|
||||
extra.insert("is_active".to_string(), json!(is_valid));
|
||||
if let Some(quota) = usage_data.get("quota").filter(|value| value.is_object()) {
|
||||
extra.insert("quota".to_string(), quota.clone());
|
||||
}
|
||||
|
||||
Ok(build_balance_data(
|
||||
None,
|
||||
None,
|
||||
Some(remaining),
|
||||
¤cy,
|
||||
extra,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn attach_balance_checkin_outcome(
|
||||
action_payload: &mut Value,
|
||||
outcome: &ProviderOpsCheckinOutcome,
|
||||
@@ -171,39 +239,316 @@ fn parse_new_api_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let user_data = if response_json.get("success").and_then(Value::as_bool) == Some(true)
|
||||
&& response_json.get("data").is_some_and(Value::is_object)
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("业务状态码表示失败")
|
||||
.to_string());
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
let Some(user_data) = user_data.and_then(Value::as_object) else {
|
||||
return Err("响应格式无效".to_string());
|
||||
};
|
||||
let quota_divisor = quota_divisor(action_config);
|
||||
let total_available =
|
||||
admin_provider_ops_value_as_f64(user_data.get("quota")).map(|value| value / quota_divisor);
|
||||
let total_used = admin_provider_ops_value_as_f64(user_data.get("used_quota"))
|
||||
.map(|value| value / quota_divisor);
|
||||
let user_data = balance_response_object(response_json)?;
|
||||
let quota_divisor = balance_divisor(action_config);
|
||||
let total_available_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"balance_path",
|
||||
"available_path",
|
||||
"total_available_path",
|
||||
"quota_path",
|
||||
],
|
||||
&[
|
||||
"total_available",
|
||||
"data.total_available",
|
||||
"balance",
|
||||
"data.balance",
|
||||
"available",
|
||||
"data.available",
|
||||
"remaining",
|
||||
"data.remaining",
|
||||
"quota",
|
||||
"data.quota",
|
||||
"balance_infos.0.total_balance",
|
||||
"balance_infos[0].total_balance",
|
||||
"data.balance_infos.0.total_balance",
|
||||
"data.balance_infos[0].total_balance",
|
||||
"balance_total",
|
||||
"data.balance_total",
|
||||
"total_balance",
|
||||
"data.total_balance",
|
||||
],
|
||||
);
|
||||
let total_used_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"used_path",
|
||||
"used_quota_path",
|
||||
"spent_path",
|
||||
"usage_path",
|
||||
"total_used_path",
|
||||
],
|
||||
&[
|
||||
"used_quota",
|
||||
"data.used_quota",
|
||||
"used",
|
||||
"data.used",
|
||||
"total_used",
|
||||
"data.total_used",
|
||||
"spent",
|
||||
"data.spent",
|
||||
"usage",
|
||||
"data.usage",
|
||||
],
|
||||
);
|
||||
let total_granted_raw = balance_value_from_candidates(
|
||||
response_json,
|
||||
user_data,
|
||||
action_config,
|
||||
&[
|
||||
"granted_path",
|
||||
"total_granted_path",
|
||||
"limit_path",
|
||||
"total_quota_path",
|
||||
],
|
||||
&[
|
||||
"total_granted",
|
||||
"data.total_granted",
|
||||
"total_quota",
|
||||
"data.total_quota",
|
||||
"granted",
|
||||
"data.granted",
|
||||
"limit",
|
||||
"data.limit",
|
||||
"balance_total",
|
||||
"data.balance_total",
|
||||
"total_balance",
|
||||
"data.total_balance",
|
||||
],
|
||||
);
|
||||
let total_available_raw = total_available_raw.or(match (total_granted_raw, total_used_raw) {
|
||||
(Some(granted), Some(used)) => Some((granted - used).max(0.0)),
|
||||
_ => None,
|
||||
});
|
||||
let total_used_raw = total_used_raw.or(match (total_granted_raw, total_available_raw) {
|
||||
(Some(granted), Some(available)) => Some((granted - available).max(0.0)),
|
||||
_ => None,
|
||||
});
|
||||
let total_granted_raw = total_granted_raw.or(match (total_available_raw, total_used_raw) {
|
||||
(Some(available), Some(used)) => Some(available + used),
|
||||
_ => None,
|
||||
});
|
||||
let mut extra = Map::new();
|
||||
if let Some(plan_name) = new_api_plan_name(user_data) {
|
||||
extra.insert("plan_name".to_string(), json!(plan_name));
|
||||
}
|
||||
Ok(build_balance_data(
|
||||
None,
|
||||
total_used,
|
||||
total_available,
|
||||
total_granted_raw.map(|value| value / quota_divisor),
|
||||
total_used_raw.map(|value| value / quota_divisor),
|
||||
total_available_raw.map(|value| value / quota_divisor),
|
||||
action_config
|
||||
.get("currency")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("USD"),
|
||||
Map::new(),
|
||||
extra,
|
||||
))
|
||||
}
|
||||
|
||||
fn new_api_plan_name(user_data: &Value) -> Option<String> {
|
||||
for key in ["group", "plan_name", "planName", "plan", "package"] {
|
||||
let value = user_data.get(key).and_then(Value::as_str)?.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn balance_response_object(response_json: &Value) -> Result<&Value, String> {
|
||||
if let Some(success) = response_json.get("success").and_then(Value::as_bool) {
|
||||
if !success {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("业务状态码表示失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = response_json.get("code").and_then(Value::as_i64) {
|
||||
if code != 0 {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询余额失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
response_json
|
||||
.as_object()
|
||||
.map(|_| response_json)
|
||||
.ok_or_else(|| "响应格式无效".to_string())
|
||||
}
|
||||
|
||||
fn balance_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
action_config: &Map<String, Value>,
|
||||
config_keys: &[&str],
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<f64> {
|
||||
for key in config_keys {
|
||||
if let Some(path) = action_config.get(*key).and_then(Value::as_str) {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = balance_value_at_path(full_response, path)
|
||||
.or_else(|| balance_value_at_path(response_data, path))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = balance_value_at_path(full_response, path)
|
||||
.or_else(|| balance_value_at_path(response_data, path))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn balance_value_at_path(value: &Value, path: &str) -> Option<f64> {
|
||||
let mut current = value;
|
||||
for segment in path
|
||||
.split('.')
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
current = value_at_path_segment(current, segment)?;
|
||||
}
|
||||
admin_provider_ops_value_as_f64(Some(current))
|
||||
}
|
||||
|
||||
fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for segment in path
|
||||
.split('.')
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
current = value_at_path_segment(current, segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn value_at_path_segment<'a>(mut current: &'a Value, segment: &str) -> Option<&'a Value> {
|
||||
if !segment.contains('[') {
|
||||
return if current.is_array() {
|
||||
segment
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|index| current.get(index))
|
||||
} else {
|
||||
current.get(segment)
|
||||
};
|
||||
}
|
||||
|
||||
let mut rest = segment;
|
||||
if let Some(open) = rest.find('[') {
|
||||
let head = rest[..open].trim();
|
||||
if !head.is_empty() {
|
||||
current = current.get(head)?;
|
||||
}
|
||||
rest = &rest[open..];
|
||||
}
|
||||
|
||||
while let Some(stripped) = rest.strip_prefix('[') {
|
||||
let close = stripped.find(']')?;
|
||||
let index = stripped[..close].trim().parse::<usize>().ok()?;
|
||||
current = current.get(index)?;
|
||||
rest = stripped[close + 1..].trim();
|
||||
if rest.is_empty() {
|
||||
return Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
current.get(rest)
|
||||
}
|
||||
|
||||
fn bool_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<bool> {
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = value_at_path(full_response, path)
|
||||
.or_else(|| value_at_path(response_data, path))
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn string_value_from_candidates(
|
||||
full_response: &Value,
|
||||
response_data: &Value,
|
||||
candidate_paths: &[&str],
|
||||
) -> Option<String> {
|
||||
for path in candidate_paths {
|
||||
if let Some(value) = value_at_path(full_response, path)
|
||||
.or_else(|| value_at_path(response_data, path))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sub2api_usage_response_object(response_json: &Value) -> Result<&Value, String> {
|
||||
if let Some(success) = response_json.get("success").and_then(Value::as_bool) {
|
||||
if !success {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = response_json.get("code").and_then(Value::as_i64) {
|
||||
if code != 0 {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询失败")
|
||||
.to_string());
|
||||
}
|
||||
if let Some(data) = response_json.get("data").filter(|value| value.is_object()) {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
response_json
|
||||
.as_object()
|
||||
.map(|_| response_json)
|
||||
.ok_or_else(|| "响应格式无效".to_string())
|
||||
}
|
||||
|
||||
fn parse_cubence_balance_payload(
|
||||
action_config: &Map<String, Value>,
|
||||
response_json: &Value,
|
||||
@@ -414,6 +759,12 @@ fn quota_divisor(action_config: &Map<String, Value>) -> f64 {
|
||||
.unwrap_or(500000.0)
|
||||
}
|
||||
|
||||
fn balance_divisor(action_config: &Map<String, Value>) -> f64 {
|
||||
admin_provider_ops_value_as_f64(action_config.get("balance_divisor"))
|
||||
.filter(|value| *value > 0.0)
|
||||
.unwrap_or_else(|| quota_divisor(action_config))
|
||||
}
|
||||
|
||||
fn parse_rfc3339_unix_secs(value: Option<&Value>) -> Option<i64> {
|
||||
let raw = value?.as_str()?.trim();
|
||||
if raw.is_empty() {
|
||||
@@ -466,7 +817,8 @@ fn parse_subscription(value: &Value) -> Option<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload,
|
||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
||||
ProviderOpsCheckinOutcome,
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -490,6 +842,109 @@ mod tests {
|
||||
assert_eq!(payload["total_used"], json!(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_alias_parser_supports_balance_field_fallbacks() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"oneapi",
|
||||
&json!({ "quota_divisor": 1, "currency": "CNY" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"balance": 12.5,
|
||||
"used": 2.25
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["currency"], json!("CNY"));
|
||||
assert_eq!(payload["total_available"], json!(12.5));
|
||||
assert_eq!(payload["total_used"], json!(2.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_parser_supports_total_available_and_total_used_fields() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"new_api",
|
||||
&json!({ "quota_divisor": 1 })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"data": {
|
||||
"total_available": 9.75,
|
||||
"total_used": 1.25
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(9.75));
|
||||
assert_eq!(payload["total_used"], json!(1.25));
|
||||
assert_eq!(payload["total_granted"], json!(11.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_parser_matches_cc_switch_usage_script_shape() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"new_api",
|
||||
&json!({ "quota_divisor": 500000, "currency": "USD" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"group": "默认套餐",
|
||||
"quota": 2_500_000,
|
||||
"used_quota": 500_000
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(5.0));
|
||||
assert_eq!(payload["total_used"], json!(1.0));
|
||||
assert_eq!(payload["total_granted"], json!(6.0));
|
||||
assert_eq!(payload["currency"], json!("USD"));
|
||||
assert_eq!(payload["extra"]["plan_name"], json!("默认套餐"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_api_parser_supports_deepseek_balance_shape() {
|
||||
let payload = parse_query_balance_payload(
|
||||
"generic_api",
|
||||
&json!({
|
||||
"quota_divisor": 1,
|
||||
"currency": "CNY",
|
||||
"balance_path": "balance_infos[0].total_balance"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"is_available": true,
|
||||
"balance_infos": [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "128.50",
|
||||
"granted_balance": "8.50",
|
||||
"topped_up_balance": "120.00"
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["currency"], json!("CNY"));
|
||||
assert_eq!(payload["total_available"], json!(128.5));
|
||||
assert_eq!(payload["total_used"], json!(null));
|
||||
assert_eq!(payload["total_granted"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_hub_single_request_parser_reads_wrapped_quota() {
|
||||
let payload = parse_query_balance_payload(
|
||||
@@ -540,6 +995,28 @@ mod tests {
|
||||
assert_eq!(payload["extra"]["active_subscriptions"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_api_key_usage_parser_matches_cc_switch_shape() {
|
||||
let payload = parse_sub2api_api_key_usage_payload(
|
||||
&json!({ "currency": "USD" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("config"),
|
||||
&json!({
|
||||
"is_active": true,
|
||||
"quota": {
|
||||
"remaining": "12.5",
|
||||
"unit": "USD"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("payload should parse");
|
||||
|
||||
assert_eq!(payload["total_available"], json!(12.5));
|
||||
assert_eq!(payload["currency"], json!("USD"));
|
||||
assert_eq!(payload["extra"]["is_active"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubence_parser_reads_wrapped_dashboard_overview() {
|
||||
let payload = parse_query_balance_payload(
|
||||
|
||||
@@ -119,12 +119,19 @@ pub fn get_architecture(architecture_id: &str) -> Option<ProviderOpsArchitecture
|
||||
}
|
||||
|
||||
pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
|
||||
match architecture_id.trim() {
|
||||
let compact = architecture_id
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match compact.as_str() {
|
||||
"" => "generic_api",
|
||||
"generic_api" => "generic_api",
|
||||
"new_api" => "new_api",
|
||||
"genericapi" => "generic_api",
|
||||
"newapi" | "oneapi" => "new_api",
|
||||
"cubence" => "cubence",
|
||||
"done_hub" => "done_hub",
|
||||
"donehub" => "done_hub",
|
||||
"yescode" => "yescode",
|
||||
"nekocode" => "nekocode",
|
||||
"anyrouter" => "anyrouter",
|
||||
@@ -136,7 +143,7 @@ pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
|
||||
pub fn admin_provider_ops_is_supported_auth_type(auth_type: &str) -> bool {
|
||||
matches!(
|
||||
auth_type,
|
||||
"api_key" | "session_login" | "oauth" | "cookie" | "none"
|
||||
"api_key" | "refresh_token" | "session_login" | "oauth" | "cookie" | "none"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +227,8 @@ mod tests {
|
||||
assert_eq!(normalize_architecture_id(""), "generic_api");
|
||||
assert_eq!(normalize_architecture_id("done_hub"), "done_hub");
|
||||
assert_eq!(normalize_architecture_id("new_api"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("newapi"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("one-api"), "new_api");
|
||||
assert_eq!(normalize_architecture_id("unknown"), "generic_api");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "访问令牌 (API Key)",
|
||||
"description": "New API 的访问令牌,与 Cookie 二选一",
|
||||
"title": "访问令牌",
|
||||
"description": "在 New API 个人安全设置中获取的访问令牌,与 Cookie 二选一",
|
||||
"x-sensitive": true,
|
||||
"x-input-type": "password"
|
||||
},
|
||||
@@ -30,7 +30,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"title": "用户 ID",
|
||||
"description": "使用访问令牌时必填,使用 Cookie 时可选"
|
||||
"description": "可选;使用 Cookie 时可自动解析"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
@@ -64,13 +64,6 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"type": "any_required",
|
||||
"fields": ["api_key", "cookie"],
|
||||
"message": "访问令牌和 Cookie 至少需要填写一个"
|
||||
},
|
||||
{
|
||||
"type": "conditional_required",
|
||||
"if": "api_key",
|
||||
"then": ["user_id"],
|
||||
"unless": "cookie",
|
||||
"message": "使用访问令牌时,用户 ID 不能为空"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -5,6 +5,38 @@ use super::{
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
let api_key_usage_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "用于访问 Sub2API /v1/usage 的模型 API Key",
|
||||
"x-sensitive": true,
|
||||
"x-input-type": "password",
|
||||
"x-help": "请求 GET /v1/usage,并通过 Authorization: Bearer <API Key> 查询余量"
|
||||
}
|
||||
},
|
||||
"required": ["api_key"],
|
||||
"x-auth-method": "bearer",
|
||||
"x-auth-type": "api_key",
|
||||
"x-field-groups": [
|
||||
{ "fields": ["base_url"] },
|
||||
{ "fields": ["api_key"] }
|
||||
],
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["api_key"],
|
||||
"message": "请填写 API Key"
|
||||
}
|
||||
]
|
||||
});
|
||||
let session_login_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -61,7 +93,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
},
|
||||
"required": ["refresh_token"],
|
||||
"x-auth-method": "bearer",
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-type": "refresh_token",
|
||||
"x-field-groups": [
|
||||
{ "fields": ["base_url"] },
|
||||
{ "fields": ["refresh_token"] }
|
||||
@@ -80,20 +112,25 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
display_name: "Sub2API",
|
||||
description: "Sub2API 风格中转站的预设配置",
|
||||
hidden: false,
|
||||
credentials_schema: session_login_schema.clone(),
|
||||
credentials_schema: api_key_usage_schema.clone(),
|
||||
verify_endpoint: "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
verify_mode: ProviderOpsVerifyMode::Sub2ApiExchange,
|
||||
balance_mode: ProviderOpsBalanceMode::Sub2ApiDualRequest,
|
||||
checkin_mode: ProviderOpsCheckinMode::None,
|
||||
query_balance_cookie_auth_errors: false,
|
||||
supported_auth_types: vec![
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "api_key",
|
||||
display_name: "API Key 用量接口",
|
||||
credentials_schema: api_key_usage_schema,
|
||||
},
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "session_login",
|
||||
display_name: "账号密码",
|
||||
credentials_schema: session_login_schema,
|
||||
},
|
||||
ProviderOpsAuthSpec {
|
||||
auth_type: "api_key",
|
||||
auth_type: "refresh_token",
|
||||
display_name: "Refresh Token",
|
||||
credentials_schema: refresh_token_schema,
|
||||
},
|
||||
@@ -101,7 +138,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
supported_actions: vec![ProviderOpsActionSpec {
|
||||
action_type: "query_balance",
|
||||
display_name: "查询余额",
|
||||
description: "查询 Sub2API 账户余额和订阅信息",
|
||||
description: "查询 Sub2API API Key 用量或账户余额和订阅信息",
|
||||
config_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -114,7 +151,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
||||
"required": []
|
||||
}),
|
||||
}],
|
||||
default_connector: Some("session_login"),
|
||||
default_connector: Some("api_key"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +160,7 @@ pub(super) fn default_action_config(action_type: &str) -> Option<Map<String, Val
|
||||
"query_balance" => Some(json_object(json!({
|
||||
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
"subscription_endpoint": "/api/v1/subscriptions/summary",
|
||||
"api_key_usage_endpoint": "/v1/usage",
|
||||
"method": "GET",
|
||||
"currency": "USD"
|
||||
}))),
|
||||
|
||||
@@ -4,7 +4,8 @@ pub mod config;
|
||||
pub mod verify;
|
||||
|
||||
pub use self::actions::{
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
|
||||
attach_balance_checkin_outcome, parse_query_balance_payload,
|
||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
||||
parse_yescode_combined_balance_payload, ProviderOpsCheckinOutcome,
|
||||
};
|
||||
pub use self::architectures::{
|
||||
|
||||
@@ -379,18 +379,9 @@ pub fn admin_provider_ops_verify_headers(
|
||||
}
|
||||
"new_api" => {
|
||||
for (name, value) in [
|
||||
(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36",
|
||||
),
|
||||
("User-Agent", "cc-switch/1.0"),
|
||||
("Content-Type", "application/json"),
|
||||
("Accept", "application/json"),
|
||||
("Accept-Language", "zh-CN"),
|
||||
("sec-ch-ua", "\"Not=A?Brand\";v=\"24\", \"Chromium\";v=\"140\""),
|
||||
("sec-ch-ua-mobile", "?0"),
|
||||
("sec-ch-ua-platform", "\"macOS\""),
|
||||
("Sec-Fetch-Site", "cross-site"),
|
||||
("Sec-Fetch-Mode", "cors"),
|
||||
("Sec-Fetch-Dest", "empty"),
|
||||
] {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
}
|
||||
|
||||
@@ -286,6 +286,74 @@ export async function refreshProviderQuota(
|
||||
return response.data
|
||||
}
|
||||
|
||||
export type ProviderKeyBalanceStatus =
|
||||
| 'success'
|
||||
| 'pending'
|
||||
| 'auth_failed'
|
||||
| 'auth_expired'
|
||||
| 'rate_limited'
|
||||
| 'network_error'
|
||||
| 'parse_error'
|
||||
| 'not_configured'
|
||||
| 'not_supported'
|
||||
| 'already_done'
|
||||
| 'unknown_error'
|
||||
|
||||
export interface ProviderKeyBalanceInfo {
|
||||
total_granted: number | null
|
||||
total_used: number | null
|
||||
total_available: number | null
|
||||
expires_at: string | null
|
||||
currency: string
|
||||
extra: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ProviderKeyBalanceResult {
|
||||
status: ProviderKeyBalanceStatus
|
||||
action_type: 'query_balance'
|
||||
data: ProviderKeyBalanceInfo | null
|
||||
message: string | null
|
||||
executed_at: string
|
||||
response_time_ms: number | null
|
||||
cache_ttl_seconds: number
|
||||
saved_to_key?: boolean
|
||||
saved_key_id?: string | null
|
||||
save_message?: string | null
|
||||
}
|
||||
|
||||
export interface ProviderKeyBalanceQuery {
|
||||
key_id?: string
|
||||
api_key?: string
|
||||
auth_type?: 'api_key' | 'bearer' | 'service_account' | 'oauth'
|
||||
api_formats?: string[]
|
||||
architecture_id?: 'new_api' | 'sub2api' | 'generic_api'
|
||||
custom_base_url?: string
|
||||
new_api_user_id?: string
|
||||
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token'
|
||||
custom_endpoint?: string
|
||||
custom_method?: 'GET' | 'POST'
|
||||
custom_currency?: string
|
||||
custom_quota_divisor?: number
|
||||
custom_balance_path?: string
|
||||
custom_used_path?: string
|
||||
custom_granted_path?: string
|
||||
auto_refresh_interval_minutes?: number
|
||||
save_balance_secret?: boolean
|
||||
save_result?: boolean
|
||||
}
|
||||
|
||||
export async function queryProviderKeyBalance(
|
||||
providerId: string,
|
||||
data: ProviderKeyBalanceQuery,
|
||||
): Promise<ProviderKeyBalanceResult> {
|
||||
const response = await client.post<ProviderKeyBalanceResult>(
|
||||
`/api/admin/endpoints/providers/${providerId}/key-balance`,
|
||||
data,
|
||||
{ timeout: 60 * 1000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入 OAuth 凭据(通用)
|
||||
* 支持的 Provider 类型:Codex、Antigravity、GeminiCli、ClaudeCode、Kiro
|
||||
|
||||
@@ -402,12 +402,46 @@ export interface GrokUpstreamMetadata {
|
||||
account_user_id?: string | null
|
||||
}
|
||||
|
||||
export interface BalanceQueryUpstreamMetadata {
|
||||
updated_at?: number
|
||||
architecture_id?: string | null
|
||||
status?: string | null
|
||||
executed_at?: string | null
|
||||
response_time_ms?: number | null
|
||||
total_available?: number | null
|
||||
total_used?: number | null
|
||||
total_granted?: number | null
|
||||
currency?: string | null
|
||||
plan_name?: string | null
|
||||
query_config?: {
|
||||
custom_base_url?: string | null
|
||||
new_api_user_id?: string | null
|
||||
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token' | string | null
|
||||
custom_endpoint?: string | null
|
||||
custom_method?: 'GET' | 'POST' | string | null
|
||||
custom_currency?: string | null
|
||||
custom_quota_divisor?: number | null
|
||||
custom_balance_path?: string | null
|
||||
custom_used_path?: string | null
|
||||
custom_granted_path?: string | null
|
||||
auto_refresh_interval_minutes?: number | null
|
||||
has_saved_secret?: boolean | null
|
||||
} | null
|
||||
extra?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ProviderKeyBalanceSummary extends BalanceQueryUpstreamMetadata {
|
||||
key_id?: string | null
|
||||
key_name?: string | null
|
||||
}
|
||||
|
||||
export interface UpstreamMetadata {
|
||||
codex?: CodexUpstreamMetadata
|
||||
antigravity?: AntigravityUpstreamMetadata
|
||||
kiro?: KiroUpstreamMetadata
|
||||
chatgpt_web?: ChatGPTWebUpstreamMetadata
|
||||
grok?: GrokUpstreamMetadata
|
||||
balance_query?: BalanceQueryUpstreamMetadata
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
@@ -684,6 +718,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
key_balance_summary?: ProviderKeyBalanceSummary | null
|
||||
kiro_simulated_cache_enabled?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -12,7 +12,13 @@ import client from './client'
|
||||
// ==================== Types ====================
|
||||
|
||||
/** 认证类型 */
|
||||
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
|
||||
export type ConnectorAuthType =
|
||||
| 'api_key'
|
||||
| 'refresh_token'
|
||||
| 'session_login'
|
||||
| 'oauth'
|
||||
| 'cookie'
|
||||
| 'none'
|
||||
|
||||
/** 操作类型 */
|
||||
export type ProviderActionType =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
title="用户认证"
|
||||
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
|
||||
:title="dialogTitle"
|
||||
description="独立配置上游余额/用量查询凭据,不影响模型调用 Key"
|
||||
:icon="KeyRound"
|
||||
size="md"
|
||||
size="4xl"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<form
|
||||
@@ -23,38 +23,30 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
class="space-y-5"
|
||||
>
|
||||
<!-- 认证模板 + 认证方式(并排) -->
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="space-y-2"
|
||||
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }"
|
||||
>
|
||||
<Label>认证模板</Label>
|
||||
<Select
|
||||
v-model="selectedArchitectureId"
|
||||
@update:model-value="handleArchitectureChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="arch in architectures"
|
||||
:key="arch.architecture_id"
|
||||
:value="arch.architecture_id"
|
||||
>
|
||||
{{ arch.display_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label>预设模板</Label>
|
||||
<span class="text-xs text-muted-foreground">留空则自动使用供应商配置</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-for="arch in architectures"
|
||||
:key="arch.architecture_id"
|
||||
type="button"
|
||||
class="h-8 rounded-md border px-3 text-xs font-medium transition-colors"
|
||||
:class="selectedArchitectureId === arch.architecture_id
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'"
|
||||
@click="selectArchitecturePreset(arch.architecture_id)"
|
||||
>
|
||||
{{ formatArchitectureLabel(arch) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentAuthTypes.length > 1"
|
||||
class="space-y-2"
|
||||
style="flex: 1"
|
||||
class="grid gap-2 sm:max-w-xs"
|
||||
>
|
||||
<Label>认证方式</Label>
|
||||
<Select
|
||||
@@ -77,6 +69,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-foreground">
|
||||
凭证配置
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
不同模板支持的凭据类型不同,API Key、访问令牌和 Refresh Token 会分别保留。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="currentSchema">
|
||||
<template
|
||||
@@ -262,13 +263,23 @@
|
||||
:disabled="isVerifying || !canVerify"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ isVerifying ? '验证中...' : '验证' }}
|
||||
<Loader2
|
||||
v-if="isVerifying"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ isVerifying ? '测试中...' : '测试脚本' }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
variant="outline"
|
||||
:disabled="isSaving || isVerifying"
|
||||
@click="handleFormat"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
<Wand2 class="h-3.5 w-3.5" />
|
||||
格式化
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -276,6 +287,20 @@
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Loader2
|
||||
v-if="isSaving"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
<Save
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ isSaving ? '保存中...' : '保存配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -284,7 +309,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { KeyRound } from 'lucide-vue-next'
|
||||
import { KeyRound, Loader2, Play, Save, Wand2 } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
@@ -325,6 +350,7 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName?: string
|
||||
providerWebsite?: string
|
||||
currentConfig?: Record<string, unknown> | null
|
||||
}>()
|
||||
@@ -376,6 +402,12 @@ const selectedArchitectureId = ref('new_api')
|
||||
const selectedAuthType = ref('')
|
||||
const formData = ref<Record<string, unknown>>({})
|
||||
|
||||
const dialogTitle = computed(() => (
|
||||
props.providerName
|
||||
? `配置用量查询 - ${props.providerName}`
|
||||
: '配置用量查询'
|
||||
))
|
||||
|
||||
// 当前架构支持的认证方式
|
||||
const currentAuthTypes = computed(() => {
|
||||
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
|
||||
@@ -441,6 +473,26 @@ function handleArchitectureChange() {
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
function selectArchitecturePreset(architectureId: string) {
|
||||
if (selectedArchitectureId.value === architectureId) return
|
||||
selectedArchitectureId.value = architectureId
|
||||
handleArchitectureChange()
|
||||
}
|
||||
|
||||
function formatArchitectureLabel(arch: ArchitectureInfo): string {
|
||||
const labels: Record<string, string> = {
|
||||
generic_api: '通用模板',
|
||||
new_api: 'NewAPI',
|
||||
sub2api: 'Sub2API',
|
||||
anyrouter: 'AnyRouter',
|
||||
done_hub: 'Done Hub',
|
||||
yescode: 'YesCode',
|
||||
cubence: 'Cubence',
|
||||
nekocode: 'NekoCode',
|
||||
}
|
||||
return labels[arch.architecture_id] || arch.display_name
|
||||
}
|
||||
|
||||
function handleAuthTypeChange() {
|
||||
resetFormData()
|
||||
verifyStatus.value = null
|
||||
@@ -478,7 +530,9 @@ function resetFormData() {
|
||||
// 初始化表单数据
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
|
||||
data[key] = key === 'base_url'
|
||||
? (props.providerWebsite || (prop as Record<string, unknown>)['x-default-value'] || '')
|
||||
: ((prop as Record<string, unknown>)['x-default-value'] ?? '')
|
||||
}
|
||||
// 代理相关默认值
|
||||
data.proxy_enabled = false
|
||||
@@ -495,6 +549,22 @@ function formatQuota(quota: number): string {
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
function handleFormat() {
|
||||
const normalized: Record<string, unknown> = { ...formData.value }
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (typeof value !== 'string') continue
|
||||
normalized[key] = key === 'base_url'
|
||||
? value.trim().replace(/\/+$/, '')
|
||||
: value.trim()
|
||||
}
|
||||
if (!normalized.base_url && props.providerWebsite) {
|
||||
normalized.base_url = props.providerWebsite.replace(/\/+$/, '')
|
||||
}
|
||||
formData.value = normalized
|
||||
verifyStatus.value = null
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const schema = currentSchema.value
|
||||
if (!schema) return
|
||||
@@ -677,6 +747,10 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
if (!config?.connector) return
|
||||
|
||||
hasExistingConfig.value = true
|
||||
const connector = config.connector as {
|
||||
auth_type?: string
|
||||
credentials?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 根据已保存的 architecture_id 选择对应架构
|
||||
const architectureId = config.architecture_id || 'new_api'
|
||||
@@ -684,7 +758,15 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||
|
||||
// 从已保存的 connector auth_type 恢复认证方式选择
|
||||
const savedAuthType = config.connector?.auth_type
|
||||
let savedAuthType = connector?.auth_type
|
||||
if (
|
||||
selectedArchitectureId.value === 'sub2api' &&
|
||||
savedAuthType === 'api_key' &&
|
||||
connector?.credentials?.refresh_token &&
|
||||
!connector?.credentials?.api_key
|
||||
) {
|
||||
savedAuthType = 'refresh_token'
|
||||
}
|
||||
const authTypes = currentAuthTypes.value
|
||||
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
|
||||
selectedAuthType.value = savedAuthType
|
||||
@@ -775,4 +857,14 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.providerWebsite,
|
||||
(value) => {
|
||||
if (!props.open || hasExistingConfig.value || !value) return
|
||||
if (!formData.value.base_url) {
|
||||
formData.value.base_url = value
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<template>
|
||||
<!-- 余额正在加载中 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<!-- 余额文字:balance + points 分开显示,或普通余额 -->
|
||||
@@ -95,6 +87,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 显示保存到 Key 的手动余额查询摘要 -->
|
||||
<div
|
||||
v-else-if="getSavedKeyBalance(provider)"
|
||||
class="space-y-0.5 text-xs"
|
||||
:title="getSavedKeyBalanceTitle(provider)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<WalletCards class="h-3 w-3 text-primary" />
|
||||
<span class="font-semibold text-foreground/90 tabular-nums">
|
||||
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/70">
|
||||
<span v-if="toFiniteNumber(getSavedKeyBalance(provider)?.total_used) !== null">
|
||||
已用 {{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_used, getSavedKeyBalance(provider)?.currency || 'USD') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 余额正在加载中 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
@@ -128,11 +149,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Loader2, WalletCards } from 'lucide-vue-next'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import type { ProviderKeyBalanceSummary, ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import {
|
||||
formatKeyBalanceAmount,
|
||||
formatKeyBalanceUpdatedAt,
|
||||
hasKeyBalanceSummary,
|
||||
keyBalanceTemplateLabel,
|
||||
toFiniteNumber,
|
||||
} from '@/features/providers/utils/keyBalanceSummary'
|
||||
|
||||
defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -147,4 +175,19 @@ defineProps<{
|
||||
formatResetCountdown: (resetsAt: number) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
|
||||
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
|
||||
}
|
||||
|
||||
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
|
||||
const summary = getSavedKeyBalance(provider)
|
||||
if (!summary) return ''
|
||||
const parts = [
|
||||
summary.key_name ? `Key: ${summary.key_name}` : null,
|
||||
keyBalanceTemplateLabel(summary.architecture_id),
|
||||
formatKeyBalanceUpdatedAt(summary.updated_at),
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -555,6 +555,64 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 手动余额查询摘要 -->
|
||||
<div
|
||||
v-if="getKeyBalanceSummary(key)"
|
||||
class="mt-2 flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="inline-flex items-center gap-1 font-medium text-foreground">
|
||||
<WalletCards class="h-3 w-3 text-primary" />
|
||||
上游余额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.available, getKeyBalanceSummary(key)?.currency) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getKeyBalanceSummary(key)?.used !== null"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
已用 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.used, getKeyBalanceSummary(key)?.currency) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getKeyBalanceSummary(key)?.granted !== null"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
总额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.granted, getKeyBalanceSummary(key)?.currency) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getKeyBalanceSummary(key)?.planName"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
套餐 {{ getKeyBalanceSummary(key)?.planName }}
|
||||
</span>
|
||||
<span class="text-muted-foreground/70">
|
||||
{{ getKeyBalanceSummary(key)?.templateLabel }} · {{ formatUpdatedAt(getKeyBalanceSummary(key)?.updatedAt || 0) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getKeyBalanceAutoRefreshIntervalMinutes(key) > 0"
|
||||
class="text-muted-foreground/70"
|
||||
>
|
||||
每 {{ getKeyBalanceAutoRefreshIntervalMinutes(key) }} 分钟自动
|
||||
</span>
|
||||
<span
|
||||
v-if="keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
需保存查询凭据
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
:disabled="refreshingBalanceKeyId === key.id || !canRefreshKeyBalance(key)"
|
||||
:title="getKeyBalanceRefreshTitle(key)"
|
||||
@click.stop="handleRefreshKeyBalance(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3 w-3"
|
||||
:class="{ 'animate-spin': refreshingBalanceKeyId === key.id }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="hasCodexQuotaDisplayData(key)"
|
||||
@@ -1217,7 +1275,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Key,
|
||||
@@ -1236,6 +1294,7 @@ import {
|
||||
ShieldX,
|
||||
Globe,
|
||||
GitBranch,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
@@ -1282,10 +1341,12 @@ import {
|
||||
exportKey,
|
||||
refreshProviderOAuth,
|
||||
refreshProviderQuota,
|
||||
queryProviderKeyBalance,
|
||||
clearOAuthInvalid,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model,
|
||||
type ProviderKeyBalanceQuery,
|
||||
API_FORMAT_ORDER,
|
||||
sortApiFormats,
|
||||
} from '@/api/endpoints'
|
||||
@@ -1330,6 +1391,17 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
rpm_limit?: number
|
||||
}
|
||||
|
||||
interface KeyBalanceSummary {
|
||||
available: number | null
|
||||
used: number | null
|
||||
granted: number | null
|
||||
currency: string
|
||||
updatedAt: number
|
||||
templateLabel: string
|
||||
planName: string | null
|
||||
architectureId: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
providerId: string | null
|
||||
open: boolean
|
||||
@@ -1365,6 +1437,8 @@ let keysLoadRequestId = 0
|
||||
let mappingPreviewLoadRequestId = 0
|
||||
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
||||
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
|
||||
const BALANCE_AUTO_REFRESH_CHECK_MS = 60_000
|
||||
let balanceAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function getProviderKeysPageSize(providerType?: string | null): number {
|
||||
return (providerType || '').trim().toLowerCase() === 'custom'
|
||||
@@ -1388,6 +1462,7 @@ const editingKey = ref<EndpointAPIKey | null>(null)
|
||||
const deleteKeyConfirmOpen = ref(false)
|
||||
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
const refreshingBalanceKeyId = ref<string | null>(null)
|
||||
|
||||
// 密钥显示状态:key_id -> 完整密钥
|
||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||
@@ -1570,6 +1645,7 @@ watch(
|
||||
// 仅在抽屉刚打开时启动倒计时
|
||||
if (newOpen && !oldOpen) {
|
||||
startCountdownTimer()
|
||||
startKeyBalanceAutoRefreshTimer()
|
||||
}
|
||||
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
|
||||
} else if (!newOpen && oldOpen) {
|
||||
@@ -1581,6 +1657,7 @@ watch(
|
||||
|
||||
// 停止倒计时定时器
|
||||
stopCountdownTimer()
|
||||
stopKeyBalanceAutoRefreshTimer()
|
||||
// 重置所有状态
|
||||
loading.value = false
|
||||
provider.value = null
|
||||
@@ -1743,6 +1820,167 @@ function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIK
|
||||
}
|
||||
}
|
||||
|
||||
function canOpenKeyBalanceQuery(key: EndpointAPIKey): boolean {
|
||||
return key.auth_type === 'api_key' || key.auth_type === 'bearer'
|
||||
}
|
||||
|
||||
function normalizeBalanceArchitectureId(value: unknown): ProviderKeyBalanceQuery['architecture_id'] | undefined {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/-/g, '_')
|
||||
if (normalized === 'newapi' || normalized === 'new_api') return 'new_api'
|
||||
if (normalized === 'sub2api') return 'sub2api'
|
||||
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return 'generic_api'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canRefreshKeyBalance(key: EndpointAPIKey): boolean {
|
||||
return canOpenKeyBalanceQuery(key)
|
||||
&& !!normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
|
||||
&& (!keyBalanceRefreshRequiresSavedSecret(key) || hasSavedBalanceSecret(key))
|
||||
}
|
||||
|
||||
function hasSavedBalanceSecret(key: EndpointAPIKey): boolean {
|
||||
return key.upstream_metadata?.balance_query?.query_config?.has_saved_secret === true
|
||||
}
|
||||
|
||||
function keyBalanceRefreshRequiresSavedSecret(key: EndpointAPIKey): boolean {
|
||||
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
|
||||
if (architectureId === 'new_api') return true
|
||||
if (architectureId !== 'sub2api') return false
|
||||
const credentialKind = String(
|
||||
key.upstream_metadata?.balance_query?.query_config?.sub2api_credential_kind || ''
|
||||
).trim()
|
||||
return credentialKind === 'access_token' || credentialKind === 'refresh_token'
|
||||
}
|
||||
|
||||
function getKeyBalanceAutoRefreshIntervalMinutes(key: EndpointAPIKey): number {
|
||||
const parsed = toFiniteNumber(
|
||||
key.upstream_metadata?.balance_query?.query_config?.auto_refresh_interval_minutes
|
||||
)
|
||||
if (parsed === null || parsed <= 0) return 0
|
||||
return Math.min(Math.floor(parsed), 10080)
|
||||
}
|
||||
|
||||
function isKeyBalanceAutoRefreshDue(key: EndpointAPIKey): boolean {
|
||||
const intervalMinutes = getKeyBalanceAutoRefreshIntervalMinutes(key)
|
||||
if (intervalMinutes <= 0 || !canRefreshKeyBalance(key)) return false
|
||||
|
||||
const updatedAt = toFiniteNumber(key.upstream_metadata?.balance_query?.updated_at)
|
||||
if (updatedAt === null || updatedAt <= 0) return true
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return now - updatedAt >= intervalMinutes * 60
|
||||
}
|
||||
|
||||
function startKeyBalanceAutoRefreshTimer() {
|
||||
if (balanceAutoRefreshTimer) return
|
||||
balanceAutoRefreshTimer = setInterval(() => {
|
||||
void refreshDueKeyBalances()
|
||||
}, BALANCE_AUTO_REFRESH_CHECK_MS)
|
||||
}
|
||||
|
||||
function stopKeyBalanceAutoRefreshTimer() {
|
||||
if (!balanceAutoRefreshTimer) return
|
||||
clearInterval(balanceAutoRefreshTimer)
|
||||
balanceAutoRefreshTimer = null
|
||||
}
|
||||
|
||||
async function refreshDueKeyBalances() {
|
||||
if (!props.open || !props.providerId || refreshingBalanceKeyId.value) return
|
||||
const dueKey = providerKeys.value.find(key => key.is_active && isKeyBalanceAutoRefreshDue(key))
|
||||
if (!dueKey) return
|
||||
await handleRefreshKeyBalance(dueKey, { silent: true })
|
||||
}
|
||||
|
||||
function getKeyBalanceRefreshTitle(key: EndpointAPIKey): string {
|
||||
if (!canOpenKeyBalanceQuery(key)) {
|
||||
return '余额查询仅支持 API Key 或 Bearer Token'
|
||||
}
|
||||
if (!canRefreshKeyBalance(key)) {
|
||||
if (keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)) {
|
||||
return '需要先手动查询一次,并开启“保存余额查询凭据”'
|
||||
}
|
||||
return '缺少上次查询模板,请先手动查询一次余额'
|
||||
}
|
||||
const summary = getKeyBalanceSummary(key)
|
||||
return summary?.templateLabel
|
||||
? `重新查询 ${summary.templateLabel} 余额`
|
||||
: '重新查询余额'
|
||||
}
|
||||
|
||||
function assignSavedBalanceQueryConfig(query: ProviderKeyBalanceQuery, key: EndpointAPIKey) {
|
||||
const config = key.upstream_metadata?.balance_query?.query_config
|
||||
if (!config || typeof config !== 'object') return
|
||||
|
||||
query.custom_base_url = trimmedStringOrUndefined(config.custom_base_url)
|
||||
query.new_api_user_id = trimmedStringOrUndefined(config.new_api_user_id)
|
||||
|
||||
const sub2apiKind = String(config.sub2api_credential_kind || '').trim()
|
||||
if (sub2apiKind === 'api_key' || sub2apiKind === 'access_token' || sub2apiKind === 'refresh_token') {
|
||||
query.sub2api_credential_kind = sub2apiKind
|
||||
}
|
||||
|
||||
query.custom_endpoint = trimmedStringOrUndefined(config.custom_endpoint)
|
||||
const customMethod = String(config.custom_method || '').trim().toUpperCase()
|
||||
if (customMethod === 'GET' || customMethod === 'POST') {
|
||||
query.custom_method = customMethod
|
||||
}
|
||||
query.custom_currency = trimmedStringOrUndefined(config.custom_currency)
|
||||
const customQuotaDivisor = toFiniteNumber(config.custom_quota_divisor)
|
||||
if (customQuotaDivisor !== null && customQuotaDivisor > 0) {
|
||||
query.custom_quota_divisor = customQuotaDivisor
|
||||
}
|
||||
const intervalMinutes = toFiniteNumber(config.auto_refresh_interval_minutes)
|
||||
if (intervalMinutes !== null && intervalMinutes > 0) {
|
||||
query.auto_refresh_interval_minutes = Math.min(Math.floor(intervalMinutes), 10080)
|
||||
}
|
||||
query.custom_balance_path = trimmedStringOrUndefined(config.custom_balance_path)
|
||||
query.custom_used_path = trimmedStringOrUndefined(config.custom_used_path)
|
||||
query.custom_granted_path = trimmedStringOrUndefined(config.custom_granted_path)
|
||||
}
|
||||
|
||||
function trimmedStringOrUndefined(value: unknown): string | undefined {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
async function handleRefreshKeyBalance(key: EndpointAPIKey, options: { silent?: boolean } = {}) {
|
||||
if (!props.providerId || refreshingBalanceKeyId.value || !canRefreshKeyBalance(key)) return
|
||||
|
||||
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
|
||||
if (!architectureId) return
|
||||
|
||||
refreshingBalanceKeyId.value = key.id
|
||||
try {
|
||||
const query: ProviderKeyBalanceQuery = {
|
||||
key_id: key.id,
|
||||
auth_type: key.auth_type === 'bearer' ? 'bearer' : 'api_key',
|
||||
api_formats: key.api_formats || [],
|
||||
architecture_id: architectureId,
|
||||
save_result: true,
|
||||
}
|
||||
assignSavedBalanceQueryConfig(query, key)
|
||||
|
||||
const result = await queryProviderKeyBalance(props.providerId, query)
|
||||
if (result.status !== 'success') {
|
||||
if (!options.silent) {
|
||||
showError(result.message || '余额刷新失败', '错误')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!options.silent) {
|
||||
showSuccess('余额已刷新')
|
||||
}
|
||||
await loadProviderKeysPage(currentKeyPage.value)
|
||||
emit('refresh')
|
||||
} catch (err: unknown) {
|
||||
if (!options.silent) {
|
||||
showError(parseApiError(err, '余额刷新失败'), '错误')
|
||||
}
|
||||
} finally {
|
||||
refreshingBalanceKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPermissions(key: EndpointAPIKey) {
|
||||
editingKey.value = key
|
||||
keyPermissionsDialogOpen.value = true
|
||||
@@ -2698,7 +2936,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
||||
}
|
||||
|
||||
async function handleKeyChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
|
||||
emit('refresh')
|
||||
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
|
||||
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
||||
@@ -3169,6 +3407,58 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
return hasAntigravityQuotaData(key.upstream_metadata)
|
||||
}
|
||||
|
||||
function getKeyBalanceSummary(key: EndpointAPIKey): KeyBalanceSummary | null {
|
||||
const metadata = key.upstream_metadata?.balance_query
|
||||
if (!metadata) return null
|
||||
const updatedAt = toFiniteNumber(metadata.updated_at)
|
||||
const available = toFiniteNumber(metadata.total_available)
|
||||
const used = toFiniteNumber(metadata.total_used)
|
||||
const granted = toFiniteNumber(metadata.total_granted)
|
||||
if (updatedAt === null || (available === null && used === null && granted === null)) {
|
||||
return null
|
||||
}
|
||||
const architectureId = String(metadata.architecture_id || '').trim()
|
||||
const labels: Record<string, string> = {
|
||||
new_api: 'NewAPI',
|
||||
sub2api: 'Sub2API',
|
||||
generic_api: '自定义'
|
||||
}
|
||||
return {
|
||||
available,
|
||||
used,
|
||||
granted,
|
||||
currency: String(metadata.currency || 'USD').trim() || 'USD',
|
||||
updatedAt,
|
||||
templateLabel: labels[architectureId] || architectureId || '余额查询',
|
||||
architectureId,
|
||||
planName: typeof metadata.plan_name === 'string' && metadata.plan_name.trim()
|
||||
? metadata.plan_name.trim()
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
|
||||
const numberValue = toFiniteNumber(value)
|
||||
if (numberValue === null) return '未知'
|
||||
const normalizedCurrency = (currency || 'USD').toUpperCase()
|
||||
const prefix = normalizedCurrency === 'USD'
|
||||
? '$'
|
||||
: normalizedCurrency === 'CNY'
|
||||
? '¥'
|
||||
: `${normalizedCurrency} `
|
||||
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
|
||||
return `${prefix}${numberValue.toFixed(decimals)}`
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number): string {
|
||||
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -3685,6 +3975,7 @@ async function loadProviderKeysPage(page = currentKeyPage.value) {
|
||||
currentKeyPage.value = Math.min(result.page, nextTotalPages)
|
||||
keyPageSize.value = result.page_size
|
||||
syncCurrentSelections(endpoints.value, result.keys)
|
||||
void refreshDueKeyBalances()
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
|
||||
providerKeys.value = []
|
||||
@@ -3782,6 +4073,10 @@ useEscapeKey(() => {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopKeyBalanceAutoRefreshTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="扩展操作配置"
|
||||
title="配置用量查询"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
@@ -125,17 +125,9 @@
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
<!-- 余额加载中 -->
|
||||
<span
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
@@ -157,6 +149,29 @@
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</span>
|
||||
<!-- 保存到 Key 的手动余额查询摘要 -->
|
||||
<span
|
||||
v-else-if="getSavedKeyBalance(provider)"
|
||||
class="text-muted-foreground inline-flex items-center gap-1"
|
||||
:title="getSavedKeyBalanceTitle(provider)"
|
||||
>
|
||||
<WalletCards class="h-3 w-3 text-primary" />
|
||||
余额
|
||||
<span class="font-semibold text-foreground/90">
|
||||
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
|
||||
</span>
|
||||
<span class="text-muted-foreground/70">
|
||||
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- 余额加载中 -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
@@ -233,13 +248,20 @@ import {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
|
||||
import { type ProviderKeyBalanceSummary, type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import {
|
||||
formatKeyBalanceAmount,
|
||||
formatKeyBalanceUpdatedAt,
|
||||
hasKeyBalanceSummary,
|
||||
keyBalanceTemplateLabel,
|
||||
} from '@/features/providers/utils/keyBalanceSummary'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -307,4 +329,19 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
|
||||
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
|
||||
}
|
||||
|
||||
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
|
||||
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
|
||||
}
|
||||
|
||||
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
|
||||
const summary = getSavedKeyBalance(provider)
|
||||
if (!summary) return ''
|
||||
const parts = [
|
||||
summary.key_name ? `Key: ${summary.key_name}` : null,
|
||||
keyBalanceTemplateLabel(summary.architecture_id),
|
||||
formatKeyBalanceUpdatedAt(summary.updated_at),
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="扩展操作配置"
|
||||
title="配置用量查询"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
|
||||
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal file
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ProviderKeyBalanceSummary } from '@/api/endpoints'
|
||||
|
||||
export function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function hasKeyBalanceSummary(summary: ProviderKeyBalanceSummary | null | undefined): summary is ProviderKeyBalanceSummary {
|
||||
if (!summary) return false
|
||||
const updatedAt = toFiniteNumber(summary.updated_at)
|
||||
if (updatedAt === null) return false
|
||||
return toFiniteNumber(summary.total_available) !== null
|
||||
|| toFiniteNumber(summary.total_used) !== null
|
||||
|| toFiniteNumber(summary.total_granted) !== null
|
||||
}
|
||||
|
||||
export function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
|
||||
const numberValue = toFiniteNumber(value)
|
||||
if (numberValue === null) return '未知'
|
||||
const normalizedCurrency = (currency || 'USD').toUpperCase()
|
||||
const prefix = normalizedCurrency === 'USD'
|
||||
? '$'
|
||||
: normalizedCurrency === 'CNY'
|
||||
? '¥'
|
||||
: `${normalizedCurrency} `
|
||||
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
|
||||
return `${prefix}${numberValue.toFixed(decimals)}`
|
||||
}
|
||||
|
||||
export function keyBalanceTemplateLabel(architectureId: unknown): string {
|
||||
const normalized = String(architectureId || '').trim().toLowerCase().replace(/-/g, '_')
|
||||
if (normalized === 'newapi' || normalized === 'new_api') return 'NewAPI'
|
||||
if (normalized === 'sub2api') return 'Sub2API'
|
||||
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return '自定义'
|
||||
return normalized || '余额查询'
|
||||
}
|
||||
|
||||
export function formatKeyBalanceUpdatedAt(updatedAt: unknown): string {
|
||||
const timestamp = toFiniteNumber(updatedAt)
|
||||
if (timestamp === null || timestamp <= 0) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - timestamp
|
||||
if (diff <= 60) return '刚刚更新'
|
||||
const minutes = Math.floor(diff / 60)
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天前`
|
||||
}
|
||||
@@ -295,6 +295,7 @@
|
||||
<ProviderAuthDialog
|
||||
v-model:open="opsConfigDialogOpen"
|
||||
:provider-id="opsConfigProviderId"
|
||||
:provider-name="opsConfigProviderName"
|
||||
:provider-website="opsConfigProviderWebsite"
|
||||
@saved="handleOpsConfigSaved"
|
||||
/>
|
||||
@@ -325,6 +326,7 @@ import { useProviderBalance } from '@/features/providers/composables/useProvider
|
||||
import {
|
||||
getProvidersSummary,
|
||||
getProvider,
|
||||
getProviderEndpoints,
|
||||
deleteProvider,
|
||||
getProviderDeleteTask,
|
||||
updateProvider,
|
||||
@@ -517,6 +519,7 @@ const {
|
||||
// 扩展操作配置对话框
|
||||
const opsConfigDialogOpen = ref(false)
|
||||
const opsConfigProviderId = ref('')
|
||||
const opsConfigProviderName = ref('')
|
||||
const opsConfigProviderWebsite = ref('')
|
||||
|
||||
// 内联编辑备注
|
||||
@@ -707,10 +710,21 @@ async function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
|
||||
}
|
||||
|
||||
// 打开扩展操作配置对话框
|
||||
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
||||
async function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
||||
opsConfigProviderId.value = provider.id
|
||||
opsConfigProviderName.value = provider.name
|
||||
opsConfigProviderWebsite.value = provider.website || ''
|
||||
opsConfigDialogOpen.value = true
|
||||
if (!opsConfigProviderWebsite.value) {
|
||||
try {
|
||||
const endpoints = await getProviderEndpoints(provider.id)
|
||||
if (opsConfigProviderId.value !== provider.id || opsConfigProviderWebsite.value) return
|
||||
const endpoint = endpoints.find(item => item.is_active) || endpoints[0]
|
||||
opsConfigProviderWebsite.value = endpoint?.base_url || ''
|
||||
} catch {
|
||||
// 保持空地址,弹窗内仍可手动填写。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展操作配置保存回调
|
||||
|
||||
Reference in New Issue
Block a user