mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Revert "Merge pull request #517 from zhiqicloud/feat/provider-balance-query"
This reverts commit7e95e769d5, reversing changes made to490306c242.
This commit is contained in:
@@ -177,17 +177,6 @@ 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,20 +380,6 @@ 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(&[]);
|
||||||
@@ -413,25 +399,6 @@ 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,4 +1,3 @@
|
|||||||
mod balance;
|
|
||||||
mod mutations;
|
mod mutations;
|
||||||
mod quota;
|
mod quota;
|
||||||
mod reads;
|
mod reads;
|
||||||
@@ -19,10 +18,6 @@ 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,68 +32,6 @@ 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,14 +111,11 @@ 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 new_api_token_auth {
|
if cookie_auth {
|
||||||
"访问令牌无效,请使用 New API 个人安全设置里的访问令牌"
|
|
||||||
} else if cookie_auth {
|
|
||||||
"Cookie 已失效,请重新配置"
|
"Cookie 已失效,请重新配置"
|
||||||
} else {
|
} else {
|
||||||
"认证失败"
|
"认证失败"
|
||||||
@@ -128,9 +125,7 @@ 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 new_api_token_auth {
|
if cookie_auth {
|
||||||
"访问令牌无效或无权限,请使用 New API 个人安全设置里的访问令牌"
|
|
||||||
} else if cookie_auth {
|
|
||||||
"Cookie 已失效或无权限"
|
"Cookie 已失效或无权限"
|
||||||
} else {
|
} else {
|
||||||
"无权限访问"
|
"无权限访问"
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ 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::{
|
use aether_admin::provider::ops::parse_sub2api_balance_payload;
|
||||||
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};
|
||||||
@@ -26,23 +24,6 @@ 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,
|
||||||
@@ -210,117 +191,6 @@ 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,9 +250,6 @@ 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());
|
||||||
@@ -260,7 +257,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,
|
||||||
architecture_id.as_str(),
|
payload.architecture_id.as_str(),
|
||||||
provider,
|
provider,
|
||||||
payload.connector.credentials,
|
payload.connector.credentials,
|
||||||
);
|
);
|
||||||
@@ -281,7 +278,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": architecture_id,
|
"architecture_id": payload.architecture_id,
|
||||||
"base_url": payload.base_url,
|
"base_url": payload.base_url,
|
||||||
"connector": {
|
"connector": {
|
||||||
"auth_type": auth_type,
|
"auth_type": auth_type,
|
||||||
@@ -331,16 +328,14 @@ 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": architecture_id,
|
"architecture_id": provider_ops_config
|
||||||
|
.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,8 +6,7 @@ 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,
|
||||||
admin_provider_ops_verify_success, admin_provider_ops_verify_user_payload,
|
parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
|
||||||
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;
|
||||||
@@ -22,21 +21,6 @@ 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,
|
||||||
@@ -109,78 +93,6 @@ 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,13 +12,6 @@ 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_key_balance,
|
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
|
||||||
admin_provider_id_for_keys, admin_provider_id_for_refresh_quota,
|
admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id,
|
||||||
admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_update_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_keys_by_provider_ids(&provider_ids),
|
state.list_provider_catalog_key_summaries_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_keys_by_provider_ids(&provider_ids),
|
state.list_provider_catalog_key_summaries_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, Map, Value};
|
use serde_json::json;
|
||||||
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,80 +27,6 @@ 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],
|
||||||
@@ -232,7 +158,6 @@ 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(),
|
||||||
@@ -271,7 +196,6 @@ 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,21 +177,6 @@ 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,7 +21,6 @@ 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,
|
||||||
@@ -174,31 +173,6 @@ 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": {
|
||||||
@@ -1963,7 +1937,7 @@ pub(crate) fn build_admin_provider_key_response(
|
|||||||
);
|
);
|
||||||
payload.insert(
|
payload.insert(
|
||||||
"upstream_metadata".to_string(),
|
"upstream_metadata".to_string(),
|
||||||
sanitized_admin_upstream_metadata(key.upstream_metadata.as_ref()),
|
json!(key.upstream_metadata),
|
||||||
);
|
);
|
||||||
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,7 +221,6 @@ 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"))
|
||||||
|
|||||||
@@ -1490,24 +1490,7 @@ async fn gateway_verifies_admin_provider_ops_locally_for_new_api_with_trusted_ad
|
|||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
Some("session=foo")
|
Some("session=foo")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(headers.contains_key("sec-ch-ua"));
|
||||||
headers
|
|
||||||
.get(axum::http::header::USER_AGENT)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some("cc-switch/1.0")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
headers
|
|
||||||
.get(axum::http::header::CONTENT_TYPE)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some("application/json")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
headers
|
|
||||||
.get(axum::http::header::ACCEPT)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some("application/json")
|
|
||||||
);
|
|
||||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||||
encoder
|
encoder
|
||||||
.write_all(
|
.write_all(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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};
|
||||||
|
|
||||||
@@ -14,7 +13,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 normalize_architecture_id(architecture_id) {
|
match 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)
|
||||||
}
|
}
|
||||||
@@ -114,73 +113,6 @@ 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,
|
||||||
@@ -239,316 +171,39 @@ 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 = balance_response_object(response_json)?;
|
let user_data = if response_json.get("success").and_then(Value::as_bool) == Some(true)
|
||||||
let quota_divisor = balance_divisor(action_config);
|
&& response_json.get("data").is_some_and(Value::is_object)
|
||||||
let total_available_raw = balance_value_from_candidates(
|
{
|
||||||
response_json,
|
response_json.get("data")
|
||||||
user_data,
|
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||||
action_config,
|
return Err(response_json
|
||||||
&[
|
.get("message")
|
||||||
"balance_path",
|
.and_then(Value::as_str)
|
||||||
"available_path",
|
.unwrap_or("业务状态码表示失败")
|
||||||
"total_available_path",
|
.to_string());
|
||||||
"quota_path",
|
} else {
|
||||||
],
|
Some(response_json)
|
||||||
&[
|
};
|
||||||
"total_available",
|
let Some(user_data) = user_data.and_then(Value::as_object) else {
|
||||||
"data.total_available",
|
return Err("响应格式无效".to_string());
|
||||||
"balance",
|
};
|
||||||
"data.balance",
|
let quota_divisor = quota_divisor(action_config);
|
||||||
"available",
|
let total_available =
|
||||||
"data.available",
|
admin_provider_ops_value_as_f64(user_data.get("quota")).map(|value| value / quota_divisor);
|
||||||
"remaining",
|
let total_used = admin_provider_ops_value_as_f64(user_data.get("used_quota"))
|
||||||
"data.remaining",
|
.map(|value| value / quota_divisor);
|
||||||
"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(
|
||||||
total_granted_raw.map(|value| value / quota_divisor),
|
None,
|
||||||
total_used_raw.map(|value| value / quota_divisor),
|
total_used,
|
||||||
total_available_raw.map(|value| value / quota_divisor),
|
total_available,
|
||||||
action_config
|
action_config
|
||||||
.get("currency")
|
.get("currency")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("USD"),
|
.unwrap_or("USD"),
|
||||||
extra,
|
Map::new(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -759,12 +414,6 @@ 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() {
|
||||||
@@ -817,8 +466,7 @@ 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,
|
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
|
||||||
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
|
|
||||||
ProviderOpsCheckinOutcome,
|
ProviderOpsCheckinOutcome,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -842,109 +490,6 @@ 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(
|
||||||
@@ -995,28 +540,6 @@ 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,19 +119,12 @@ 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 {
|
||||||
let compact = architecture_id
|
match architecture_id.trim() {
|
||||||
.trim()
|
|
||||||
.chars()
|
|
||||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
|
||||||
.collect::<String>()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
match compact.as_str() {
|
|
||||||
"" => "generic_api",
|
"" => "generic_api",
|
||||||
"genericapi" => "generic_api",
|
"generic_api" => "generic_api",
|
||||||
"newapi" | "oneapi" => "new_api",
|
"new_api" => "new_api",
|
||||||
"cubence" => "cubence",
|
"cubence" => "cubence",
|
||||||
"donehub" => "done_hub",
|
"done_hub" => "done_hub",
|
||||||
"yescode" => "yescode",
|
"yescode" => "yescode",
|
||||||
"nekocode" => "nekocode",
|
"nekocode" => "nekocode",
|
||||||
"anyrouter" => "anyrouter",
|
"anyrouter" => "anyrouter",
|
||||||
@@ -143,7 +136,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" | "refresh_token" | "session_login" | "oauth" | "cookie" | "none"
|
"api_key" | "session_login" | "oauth" | "cookie" | "none"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +220,6 @@ 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": "访问令牌",
|
"title": "访问令牌 (API Key)",
|
||||||
"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,6 +64,13 @@ 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,38 +5,6 @@ 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": {
|
||||||
@@ -93,7 +61,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
|||||||
},
|
},
|
||||||
"required": ["refresh_token"],
|
"required": ["refresh_token"],
|
||||||
"x-auth-method": "bearer",
|
"x-auth-method": "bearer",
|
||||||
"x-auth-type": "refresh_token",
|
"x-auth-type": "api_key",
|
||||||
"x-field-groups": [
|
"x-field-groups": [
|
||||||
{ "fields": ["base_url"] },
|
{ "fields": ["base_url"] },
|
||||||
{ "fields": ["refresh_token"] }
|
{ "fields": ["refresh_token"] }
|
||||||
@@ -112,25 +80,20 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
|||||||
display_name: "Sub2API",
|
display_name: "Sub2API",
|
||||||
description: "Sub2API 风格中转站的预设配置",
|
description: "Sub2API 风格中转站的预设配置",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
credentials_schema: api_key_usage_schema.clone(),
|
credentials_schema: session_login_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: "refresh_token",
|
auth_type: "api_key",
|
||||||
display_name: "Refresh Token",
|
display_name: "Refresh Token",
|
||||||
credentials_schema: refresh_token_schema,
|
credentials_schema: refresh_token_schema,
|
||||||
},
|
},
|
||||||
@@ -138,7 +101,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 API Key 用量或账户余额和订阅信息",
|
description: "查询 Sub2API 账户余额和订阅信息",
|
||||||
config_schema: json!({
|
config_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -151,7 +114,7 @@ pub(super) fn spec() -> ProviderOpsArchitectureSpec {
|
|||||||
"required": []
|
"required": []
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
default_connector: Some("api_key"),
|
default_connector: Some("session_login"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +123,6 @@ 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,8 +4,7 @@ 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,
|
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_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,9 +379,18 @@ 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"),
|
(
|
||||||
("Content-Type", "application/json"),
|
"User-Agent",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36",
|
||||||
|
),
|
||||||
("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,74 +286,6 @@ 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,46 +402,12 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按格式的健康度数据
|
// 按格式的健康度数据
|
||||||
@@ -718,7 +684,6 @@ 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,13 +12,7 @@ import client from './client'
|
|||||||
// ==================== Types ====================
|
// ==================== Types ====================
|
||||||
|
|
||||||
/** 认证类型 */
|
/** 认证类型 */
|
||||||
export type ConnectorAuthType =
|
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
|
||||||
| '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="dialogTitle"
|
title="用户认证"
|
||||||
description="独立配置上游余额/用量查询凭据,不影响模型调用 Key"
|
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
|
||||||
:icon="KeyRound"
|
:icon="KeyRound"
|
||||||
size="4xl"
|
size="md"
|
||||||
@update:open="$emit('update:open', $event)"
|
@update:open="$emit('update:open', $event)"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
@@ -23,30 +23,38 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="space-y-5"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<!-- 认证模板 + 认证方式(并排) -->
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex gap-3">
|
||||||
<Label>预设模板</Label>
|
<div
|
||||||
<span class="text-xs text-muted-foreground">留空则自动使用供应商配置</span>
|
class="space-y-2"
|
||||||
</div>
|
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }"
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
>
|
||||||
<button
|
<Label>认证模板</Label>
|
||||||
v-for="arch in architectures"
|
<Select
|
||||||
:key="arch.architecture_id"
|
v-model="selectedArchitectureId"
|
||||||
type="button"
|
@update:model-value="handleArchitectureChange"
|
||||||
class="h-8 rounded-md border px-3 text-xs font-medium transition-colors"
|
|
||||||
:class="selectedArchitectureId === arch.architecture_id
|
|
||||||
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
|
||||||
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'"
|
|
||||||
@click="selectArchitecturePreset(arch.architecture_id)"
|
|
||||||
>
|
>
|
||||||
{{ formatArchitectureLabel(arch) }}
|
<SelectTrigger>
|
||||||
</button>
|
<SelectValue placeholder="选择认证模板" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="arch in architectures"
|
||||||
|
:key="arch.architecture_id"
|
||||||
|
:value="arch.architecture_id"
|
||||||
|
>
|
||||||
|
{{ arch.display_name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="currentAuthTypes.length > 1"
|
v-if="currentAuthTypes.length > 1"
|
||||||
class="grid gap-2 sm:max-w-xs"
|
class="space-y-2"
|
||||||
|
style="flex: 1"
|
||||||
>
|
>
|
||||||
<Label>认证方式</Label>
|
<Label>认证方式</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -69,15 +77,6 @@
|
|||||||
</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
|
||||||
@@ -263,23 +262,13 @@
|
|||||||
:disabled="isVerifying || !canVerify"
|
:disabled="isVerifying || !canVerify"
|
||||||
@click="handleVerify"
|
@click="handleVerify"
|
||||||
>
|
>
|
||||||
<Loader2
|
{{ isVerifying ? '验证中...' : '验证' }}
|
||||||
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
|
||||||
variant="outline"
|
:disabled="isSaving || !canSave"
|
||||||
:disabled="isSaving || isVerifying"
|
@click="handleSave"
|
||||||
@click="handleFormat"
|
|
||||||
>
|
>
|
||||||
<Wand2 class="h-3.5 w-3.5" />
|
{{ isSaving ? '保存中...' : '保存' }}
|
||||||
格式化
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -287,20 +276,6 @@
|
|||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</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>
|
||||||
@@ -309,7 +284,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, Loader2, Play, Save, Wand2 } from 'lucide-vue-next'
|
import { KeyRound } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
@@ -350,7 +325,6 @@ 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
|
||||||
}>()
|
}>()
|
||||||
@@ -402,12 +376,6 @@ 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)
|
||||||
@@ -473,26 +441,6 @@ 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
|
||||||
@@ -530,9 +478,7 @@ 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] = key === 'base_url'
|
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
|
||||||
? (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
|
||||||
@@ -549,22 +495,6 @@ 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
|
||||||
@@ -747,10 +677,6 @@ 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'
|
||||||
@@ -758,15 +684,7 @@ function loadFromConfig(config: Record<string, unknown>) {
|
|||||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||||
|
|
||||||
// 从已保存的 connector auth_type 恢复认证方式选择
|
// 从已保存的 connector auth_type 恢复认证方式选择
|
||||||
let savedAuthType = connector?.auth_type
|
const savedAuthType = config.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
|
||||||
@@ -857,14 +775,4 @@ 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,7 +1,15 @@
|
|||||||
<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-if="provider.ops_configured && getProviderBalance(provider.id)"
|
v-else-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 分开显示,或普通余额 -->
|
||||||
@@ -87,35 +95,6 @@
|
|||||||
</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)"
|
||||||
@@ -149,18 +128,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Loader2, WalletCards } from 'lucide-vue-next'
|
import { Loader2 } from 'lucide-vue-next'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import type { ProviderKeyBalanceSummary, ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { 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
|
||||||
@@ -175,19 +147,4 @@ 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,64 +555,6 @@
|
|||||||
</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)"
|
||||||
@@ -1275,7 +1217,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
|
import { ref, watch, computed, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Key,
|
Key,
|
||||||
@@ -1294,7 +1236,6 @@ 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'
|
||||||
@@ -1341,12 +1282,10 @@ 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'
|
||||||
@@ -1391,17 +1330,6 @@ 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
|
||||||
@@ -1437,8 +1365,6 @@ 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'
|
||||||
@@ -1462,7 +1388,6 @@ 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())
|
||||||
@@ -1645,7 +1570,6 @@ 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) {
|
||||||
@@ -1657,7 +1581,6 @@ watch(
|
|||||||
|
|
||||||
// 停止倒计时定时器
|
// 停止倒计时定时器
|
||||||
stopCountdownTimer()
|
stopCountdownTimer()
|
||||||
stopKeyBalanceAutoRefreshTimer()
|
|
||||||
// 重置所有状态
|
// 重置所有状态
|
||||||
loading.value = false
|
loading.value = false
|
||||||
provider.value = null
|
provider.value = null
|
||||||
@@ -1820,167 +1743,6 @@ 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
|
||||||
@@ -2936,7 +2698,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleKeyChanged() {
|
async function handleKeyChanged() {
|
||||||
await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
|
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
|
||||||
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
||||||
@@ -3407,58 +3169,6 @@ 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)
|
||||||
@@ -3975,7 +3685,6 @@ 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 = []
|
||||||
@@ -4073,10 +3782,6 @@ 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,9 +125,17 @@
|
|||||||
>
|
>
|
||||||
{{ 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-if="provider.ops_configured && getProviderBalance(provider.id)"
|
v-else-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>
|
||||||
@@ -149,29 +157,6 @@
|
|||||||
: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)"
|
||||||
@@ -248,20 +233,13 @@ 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 ProviderKeyBalanceSummary, type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
|
import { 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
|
||||||
@@ -329,19 +307,4 @@ 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" />
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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,7 +295,6 @@
|
|||||||
<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"
|
||||||
/>
|
/>
|
||||||
@@ -326,7 +325,6 @@ import { useProviderBalance } from '@/features/providers/composables/useProvider
|
|||||||
import {
|
import {
|
||||||
getProvidersSummary,
|
getProvidersSummary,
|
||||||
getProvider,
|
getProvider,
|
||||||
getProviderEndpoints,
|
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
getProviderDeleteTask,
|
getProviderDeleteTask,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
@@ -519,7 +517,6 @@ const {
|
|||||||
// 扩展操作配置对话框
|
// 扩展操作配置对话框
|
||||||
const opsConfigDialogOpen = ref(false)
|
const opsConfigDialogOpen = ref(false)
|
||||||
const opsConfigProviderId = ref('')
|
const opsConfigProviderId = ref('')
|
||||||
const opsConfigProviderName = ref('')
|
|
||||||
const opsConfigProviderWebsite = ref('')
|
const opsConfigProviderWebsite = ref('')
|
||||||
|
|
||||||
// 内联编辑备注
|
// 内联编辑备注
|
||||||
@@ -710,21 +707,10 @@ async function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打开扩展操作配置对话框
|
// 打开扩展操作配置对话框
|
||||||
async function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
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