支持 Provider Key 余额查询与自动刷新

This commit is contained in:
zhiqicloud
2026-05-20 12:33:31 +08:00
parent 4d856f3deb
commit 122daf0f87
33 changed files with 3729 additions and 152 deletions

View File

@@ -177,6 +177,17 @@ pub(super) fn classify_admin_endpoints_family_route(
"admin:endpoints_manage", "admin:endpoints_manage",
false, 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 } else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/providers/") && normalized_path.starts_with("/api/admin/endpoints/providers/")
&& normalized_path.ends_with("/keys") && normalized_path.ends_with("/keys")

View File

@@ -380,6 +380,20 @@ fn classifies_admin_refresh_provider_quota_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate()); 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] #[test]
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() { fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
let headers = headers(&[]); 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)); 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] #[test]
fn classifies_admin_default_body_rules_as_admin_proxy_route() { fn classifies_admin_default_body_rules_as_admin_proxy_route() {
let headers = headers(&[]); let headers = headers(&[]);

View File

@@ -1,3 +1,4 @@
mod balance;
mod mutations; mod mutations;
mod quota; mod quota;
mod reads; mod reads;
@@ -18,6 +19,10 @@ pub(crate) async fn maybe_build_local_admin_endpoints_keys_response(
return Ok(Some(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? { if let Some(response) = mutations::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response)); return Ok(Some(response));
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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( pub(crate) async fn admin_provider_ops_local_action_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider_id: &str, provider_id: &str,

View File

@@ -111,11 +111,14 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
if status != http::StatusCode::OK { if status != http::StatusCode::OK {
let cookie_auth = architecture.query_balance_cookie_auth_errors; let cookie_auth = architecture.query_balance_cookie_auth_errors;
let new_api_token_auth = architecture.architecture_id == "new_api";
return match status { return match status {
http::StatusCode::UNAUTHORIZED => admin_provider_ops_action_error( http::StatusCode::UNAUTHORIZED => admin_provider_ops_action_error(
"auth_failed", "auth_failed",
"query_balance", "query_balance",
if cookie_auth { if new_api_token_auth {
"访问令牌无效,请使用 New API 个人安全设置里的访问令牌"
} else if cookie_auth {
"Cookie 已失效,请重新配置" "Cookie 已失效,请重新配置"
} else { } else {
"认证失败" "认证失败"
@@ -125,7 +128,9 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
http::StatusCode::FORBIDDEN => admin_provider_ops_action_error( http::StatusCode::FORBIDDEN => admin_provider_ops_action_error(
"auth_failed", "auth_failed",
"query_balance", "query_balance",
if cookie_auth { if new_api_token_auth {
"访问令牌无效或无权限,请使用 New API 个人安全设置里的访问令牌"
} else if cookie_auth {
"Cookie 已失效或无权限" "Cookie 已失效或无权限"
} else { } else {
"无权限访问" "无权限访问"

View File

@@ -8,7 +8,9 @@ use super::super::responses::{
}; };
use super::super::support::admin_provider_ops_json_object_map; use super::super::support::admin_provider_ops_json_object_map;
use crate::handlers::admin::request::AdminAppState; 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_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -24,6 +26,23 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
proxy_snapshot: Option<&ProxySnapshot>, proxy_snapshot: Option<&ProxySnapshot>,
) -> serde_json::Value { ) -> serde_json::Value {
let start = std::time::Instant::now(); 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) = let (access_token, updated_credentials, _frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token( match admin_provider_ops_sub2api_exchange_token(
state, 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 { fn network_error_message(error: &str) -> String {
let normalized = error.trim(); let normalized = error.trim();
let lower = normalized.to_ascii_lowercase(); let lower = normalized.to_ascii_lowercase();

View File

@@ -250,6 +250,9 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
provider: &StoredProviderCatalogProvider, provider: &StoredProviderCatalogProvider,
payload: AdminProviderOpsSaveConfigRequest, payload: AdminProviderOpsSaveConfigRequest,
) -> Result<serde_json::Value, String> { ) -> 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(); 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()) { if auth_type.is_empty() || !admin_provider_ops_is_supported_auth_type(auth_type.as_str()) {
return Err("connector.auth_type 必须是合法的认证类型".to_string()); 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( let merged_credentials = admin_provider_ops_merge_credentials(
state, state,
payload.architecture_id.as_str(), architecture_id.as_str(),
provider, provider,
payload.connector.credentials, 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>>(); .collect::<serde_json::Map<String, serde_json::Value>>();
Ok(json!({ Ok(json!({
"architecture_id": payload.architecture_id, "architecture_id": architecture_id,
"base_url": payload.base_url, "base_url": payload.base_url,
"connector": { "connector": {
"auth_type": auth_type, "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 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!({ json!({
"provider_id": provider_id, "provider_id": provider_id,
"is_configured": true, "is_configured": true,
"architecture_id": provider_ops_config "architecture_id": architecture_id,
.get("architecture_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("generic_api"),
"base_url": resolve_admin_provider_ops_base_url( "base_url": resolve_admin_provider_ops_base_url(
provider, provider,
endpoints, endpoints,

View File

@@ -6,7 +6,8 @@ use crate::handlers::admin::provider::ops::providers::config::persist_admin_prov
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::{ use aether_admin::provider::ops::{
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_verify_failure, 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_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider; 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>, credentials: &Map<String, Value>,
proxy_snapshot: Option<&ProxySnapshot>, proxy_snapshot: Option<&ProxySnapshot>,
) -> Value { ) -> 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) = let (access_token, updated_credentials, frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token( match admin_provider_ops_sub2api_exchange_token(
state, 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=...) 的行为: // 对齐 Python httpx.AsyncClient(base_url=...) 的行为:
// 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。 // 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。
pub(in super::super) fn admin_provider_ops_sub2api_request_url( pub(in super::super) fn admin_provider_ops_sub2api_request_url(

View File

@@ -12,6 +12,13 @@ pub(crate) fn admin_provider_id_for_refresh_quota(request_path: &str) -> Option<
.map(ToOwned::to_owned) .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> { pub(crate) fn admin_reveal_key_id(request_path: &str) -> Option<String> {
request_path request_path
.strip_prefix("/api/admin/endpoints/keys/")? .strip_prefix("/api/admin/endpoints/keys/")?

View File

@@ -15,9 +15,9 @@ pub(crate) use self::crud::{
is_admin_providers_root, is_admin_providers_root,
}; };
pub(crate) use self::endpoint_keys::{ pub(crate) use self::endpoint_keys::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys, admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_key_balance,
admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_provider_id_for_keys, admin_provider_id_for_refresh_quota,
admin_update_key_id, admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_update_key_id,
}; };
pub(crate) use self::oauth::{ pub(crate) use self::oauth::{
admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path, admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path,

View File

@@ -31,7 +31,7 @@ pub(crate) async fn build_admin_provider_summary_payload(
active_global_model_ids_result, active_global_model_ids_result,
) = tokio::join!( ) = tokio::join!(
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids), 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.read_provider_quota_snapshot(provider_id),
state.list_provider_model_stats(&provider_ids), state.list_provider_model_stats(&provider_ids),
state.list_active_global_model_ids_by_provider_ids(&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 { } else {
let (endpoints_result, keys_result, model_stats_result, active_global_model_refs_result) = tokio::join!( 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_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_provider_model_stats(&provider_ids),
state.list_active_global_model_ids_by_provider_ids(&provider_ids), state.list_active_global_model_ids_by_provider_ids(&provider_ids),
); );

View File

@@ -7,7 +7,7 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use serde_json::json; use serde_json::{json, Map, Value};
use std::collections::BTreeMap; use std::collections::BTreeMap;
fn json_truthy(value: &serde_json::Value) -> bool { 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) .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( pub(crate) fn build_admin_provider_summary_value(
provider: &StoredProviderCatalogProvider, provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint], endpoints: &[StoredProviderCatalogEndpoint],
@@ -158,6 +232,7 @@ pub(crate) fn build_admin_provider_summary_value(
.and_then(|quota| quota.quota_expires_at_unix_secs) .and_then(|quota| quota.quota_expires_at_unix_secs)
.or(provider.quota_expires_at_unix_secs) .or(provider.quota_expires_at_unix_secs)
.and_then(unix_secs_to_rfc3339); .and_then(unix_secs_to_rfc3339);
let key_balance_summary = latest_key_balance_summary(keys);
json!({ json!({
"id": provider.id.clone(), "id": provider.id.clone(),
@@ -196,6 +271,7 @@ pub(crate) fn build_admin_provider_summary_value(
"endpoint_health_details": endpoint_health_details, "endpoint_health_details": endpoint_health_details,
"ops_configured": ops_configured, "ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id, "ops_architecture_id": ops_architecture_id,
"key_balance_summary": key_balance_summary,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled, "kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs), "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), "updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),

View File

@@ -177,6 +177,21 @@ impl<'a> AdminAppState<'a> {
self.app.update_provider_catalog_key(key).await 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( pub(crate) async fn create_provider_catalog_key(
&self, &self,
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,

View File

@@ -21,6 +21,7 @@ const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] "; const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] "; const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_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( pub(crate) fn provider_catalog_key_supports_format(
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
@@ -173,6 +174,31 @@ pub(crate) fn parse_catalog_auth_config_json(
.cloned() .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 { pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
json!({ json!({
"oauth": { "oauth": {
@@ -1937,7 +1963,7 @@ pub(crate) fn build_admin_provider_key_response(
); );
payload.insert( payload.insert(
"upstream_metadata".to_string(), "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("proxy".to_string(), json!(key.proxy));
payload.insert("fingerprint".to_string(), json!(key.fingerprint)); payload.insert("fingerprint".to_string(), json!(key.fingerprint));

View File

@@ -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("create_endpoint"))
| (Some("endpoints_manage"), http::Method::POST, Some("batch_delete_keys")) | (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("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_key"))
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint")) | (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled")) | (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))

View File

@@ -1,3 +1,4 @@
use super::architectures::normalize_architecture_id;
use super::verify::admin_provider_ops_value_as_f64; use super::verify::admin_provider_ops_value_as_f64;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@@ -13,7 +14,7 @@ pub fn parse_query_balance_payload(
action_config: &Map<String, Value>, action_config: &Map<String, Value>,
response_json: &Value, response_json: &Value,
) -> Result<Value, String> { ) -> Result<Value, String> {
match architecture_id { match normalize_architecture_id(architecture_id) {
"generic_api" | "new_api" | "anyrouter" | "done_hub" => { "generic_api" | "new_api" | "anyrouter" | "done_hub" => {
parse_new_api_balance_payload(action_config, response_json) 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),
&currency,
extra,
))
}
pub fn attach_balance_checkin_outcome( pub fn attach_balance_checkin_outcome(
action_payload: &mut Value, action_payload: &mut Value,
outcome: &ProviderOpsCheckinOutcome, outcome: &ProviderOpsCheckinOutcome,
@@ -171,39 +239,316 @@ fn parse_new_api_balance_payload(
action_config: &Map<String, Value>, action_config: &Map<String, Value>,
response_json: &Value, response_json: &Value,
) -> Result<Value, String> { ) -> Result<Value, String> {
let user_data = if response_json.get("success").and_then(Value::as_bool) == Some(true) let user_data = balance_response_object(response_json)?;
&& response_json.get("data").is_some_and(Value::is_object) let quota_divisor = balance_divisor(action_config);
{ let total_available_raw = balance_value_from_candidates(
response_json.get("data") response_json,
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) { user_data,
return Err(response_json action_config,
.get("message") &[
.and_then(Value::as_str) "balance_path",
.unwrap_or("业务状态码表示失败") "available_path",
.to_string()); "total_available_path",
} else { "quota_path",
Some(response_json) ],
}; &[
let Some(user_data) = user_data.and_then(Value::as_object) else { "total_available",
return Err("响应格式无效".to_string()); "data.total_available",
}; "balance",
let quota_divisor = quota_divisor(action_config); "data.balance",
let total_available = "available",
admin_provider_ops_value_as_f64(user_data.get("quota")).map(|value| value / quota_divisor); "data.available",
let total_used = admin_provider_ops_value_as_f64(user_data.get("used_quota")) "remaining",
.map(|value| value / quota_divisor); "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( Ok(build_balance_data(
None, total_granted_raw.map(|value| value / quota_divisor),
total_used, total_used_raw.map(|value| value / quota_divisor),
total_available, total_available_raw.map(|value| value / quota_divisor),
action_config action_config
.get("currency") .get("currency")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("USD"), .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( fn parse_cubence_balance_payload(
action_config: &Map<String, Value>, action_config: &Map<String, Value>,
response_json: &Value, response_json: &Value,
@@ -414,6 +759,12 @@ fn quota_divisor(action_config: &Map<String, Value>) -> f64 {
.unwrap_or(500000.0) .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> { fn parse_rfc3339_unix_secs(value: Option<&Value>) -> Option<i64> {
let raw = value?.as_str()?.trim(); let raw = value?.as_str()?.trim();
if raw.is_empty() { if raw.is_empty() {
@@ -466,7 +817,8 @@ fn parse_subscription(value: &Value) -> Option<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, ProviderOpsCheckinOutcome,
}; };
use serde_json::json; use serde_json::json;
@@ -490,6 +842,109 @@ mod tests {
assert_eq!(payload["total_used"], json!(1.0)); 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] #[test]
fn done_hub_single_request_parser_reads_wrapped_quota() { fn done_hub_single_request_parser_reads_wrapped_quota() {
let payload = parse_query_balance_payload( let payload = parse_query_balance_payload(
@@ -540,6 +995,28 @@ mod tests {
assert_eq!(payload["extra"]["active_subscriptions"], json!(2)); 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] #[test]
fn cubence_parser_reads_wrapped_dashboard_overview() { fn cubence_parser_reads_wrapped_dashboard_overview() {
let payload = parse_query_balance_payload( let payload = parse_query_balance_payload(

View File

@@ -119,12 +119,19 @@ pub fn get_architecture(architecture_id: &str) -> Option<ProviderOpsArchitecture
} }
pub fn normalize_architecture_id(architecture_id: &str) -> &'static str { 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" => "generic_api", "genericapi" => "generic_api",
"new_api" => "new_api", "newapi" | "oneapi" => "new_api",
"cubence" => "cubence", "cubence" => "cubence",
"done_hub" => "done_hub", "donehub" => "done_hub",
"yescode" => "yescode", "yescode" => "yescode",
"nekocode" => "nekocode", "nekocode" => "nekocode",
"anyrouter" => "anyrouter", "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 { pub fn admin_provider_ops_is_supported_auth_type(auth_type: &str) -> bool {
matches!( matches!(
auth_type, 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(""), "generic_api");
assert_eq!(normalize_architecture_id("done_hub"), "done_hub"); assert_eq!(normalize_architecture_id("done_hub"), "done_hub");
assert_eq!(normalize_architecture_id("new_api"), "new_api"); 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"); assert_eq!(normalize_architecture_id("unknown"), "generic_api");
} }

View File

@@ -10,8 +10,8 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
"properties": { "properties": {
"api_key": { "api_key": {
"type": "string", "type": "string",
"title": "访问令牌 (API Key)", "title": "访问令牌",
"description": "New API 的访问令牌,与 Cookie 二选一", "description": "New API 个人安全设置中获取的访问令牌,与 Cookie 二选一",
"x-sensitive": true, "x-sensitive": true,
"x-input-type": "password" "x-input-type": "password"
}, },
@@ -30,7 +30,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
"user_id": { "user_id": {
"type": "string", "type": "string",
"title": "用户 ID", "title": "用户 ID",
"description": "使用访问令牌时必填,使用 Cookie 时可" "description": "可选;使用 Cookie 时可自动解析"
} }
}, },
"required": [], "required": [],
@@ -64,13 +64,6 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
"type": "any_required", "type": "any_required",
"fields": ["api_key", "cookie"], "fields": ["api_key", "cookie"],
"message": "访问令牌和 Cookie 至少需要填写一个" "message": "访问令牌和 Cookie 至少需要填写一个"
},
{
"type": "conditional_required",
"if": "api_key",
"then": ["user_id"],
"unless": "cookie",
"message": "使用访问令牌时,用户 ID 不能为空"
} }
] ]
}); });

View File

@@ -5,6 +5,38 @@ use super::{
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
pub(super) fn spec() -> ProviderOpsArchitectureSpec { 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!({ let session_login_schema = json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -61,7 +93,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
}, },
"required": ["refresh_token"], "required": ["refresh_token"],
"x-auth-method": "bearer", "x-auth-method": "bearer",
"x-auth-type": "api_key", "x-auth-type": "refresh_token",
"x-field-groups": [ "x-field-groups": [
{ "fields": ["base_url"] }, { "fields": ["base_url"] },
{ "fields": ["refresh_token"] } { "fields": ["refresh_token"] }
@@ -80,20 +112,25 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
display_name: "Sub2API", display_name: "Sub2API",
description: "Sub2API 风格中转站的预设配置", description: "Sub2API 风格中转站的预设配置",
hidden: false, 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_endpoint: "/api/v1/auth/me?timezone=Asia/Shanghai",
verify_mode: ProviderOpsVerifyMode::Sub2ApiExchange, verify_mode: ProviderOpsVerifyMode::Sub2ApiExchange,
balance_mode: ProviderOpsBalanceMode::Sub2ApiDualRequest, balance_mode: ProviderOpsBalanceMode::Sub2ApiDualRequest,
checkin_mode: ProviderOpsCheckinMode::None, checkin_mode: ProviderOpsCheckinMode::None,
query_balance_cookie_auth_errors: false, query_balance_cookie_auth_errors: false,
supported_auth_types: vec![ supported_auth_types: vec![
ProviderOpsAuthSpec {
auth_type: "api_key",
display_name: "API Key 用量接口",
credentials_schema: api_key_usage_schema,
},
ProviderOpsAuthSpec { ProviderOpsAuthSpec {
auth_type: "session_login", auth_type: "session_login",
display_name: "账号密码", display_name: "账号密码",
credentials_schema: session_login_schema, credentials_schema: session_login_schema,
}, },
ProviderOpsAuthSpec { ProviderOpsAuthSpec {
auth_type: "api_key", auth_type: "refresh_token",
display_name: "Refresh Token", display_name: "Refresh Token",
credentials_schema: refresh_token_schema, credentials_schema: refresh_token_schema,
}, },
@@ -101,7 +138,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
supported_actions: vec![ProviderOpsActionSpec { supported_actions: vec![ProviderOpsActionSpec {
action_type: "query_balance", action_type: "query_balance",
display_name: "查询余额", display_name: "查询余额",
description: "查询 Sub2API 账户余额和订阅信息", description: "查询 Sub2API API Key 用量或账户余额和订阅信息",
config_schema: json!({ config_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -114,7 +151,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
"required": [] "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!({ "query_balance" => Some(json_object(json!({
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai", "endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
"subscription_endpoint": "/api/v1/subscriptions/summary", "subscription_endpoint": "/api/v1/subscriptions/summary",
"api_key_usage_endpoint": "/v1/usage",
"method": "GET", "method": "GET",
"currency": "USD" "currency": "USD"
}))), }))),

View File

@@ -4,7 +4,8 @@ pub mod config;
pub mod verify; pub mod verify;
pub use self::actions::{ 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, parse_yescode_combined_balance_payload, ProviderOpsCheckinOutcome,
}; };
pub use self::architectures::{ pub use self::architectures::{

View File

@@ -379,18 +379,9 @@ pub fn admin_provider_ops_verify_headers(
} }
"new_api" => { "new_api" => {
for (name, value) in [ for (name, value) in [
( ("User-Agent", "cc-switch/1.0"),
"User-Agent", ("Content-Type", "application/json"),
"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",
),
("Accept", "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)?; insert_header(&mut headers, name, value)?;
} }

View File

@@ -286,6 +286,74 @@ export async function refreshProviderQuota(
return response.data 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 凭据(通用) * 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro * 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro

View File

@@ -402,12 +402,46 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null 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 { export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata kiro?: KiroUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata grok?: GrokUpstreamMetadata
balance_query?: BalanceQueryUpstreamMetadata
} }
// 按格式的健康度数据 // 按格式的健康度数据
@@ -684,6 +718,7 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等) ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
key_balance_summary?: ProviderKeyBalanceSummary | null
kiro_simulated_cache_enabled?: boolean kiro_simulated_cache_enabled?: boolean
created_at: string created_at: string
updated_at: string updated_at: string

View File

@@ -12,7 +12,13 @@ import client from './client'
// ==================== Types ==================== // ==================== 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 = export type ProviderActionType =

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
<template> <template>
<Dialog <Dialog
:open="open" :open="open"
title="用户认证" :title="dialogTitle"
description="配置提供商的用户认证信息,用于余额查询、签到等操作" description="独立配置上游余额/用量查询凭据,不影响模型调用 Key"
:icon="KeyRound" :icon="KeyRound"
size="md" size="4xl"
@update:open="$emit('update:open', $event)" @update:open="$emit('update:open', $event)"
> >
<form <form
@@ -23,38 +23,30 @@
</div> </div>
<div <div
v-else v-else
class="space-y-4" class="space-y-5"
> >
<!-- 认证模板 + 认证方式并排 --> <div class="space-y-2">
<div class="flex gap-3"> <div class="flex items-center justify-between gap-3">
<div <Label>预设模板</Label>
class="space-y-2" <span class="text-xs text-muted-foreground">留空则自动使用供应商配置</span>
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }" </div>
> <div class="flex flex-wrap items-center gap-2">
<Label>认证模板</Label> <button
<Select v-for="arch in architectures"
v-model="selectedArchitectureId" :key="arch.architecture_id"
@update:model-value="handleArchitectureChange" type="button"
> class="h-8 rounded-md border px-3 text-xs font-medium transition-colors"
<SelectTrigger> :class="selectedArchitectureId === arch.architecture_id
<SelectValue placeholder="选择认证模板" /> ? 'border-primary bg-primary text-primary-foreground shadow-sm'
</SelectTrigger> : 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'"
<SelectContent> @click="selectArchitecturePreset(arch.architecture_id)"
<SelectItem >
v-for="arch in architectures" {{ formatArchitectureLabel(arch) }}
:key="arch.architecture_id" </button>
:value="arch.architecture_id"
>
{{ arch.display_name }}
</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div <div
v-if="currentAuthTypes.length > 1" v-if="currentAuthTypes.length > 1"
class="space-y-2" class="grid gap-2 sm:max-w-xs"
style="flex: 1"
> >
<Label>认证方式</Label> <Label>认证方式</Label>
<Select <Select
@@ -77,6 +69,15 @@
</div> </div>
</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 v-if="currentSchema">
<template <template
@@ -262,13 +263,23 @@
:disabled="isVerifying || !canVerify" :disabled="isVerifying || !canVerify"
@click="handleVerify" @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>
<Button <Button
:disabled="isSaving || !canSave" variant="outline"
@click="handleSave" :disabled="isSaving || isVerifying"
@click="handleFormat"
> >
{{ isSaving ? '保存中...' : '保存' }} <Wand2 class="h-3.5 w-3.5" />
格式化
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -276,6 +287,20 @@
> >
取消 取消
</Button> </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>
</div> </div>
</template> </template>
@@ -284,7 +309,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { KeyRound } from 'lucide-vue-next' import { KeyRound, Loader2, Play, Save, Wand2 } from 'lucide-vue-next'
import { import {
Dialog, Dialog,
Button, Button,
@@ -325,6 +350,7 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
providerId: string providerId: string
providerName?: string
providerWebsite?: string providerWebsite?: string
currentConfig?: Record<string, unknown> | null currentConfig?: Record<string, unknown> | null
}>() }>()
@@ -376,6 +402,12 @@ const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('') const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({}) const formData = ref<Record<string, unknown>>({})
const dialogTitle = computed(() => (
props.providerName
? `配置用量查询 - ${props.providerName}`
: '配置用量查询'
))
// 当前架构支持的认证方式 // 当前架构支持的认证方式
const currentAuthTypes = computed(() => { const currentAuthTypes = computed(() => {
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value) const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
@@ -441,6 +473,26 @@ function handleArchitectureChange() {
formChanged.value = true 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() { function handleAuthTypeChange() {
resetFormData() resetFormData()
verifyStatus.value = null verifyStatus.value = null
@@ -478,7 +530,9 @@ function resetFormData() {
// 初始化表单数据 // 初始化表单数据
const data: Record<string, unknown> = {} const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) { 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 data.proxy_enabled = false
@@ -495,6 +549,22 @@ function formatQuota(quota: number): string {
return quota.toLocaleString() 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() { async function handleVerify() {
const schema = currentSchema.value const schema = currentSchema.value
if (!schema) return if (!schema) return
@@ -677,6 +747,10 @@ function loadFromConfig(config: Record<string, unknown>) {
if (!config?.connector) return if (!config?.connector) return
hasExistingConfig.value = true hasExistingConfig.value = true
const connector = config.connector as {
auth_type?: string
credentials?: Record<string, unknown>
}
// 根据已保存的 architecture_id 选择对应架构 // 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api' const architectureId = config.architecture_id || 'new_api'
@@ -684,7 +758,15 @@ function loadFromConfig(config: Record<string, unknown>) {
selectedArchitectureId.value = archExists ? architectureId : 'new_api' selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择 // 从已保存的 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 const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) { if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = 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> </script>

View File

@@ -1,15 +1,7 @@
<template> <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 查询的余额 --> <!-- 显示从上游 API 查询的余额 -->
<div <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" class="flex items-center gap-2 text-xs"
> >
<!-- 余额文字balance + points 分开显示或普通余额 --> <!-- 余额文字balance + points 分开显示或普通余额 -->
@@ -95,6 +87,35 @@
</div> </div>
</div> </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 <div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)" v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -128,11 +149,18 @@
</template> </template>
<script setup lang="ts"> <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 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 { formatBillingType } from '@/utils/format'
import type { BalanceExtraItem } from '@/features/providers/auth-templates' import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
toFiniteNumber,
} from '@/features/providers/utils/keyBalanceSummary'
defineProps<{ defineProps<{
provider: ProviderWithEndpointsSummary provider: ProviderWithEndpointsSummary
@@ -147,4 +175,19 @@ defineProps<{
formatResetCountdown: (resetsAt: number) => string formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => 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> </script>

View File

@@ -555,6 +555,64 @@
</Button> </Button>
</div> </div>
</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 上游额度信息(仅当有元数据时显示) --> <!-- Codex 上游额度信息(仅当有元数据时显示) -->
<div <div
v-if="hasCodexQuotaDisplayData(key)" v-if="hasCodexQuotaDisplayData(key)"
@@ -1217,7 +1275,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue' import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
import { import {
Plus, Plus,
Key, Key,
@@ -1236,6 +1294,7 @@ import {
ShieldX, ShieldX,
Globe, Globe,
GitBranch, GitBranch,
WalletCards,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey' import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1282,10 +1341,12 @@ import {
exportKey, exportKey,
refreshProviderOAuth, refreshProviderOAuth,
refreshProviderQuota, refreshProviderQuota,
queryProviderKeyBalance,
clearOAuthInvalid, clearOAuthInvalid,
type ProviderEndpoint, type ProviderEndpoint,
type EndpointAPIKey, type EndpointAPIKey,
type Model, type Model,
type ProviderKeyBalanceQuery,
API_FORMAT_ORDER, API_FORMAT_ORDER,
sortApiFormats, sortApiFormats,
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -1330,6 +1391,17 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
rpm_limit?: number 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 { interface Props {
providerId: string | null providerId: string | null
open: boolean open: boolean
@@ -1365,6 +1437,8 @@ let keysLoadRequestId = 0
let mappingPreviewLoadRequestId = 0 let mappingPreviewLoadRequestId = 0
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3 const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4 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 { function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom' return (providerType || '').trim().toLowerCase() === 'custom'
@@ -1388,6 +1462,7 @@ const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false) const deleteKeyConfirmOpen = ref(false)
const keyToDelete = ref<EndpointAPIKey | null>(null) const keyToDelete = ref<EndpointAPIKey | null>(null)
const togglingKeyId = ref<string | null>(null) const togglingKeyId = ref<string | null>(null)
const refreshingBalanceKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥 // 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map()) const revealedKeys = ref<Map<string, string>>(new Map())
@@ -1570,6 +1645,7 @@ watch(
// 仅在抽屉刚打开时启动倒计时 // 仅在抽屉刚打开时启动倒计时
if (newOpen && !oldOpen) { if (newOpen && !oldOpen) {
startCountdownTimer() startCountdownTimer()
startKeyBalanceAutoRefreshTimer()
} }
void endpointsPromise.then(() => autoRefreshQuotaInBackground()) void endpointsPromise.then(() => autoRefreshQuotaInBackground())
} else if (!newOpen && oldOpen) { } else if (!newOpen && oldOpen) {
@@ -1581,6 +1657,7 @@ watch(
// 停止倒计时定时器 // 停止倒计时定时器
stopCountdownTimer() stopCountdownTimer()
stopKeyBalanceAutoRefreshTimer()
// 重置所有状态 // 重置所有状态
loading.value = false loading.value = false
provider.value = null 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) { function handleKeyPermissions(key: EndpointAPIKey) {
editingKey.value = key editingKey.value = key
keyPermissionsDialogOpen.value = true keyPermissionsDialogOpen.value = true
@@ -2698,7 +2936,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
} }
async function handleKeyChanged() { async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()]) await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
emit('refresh') emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空) // 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground({ ignoreCooldown: true }) void autoRefreshQuotaInBackground({ ignoreCooldown: true })
@@ -3169,6 +3407,58 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
return hasAntigravityQuotaData(key.upstream_metadata) 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 { function formatUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return '' if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -3685,6 +3975,7 @@ async function loadProviderKeysPage(page = currentKeyPage.value) {
currentKeyPage.value = Math.min(result.page, nextTotalPages) currentKeyPage.value = Math.min(result.page, nextTotalPages)
keyPageSize.value = result.page_size keyPageSize.value = result.page_size
syncCurrentSelections(endpoints.value, result.keys) syncCurrentSelections(endpoints.value, result.keys)
void refreshDueKeyBalances()
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
providerKeys.value = [] providerKeys.value = []
@@ -3782,6 +4073,10 @@ useEscapeKey(() => {
disableOnInput: true, disableOnInput: true,
once: false once: false
}) })
onUnmounted(() => {
stopKeyBalanceAutoRefreshTimer()
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -93,7 +93,7 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="扩展操作配置" title="配置用量查询"
@click="$emit('openOpsConfig', provider)" @click="$emit('openOpsConfig', provider)"
> >
<KeyRound class="h-3.5 w-3.5" /> <KeyRound class="h-3.5 w-3.5" />
@@ -125,17 +125,9 @@
> >
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }} {{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge> </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 查询 --> <!-- 余额从上游 API 查询 -->
<span <span
v-else-if="provider.ops_configured && getProviderBalance(provider.id)" v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground" class="text-muted-foreground"
> >
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span> 余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
@@ -157,6 +149,29 @@
:title="getProviderCheckin(provider.id)?.message" :title="getProviderCheckin(provider.id)?.message"
>签到失败</span> >签到失败</span>
</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 <span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)" v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -233,13 +248,20 @@ import {
Check, Check,
X, X,
Loader2, Loader2,
WalletCards,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.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 { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus' import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils' import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
} from '@/features/providers/utils/keyBalanceSummary'
const props = defineProps<{ const props = defineProps<{
provider: ProviderWithEndpointsSummary provider: ProviderWithEndpointsSummary
@@ -307,4 +329,19 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' { function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号' 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> </script>

View File

@@ -163,7 +163,7 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground" class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="扩展操作配置" title="配置用量查询"
@click="$emit('openOpsConfig', provider)" @click="$emit('openOpsConfig', provider)"
> >
<KeyRound class="h-3.5 w-3.5" /> <KeyRound class="h-3.5 w-3.5" />

View 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}天前`
}

View File

@@ -295,6 +295,7 @@
<ProviderAuthDialog <ProviderAuthDialog
v-model:open="opsConfigDialogOpen" v-model:open="opsConfigDialogOpen"
:provider-id="opsConfigProviderId" :provider-id="opsConfigProviderId"
:provider-name="opsConfigProviderName"
:provider-website="opsConfigProviderWebsite" :provider-website="opsConfigProviderWebsite"
@saved="handleOpsConfigSaved" @saved="handleOpsConfigSaved"
/> />
@@ -325,6 +326,7 @@ import { useProviderBalance } from '@/features/providers/composables/useProvider
import { import {
getProvidersSummary, getProvidersSummary,
getProvider, getProvider,
getProviderEndpoints,
deleteProvider, deleteProvider,
getProviderDeleteTask, getProviderDeleteTask,
updateProvider, updateProvider,
@@ -517,6 +519,7 @@ const {
// 扩展操作配置对话框 // 扩展操作配置对话框
const opsConfigDialogOpen = ref(false) const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('') const opsConfigProviderId = ref('')
const opsConfigProviderName = ref('')
const opsConfigProviderWebsite = 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 opsConfigProviderId.value = provider.id
opsConfigProviderName.value = provider.name
opsConfigProviderWebsite.value = provider.website || '' opsConfigProviderWebsite.value = provider.website || ''
opsConfigDialogOpen.value = true 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 {
// 保持空地址,弹窗内仍可手动填写。
}
}
} }
// 扩展操作配置保存回调 // 扩展操作配置保存回调