mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
支持 Provider Key 余额查询与自动刷新
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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(&[]);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
"无权限访问"
|
"无权限访问"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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/")?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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),
|
||||||
|
¤cy,
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 不能为空"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}))),
|
}))),
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal file
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ProviderKeyBalanceSummary } from '@/api/endpoints'
|
||||||
|
|
||||||
|
export function toFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasKeyBalanceSummary(summary: ProviderKeyBalanceSummary | null | undefined): summary is ProviderKeyBalanceSummary {
|
||||||
|
if (!summary) return false
|
||||||
|
const updatedAt = toFiniteNumber(summary.updated_at)
|
||||||
|
if (updatedAt === null) return false
|
||||||
|
return toFiniteNumber(summary.total_available) !== null
|
||||||
|
|| toFiniteNumber(summary.total_used) !== null
|
||||||
|
|| toFiniteNumber(summary.total_granted) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
|
||||||
|
const numberValue = toFiniteNumber(value)
|
||||||
|
if (numberValue === null) return '未知'
|
||||||
|
const normalizedCurrency = (currency || 'USD').toUpperCase()
|
||||||
|
const prefix = normalizedCurrency === 'USD'
|
||||||
|
? '$'
|
||||||
|
: normalizedCurrency === 'CNY'
|
||||||
|
? '¥'
|
||||||
|
: `${normalizedCurrency} `
|
||||||
|
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
|
||||||
|
return `${prefix}${numberValue.toFixed(decimals)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyBalanceTemplateLabel(architectureId: unknown): string {
|
||||||
|
const normalized = String(architectureId || '').trim().toLowerCase().replace(/-/g, '_')
|
||||||
|
if (normalized === 'newapi' || normalized === 'new_api') return 'NewAPI'
|
||||||
|
if (normalized === 'sub2api') return 'Sub2API'
|
||||||
|
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return '自定义'
|
||||||
|
return normalized || '余额查询'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKeyBalanceUpdatedAt(updatedAt: unknown): string {
|
||||||
|
const timestamp = toFiniteNumber(updatedAt)
|
||||||
|
if (timestamp === null || timestamp <= 0) return ''
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const diff = now - timestamp
|
||||||
|
if (diff <= 60) return '刚刚更新'
|
||||||
|
const minutes = Math.floor(diff / 60)
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}天前`
|
||||||
|
}
|
||||||
@@ -295,6 +295,7 @@
|
|||||||
<ProviderAuthDialog
|
<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 {
|
||||||
|
// 保持空地址,弹窗内仍可手动填写。
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扩展操作配置保存回调
|
// 扩展操作配置保存回调
|
||||||
|
|||||||
Reference in New Issue
Block a user