Revert "Merge pull request #517 from zhiqicloud/feat/provider-balance-query"

This reverts commit 7e95e769d5, reversing
changes made to 490306c242.
This commit is contained in:
fawney19
2026-05-21 01:24:17 +08:00
parent 923515ab28
commit e7f8b259ac
34 changed files with 152 additions and 3746 deletions

View File

@@ -177,17 +177,6 @@ pub(super) fn classify_admin_endpoints_family_route(
"admin:endpoints_manage",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/providers/")
&& normalized_path.ends_with("/key-balance")
{
Some(classified(
"admin_proxy",
"endpoints_manage",
"query_key_balance",
"admin:endpoints_manage",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/providers/")
&& normalized_path.ends_with("/keys")

View File

@@ -380,20 +380,6 @@ fn classifies_admin_refresh_provider_quota_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_query_provider_key_balance_as_admin_proxy_route() {
let headers = http::HeaderMap::new();
let uri: Uri = "/api/admin/endpoints/providers/provider-newapi/key-balance"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
.expect("decision should resolve");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("endpoints_manage"));
assert_eq!(decision.route_kind.as_deref(), Some("query_key_balance"));
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
let headers = headers(&[]);
@@ -413,25 +399,6 @@ fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
assert!(local_proxy_route_requires_buffered_body(&context));
}
#[test]
fn admin_query_provider_key_balance_buffers_request_body_for_key_secret() {
let headers = headers(&[]);
let uri: Uri = "/api/admin/endpoints/providers/provider-newapi/key-balance"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
.expect("decision should resolve");
let context = GatewayPublicRequestContext::from_request_parts(
"trace-key-balance",
&http::Method::POST,
&uri,
&headers,
Some(decision),
);
assert!(local_proxy_route_requires_buffered_body(&context));
}
#[test]
fn classifies_admin_default_body_rules_as_admin_proxy_route() {
let headers = headers(&[]);

View File

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

View File

@@ -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(
state: &AdminAppState<'_>,
provider_id: &str,

View File

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

View File

@@ -8,9 +8,7 @@ use super::super::responses::{
};
use super::super::support::admin_provider_ops_json_object_map;
use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::{
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
};
use aether_admin::provider::ops::parse_sub2api_balance_payload;
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use serde_json::{json, Value};
@@ -26,23 +24,6 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
proxy_snapshot: Option<&ProxySnapshot>,
) -> serde_json::Value {
let start = std::time::Instant::now();
if let Some(api_key) = credentials
.get("api_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return admin_provider_ops_sub2api_api_key_balance_payload(
state,
provider_id,
base_url,
action_config,
api_key,
proxy_snapshot,
start,
)
.await;
}
let (access_token, updated_credentials, _frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token(
state,
@@ -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 {
let normalized = error.trim();
let lower = normalized.to_ascii_lowercase();

View File

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

View File

@@ -6,8 +6,7 @@ use crate::handlers::admin::provider::ops::providers::config::persist_admin_prov
use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::{
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_verify_failure,
admin_provider_ops_verify_success, admin_provider_ops_verify_user_payload,
parse_sub2api_api_key_usage_payload, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
};
use aether_contracts::ProxySnapshot;
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>,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Value {
if let Some(api_key) = credentials
.get("api_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return admin_provider_ops_local_sub2api_api_key_verify_response(
state,
base_url,
api_key,
proxy_snapshot,
)
.await;
}
let (access_token, updated_credentials, frontend_updated_credentials) =
match admin_provider_ops_sub2api_exchange_token(
state,
@@ -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=...) 的行为:
// 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。
pub(in super::super) fn admin_provider_ops_sub2api_request_url(

View File

@@ -12,13 +12,6 @@ pub(crate) fn admin_provider_id_for_refresh_quota(request_path: &str) -> Option<
.map(ToOwned::to_owned)
}
pub(crate) fn admin_provider_id_for_key_balance(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/providers/")?
.strip_suffix("/key-balance")
.map(ToOwned::to_owned)
}
pub(crate) fn admin_reveal_key_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/keys/")?

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::{json, Map, Value};
use serde_json::json;
use std::collections::BTreeMap;
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)
}
fn finite_json_number(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64().filter(|value| value.is_finite()),
Some(Value::String(value)) => value
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite()),
_ => None,
}
}
fn finite_json_u64(value: Option<&Value>) -> Option<u64> {
finite_json_number(value).and_then(|value| {
if value >= 0.0 {
Some(value as u64)
} else {
None
}
})
}
fn latest_key_balance_summary(keys: &[StoredProviderCatalogKey]) -> Value {
let mut selected: Option<(u64, &StoredProviderCatalogKey, &Map<String, Value>)> = None;
for key in keys {
let Some(balance) = key
.upstream_metadata
.as_ref()
.and_then(|metadata| metadata.get("balance_query"))
.and_then(Value::as_object)
else {
continue;
};
let Some(updated_at) = finite_json_u64(balance.get("updated_at")) else {
continue;
};
let has_balance_value = ["total_available", "total_used", "total_granted"]
.into_iter()
.any(|field| finite_json_number(balance.get(field)).is_some());
if !has_balance_value {
continue;
}
if selected
.as_ref()
.is_none_or(|(selected_updated_at, _, _)| updated_at > *selected_updated_at)
{
selected = Some((updated_at, key, balance));
}
}
let Some((updated_at, key, balance)) = selected else {
return Value::Null;
};
json!({
"key_id": key.id.clone(),
"key_name": key.name.clone(),
"updated_at": updated_at,
"architecture_id": balance.get("architecture_id").cloned().unwrap_or(Value::Null),
"status": balance.get("status").cloned().unwrap_or_else(|| json!("success")),
"executed_at": balance.get("executed_at").cloned().unwrap_or(Value::Null),
"response_time_ms": balance.get("response_time_ms").cloned().unwrap_or(Value::Null),
"total_available": balance.get("total_available").cloned().unwrap_or(Value::Null),
"total_used": balance.get("total_used").cloned().unwrap_or(Value::Null),
"total_granted": balance.get("total_granted").cloned().unwrap_or(Value::Null),
"currency": balance.get("currency").cloned().unwrap_or_else(|| json!("USD")),
"plan_name": balance.get("plan_name").cloned().unwrap_or(Value::Null),
"query_config": balance.get("query_config").cloned().unwrap_or(Value::Null),
"extra": balance.get("extra").cloned().unwrap_or(Value::Null),
})
}
pub(crate) fn build_admin_provider_summary_value(
provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint],
@@ -232,7 +158,6 @@ pub(crate) fn build_admin_provider_summary_value(
.and_then(|quota| quota.quota_expires_at_unix_secs)
.or(provider.quota_expires_at_unix_secs)
.and_then(unix_secs_to_rfc3339);
let key_balance_summary = latest_key_balance_summary(keys);
json!({
"id": provider.id.clone(),
@@ -271,7 +196,6 @@ pub(crate) fn build_admin_provider_summary_value(
"endpoint_health_details": endpoint_health_details,
"ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id,
"key_balance_summary": key_balance_summary,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),

View File

@@ -177,21 +177,6 @@ impl<'a> AdminAppState<'a> {
self.app.update_provider_catalog_key(key).await
}
pub(crate) async fn update_provider_catalog_key_upstream_metadata(
&self,
key_id: &str,
upstream_metadata: Option<&serde_json::Value>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
self.app
.update_provider_catalog_key_upstream_metadata(
key_id,
upstream_metadata,
updated_at_unix_secs,
)
.await
}
pub(crate) async fn create_provider_catalog_key(
&self,
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,

View File

@@ -21,7 +21,6 @@ const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
const BALANCE_QUERY_SECRET_CIPHERTEXT_KEY: &str = "secret_ciphertext";
pub(crate) fn provider_catalog_key_supports_format(
key: &StoredProviderCatalogKey,
@@ -174,31 +173,6 @@ pub(crate) fn parse_catalog_auth_config_json(
.cloned()
}
fn sanitized_admin_upstream_metadata(upstream_metadata: Option<&Value>) -> Value {
let Some(mut metadata) = upstream_metadata.cloned() else {
return Value::Null;
};
let Some(balance_query) = metadata
.as_object_mut()
.and_then(|metadata| metadata.get_mut("balance_query"))
.and_then(Value::as_object_mut)
else {
return metadata;
};
let has_saved_secret = balance_query
.remove(BALANCE_QUERY_SECRET_CIPHERTEXT_KEY)
.is_some();
if has_saved_secret {
let query_config = balance_query
.entry("query_config".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if let Some(query_config) = query_config.as_object_mut() {
query_config.insert("has_saved_secret".to_string(), Value::Bool(true));
}
}
metadata
}
pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
json!({
"oauth": {
@@ -1963,7 +1937,7 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert(
"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("fingerprint".to_string(), json!(key.fingerprint));

View File

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

View File

@@ -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()),
Some("session=foo")
);
assert_eq!(
headers
.get(axum::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
Some("cc-switch/1.0")
);
assert_eq!(
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(
headers
.get(axum::http::header::ACCEPT)
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert!(headers.contains_key("sec-ch-ua"));
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(

View File

@@ -1,4 +1,3 @@
use super::architectures::normalize_architecture_id;
use super::verify::admin_provider_ops_value_as_f64;
use serde_json::{json, Map, Value};
@@ -14,7 +13,7 @@ pub fn parse_query_balance_payload(
action_config: &Map<String, Value>,
response_json: &Value,
) -> Result<Value, String> {
match normalize_architecture_id(architecture_id) {
match architecture_id {
"generic_api" | "new_api" | "anyrouter" | "done_hub" => {
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),
&currency,
extra,
))
}
pub fn attach_balance_checkin_outcome(
action_payload: &mut Value,
outcome: &ProviderOpsCheckinOutcome,
@@ -239,316 +171,39 @@ fn parse_new_api_balance_payload(
action_config: &Map<String, Value>,
response_json: &Value,
) -> Result<Value, String> {
let user_data = balance_response_object(response_json)?;
let quota_divisor = balance_divisor(action_config);
let total_available_raw = balance_value_from_candidates(
response_json,
user_data,
action_config,
&[
"balance_path",
"available_path",
"total_available_path",
"quota_path",
],
&[
"total_available",
"data.total_available",
"balance",
"data.balance",
"available",
"data.available",
"remaining",
"data.remaining",
"quota",
"data.quota",
"balance_infos.0.total_balance",
"balance_infos[0].total_balance",
"data.balance_infos.0.total_balance",
"data.balance_infos[0].total_balance",
"balance_total",
"data.balance_total",
"total_balance",
"data.total_balance",
],
);
let total_used_raw = balance_value_from_candidates(
response_json,
user_data,
action_config,
&[
"used_path",
"used_quota_path",
"spent_path",
"usage_path",
"total_used_path",
],
&[
"used_quota",
"data.used_quota",
"used",
"data.used",
"total_used",
"data.total_used",
"spent",
"data.spent",
"usage",
"data.usage",
],
);
let total_granted_raw = balance_value_from_candidates(
response_json,
user_data,
action_config,
&[
"granted_path",
"total_granted_path",
"limit_path",
"total_quota_path",
],
&[
"total_granted",
"data.total_granted",
"total_quota",
"data.total_quota",
"granted",
"data.granted",
"limit",
"data.limit",
"balance_total",
"data.balance_total",
"total_balance",
"data.total_balance",
],
);
let total_available_raw = total_available_raw.or(match (total_granted_raw, total_used_raw) {
(Some(granted), Some(used)) => Some((granted - used).max(0.0)),
_ => None,
});
let total_used_raw = total_used_raw.or(match (total_granted_raw, total_available_raw) {
(Some(granted), Some(available)) => Some((granted - available).max(0.0)),
_ => None,
});
let total_granted_raw = total_granted_raw.or(match (total_available_raw, total_used_raw) {
(Some(available), Some(used)) => Some(available + used),
_ => None,
});
let mut extra = Map::new();
if let Some(plan_name) = new_api_plan_name(user_data) {
extra.insert("plan_name".to_string(), json!(plan_name));
}
let user_data = if response_json.get("success").and_then(Value::as_bool) == Some(true)
&& response_json.get("data").is_some_and(Value::is_object)
{
response_json.get("data")
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
return Err(response_json
.get("message")
.and_then(Value::as_str)
.unwrap_or("业务状态码表示失败")
.to_string());
} else {
Some(response_json)
};
let Some(user_data) = user_data.and_then(Value::as_object) else {
return Err("响应格式无效".to_string());
};
let quota_divisor = quota_divisor(action_config);
let total_available =
admin_provider_ops_value_as_f64(user_data.get("quota")).map(|value| value / quota_divisor);
let total_used = admin_provider_ops_value_as_f64(user_data.get("used_quota"))
.map(|value| value / quota_divisor);
Ok(build_balance_data(
total_granted_raw.map(|value| value / quota_divisor),
total_used_raw.map(|value| value / quota_divisor),
total_available_raw.map(|value| value / quota_divisor),
None,
total_used,
total_available,
action_config
.get("currency")
.and_then(Value::as_str)
.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(
action_config: &Map<String, Value>,
response_json: &Value,
@@ -759,12 +414,6 @@ fn quota_divisor(action_config: &Map<String, Value>) -> f64 {
.unwrap_or(500000.0)
}
fn balance_divisor(action_config: &Map<String, Value>) -> f64 {
admin_provider_ops_value_as_f64(action_config.get("balance_divisor"))
.filter(|value| *value > 0.0)
.unwrap_or_else(|| quota_divisor(action_config))
}
fn parse_rfc3339_unix_secs(value: Option<&Value>) -> Option<i64> {
let raw = value?.as_str()?.trim();
if raw.is_empty() {
@@ -817,8 +466,7 @@ fn parse_subscription(value: &Value) -> Option<Value> {
#[cfg(test)]
mod tests {
use super::{
attach_balance_checkin_outcome, parse_query_balance_payload,
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
ProviderOpsCheckinOutcome,
};
use serde_json::json;
@@ -842,109 +490,6 @@ mod tests {
assert_eq!(payload["total_used"], json!(1.0));
}
#[test]
fn new_api_alias_parser_supports_balance_field_fallbacks() {
let payload = parse_query_balance_payload(
"oneapi",
&json!({ "quota_divisor": 1, "currency": "CNY" })
.as_object()
.cloned()
.expect("config"),
&json!({
"success": true,
"data": {
"balance": 12.5,
"used": 2.25
}
}),
)
.expect("payload should parse");
assert_eq!(payload["currency"], json!("CNY"));
assert_eq!(payload["total_available"], json!(12.5));
assert_eq!(payload["total_used"], json!(2.25));
}
#[test]
fn new_api_parser_supports_total_available_and_total_used_fields() {
let payload = parse_query_balance_payload(
"new_api",
&json!({ "quota_divisor": 1 })
.as_object()
.cloned()
.expect("config"),
&json!({
"data": {
"total_available": 9.75,
"total_used": 1.25
}
}),
)
.expect("payload should parse");
assert_eq!(payload["total_available"], json!(9.75));
assert_eq!(payload["total_used"], json!(1.25));
assert_eq!(payload["total_granted"], json!(11.0));
}
#[test]
fn new_api_parser_matches_cc_switch_usage_script_shape() {
let payload = parse_query_balance_payload(
"new_api",
&json!({ "quota_divisor": 500000, "currency": "USD" })
.as_object()
.cloned()
.expect("config"),
&json!({
"success": true,
"data": {
"group": "默认套餐",
"quota": 2_500_000,
"used_quota": 500_000
}
}),
)
.expect("payload should parse");
assert_eq!(payload["total_available"], json!(5.0));
assert_eq!(payload["total_used"], json!(1.0));
assert_eq!(payload["total_granted"], json!(6.0));
assert_eq!(payload["currency"], json!("USD"));
assert_eq!(payload["extra"]["plan_name"], json!("默认套餐"));
}
#[test]
fn generic_api_parser_supports_deepseek_balance_shape() {
let payload = parse_query_balance_payload(
"generic_api",
&json!({
"quota_divisor": 1,
"currency": "CNY",
"balance_path": "balance_infos[0].total_balance"
})
.as_object()
.cloned()
.expect("config"),
&json!({
"is_available": true,
"balance_infos": [
{
"currency": "CNY",
"total_balance": "128.50",
"granted_balance": "8.50",
"topped_up_balance": "120.00"
}
]
}),
)
.expect("payload should parse");
assert_eq!(payload["currency"], json!("CNY"));
assert_eq!(payload["total_available"], json!(128.5));
assert_eq!(payload["total_used"], json!(null));
assert_eq!(payload["total_granted"], json!(null));
}
#[test]
fn done_hub_single_request_parser_reads_wrapped_quota() {
let payload = parse_query_balance_payload(
@@ -995,28 +540,6 @@ mod tests {
assert_eq!(payload["extra"]["active_subscriptions"], json!(2));
}
#[test]
fn sub2api_api_key_usage_parser_matches_cc_switch_shape() {
let payload = parse_sub2api_api_key_usage_payload(
&json!({ "currency": "USD" })
.as_object()
.cloned()
.expect("config"),
&json!({
"is_active": true,
"quota": {
"remaining": "12.5",
"unit": "USD"
}
}),
)
.expect("payload should parse");
assert_eq!(payload["total_available"], json!(12.5));
assert_eq!(payload["currency"], json!("USD"));
assert_eq!(payload["extra"]["is_active"], json!(true));
}
#[test]
fn cubence_parser_reads_wrapped_dashboard_overview() {
let payload = parse_query_balance_payload(

View File

@@ -119,19 +119,12 @@ pub fn get_architecture(architecture_id: &str) -> Option<ProviderOpsArchitecture
}
pub fn normalize_architecture_id(architecture_id: &str) -> &'static str {
let compact = architecture_id
.trim()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
match compact.as_str() {
match architecture_id.trim() {
"" => "generic_api",
"genericapi" => "generic_api",
"newapi" | "oneapi" => "new_api",
"generic_api" => "generic_api",
"new_api" => "new_api",
"cubence" => "cubence",
"donehub" => "done_hub",
"done_hub" => "done_hub",
"yescode" => "yescode",
"nekocode" => "nekocode",
"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 {
matches!(
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("done_hub"), "done_hub");
assert_eq!(normalize_architecture_id("new_api"), "new_api");
assert_eq!(normalize_architecture_id("newapi"), "new_api");
assert_eq!(normalize_architecture_id("one-api"), "new_api");
assert_eq!(normalize_architecture_id("unknown"), "generic_api");
}

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ pub mod config;
pub mod verify;
pub use self::actions::{
attach_balance_checkin_outcome, parse_query_balance_payload,
parse_sub2api_api_key_usage_payload, parse_sub2api_balance_payload,
attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload,
parse_yescode_combined_balance_payload, ProviderOpsCheckinOutcome,
};
pub use self::architectures::{

View File

@@ -379,9 +379,18 @@ pub fn admin_provider_ops_verify_headers(
}
"new_api" => {
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-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)?;
}

View File

@@ -286,74 +286,6 @@ export async function refreshProviderQuota(
return response.data
}
export type ProviderKeyBalanceStatus =
| 'success'
| 'pending'
| 'auth_failed'
| 'auth_expired'
| 'rate_limited'
| 'network_error'
| 'parse_error'
| 'not_configured'
| 'not_supported'
| 'already_done'
| 'unknown_error'
export interface ProviderKeyBalanceInfo {
total_granted: number | null
total_used: number | null
total_available: number | null
expires_at: string | null
currency: string
extra: Record<string, unknown>
}
export interface ProviderKeyBalanceResult {
status: ProviderKeyBalanceStatus
action_type: 'query_balance'
data: ProviderKeyBalanceInfo | null
message: string | null
executed_at: string
response_time_ms: number | null
cache_ttl_seconds: number
saved_to_key?: boolean
saved_key_id?: string | null
save_message?: string | null
}
export interface ProviderKeyBalanceQuery {
key_id?: string
api_key?: string
auth_type?: 'api_key' | 'bearer' | 'service_account' | 'oauth'
api_formats?: string[]
architecture_id?: 'new_api' | 'sub2api' | 'generic_api'
custom_base_url?: string
new_api_user_id?: string
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token'
custom_endpoint?: string
custom_method?: 'GET' | 'POST'
custom_currency?: string
custom_quota_divisor?: number
custom_balance_path?: string
custom_used_path?: string
custom_granted_path?: string
auto_refresh_interval_minutes?: number
save_balance_secret?: boolean
save_result?: boolean
}
export async function queryProviderKeyBalance(
providerId: string,
data: ProviderKeyBalanceQuery,
): Promise<ProviderKeyBalanceResult> {
const response = await client.post<ProviderKeyBalanceResult>(
`/api/admin/endpoints/providers/${providerId}/key-balance`,
data,
{ timeout: 60 * 1000 },
)
return response.data
}
/**
* 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro

View File

@@ -402,46 +402,12 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface BalanceQueryUpstreamMetadata {
updated_at?: number
architecture_id?: string | null
status?: string | null
executed_at?: string | null
response_time_ms?: number | null
total_available?: number | null
total_used?: number | null
total_granted?: number | null
currency?: string | null
plan_name?: string | null
query_config?: {
custom_base_url?: string | null
new_api_user_id?: string | null
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token' | string | null
custom_endpoint?: string | null
custom_method?: 'GET' | 'POST' | string | null
custom_currency?: string | null
custom_quota_divisor?: number | null
custom_balance_path?: string | null
custom_used_path?: string | null
custom_granted_path?: string | null
auto_refresh_interval_minutes?: number | null
has_saved_secret?: boolean | null
} | null
extra?: Record<string, unknown> | null
}
export interface ProviderKeyBalanceSummary extends BalanceQueryUpstreamMetadata {
key_id?: string | null
key_name?: string | null
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
balance_query?: BalanceQueryUpstreamMetadata
}
// 按格式的健康度数据
@@ -718,7 +684,6 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
key_balance_summary?: ProviderKeyBalanceSummary | null
kiro_simulated_cache_enabled?: boolean
created_at: string
updated_at: string

View File

@@ -12,13 +12,7 @@ import client from './client'
// ==================== Types ====================
/** 认证类型 */
export type ConnectorAuthType =
| 'api_key'
| 'refresh_token'
| 'session_login'
| 'oauth'
| 'cookie'
| 'none'
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
/** 操作类型 */
export type ProviderActionType =

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,15 @@
<template>
<!-- 余额正在加载中 -->
<div
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 显示从上游 API 查询的余额 -->
<div
v-if="provider.ops_configured && getProviderBalance(provider.id)"
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="flex items-center gap-2 text-xs"
>
<!-- 余额文字balance + points 分开显示或普通余额 -->
@@ -87,35 +95,6 @@
</div>
</div>
</div>
<!-- 显示保存到 Key 的手动余额查询摘要 -->
<div
v-else-if="getSavedKeyBalance(provider)"
class="space-y-0.5 text-xs"
:title="getSavedKeyBalanceTitle(provider)"
>
<div class="flex items-center gap-1.5">
<WalletCards class="h-3 w-3 text-primary" />
<span class="font-semibold text-foreground/90 tabular-nums">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/70">
<span v-if="toFiniteNumber(getSavedKeyBalance(provider)?.total_used) !== null">
已用 {{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_used, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span>
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</div>
</div>
<!-- 余额正在加载中 -->
<div
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 余额查询失败时显示错误 -->
<div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -149,18 +128,11 @@
</template>
<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 type { ProviderKeyBalanceSummary, ProviderWithEndpointsSummary } from '@/api/endpoints'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
toFiniteNumber,
} from '@/features/providers/utils/keyBalanceSummary'
defineProps<{
provider: ProviderWithEndpointsSummary
@@ -175,19 +147,4 @@ defineProps<{
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

@@ -555,64 +555,6 @@
</Button>
</div>
</div>
<!-- 手动余额查询摘要 -->
<div
v-if="getKeyBalanceSummary(key)"
class="mt-2 flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px]"
>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<span class="inline-flex items-center gap-1 font-medium text-foreground">
<WalletCards class="h-3 w-3 text-primary" />
上游余额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.available, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.used !== null"
class="text-muted-foreground"
>
已用 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.used, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.granted !== null"
class="text-muted-foreground"
>
总额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.granted, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.planName"
class="text-muted-foreground"
>
套餐 {{ getKeyBalanceSummary(key)?.planName }}
</span>
<span class="text-muted-foreground/70">
{{ getKeyBalanceSummary(key)?.templateLabel }} · {{ formatUpdatedAt(getKeyBalanceSummary(key)?.updatedAt || 0) }}
</span>
<span
v-if="getKeyBalanceAutoRefreshIntervalMinutes(key) > 0"
class="text-muted-foreground/70"
>
每 {{ getKeyBalanceAutoRefreshIntervalMinutes(key) }} 分钟自动
</span>
<span
v-if="keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)"
class="text-amber-600 dark:text-amber-400"
>
需保存查询凭据
</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
:disabled="refreshingBalanceKeyId === key.id || !canRefreshKeyBalance(key)"
:title="getKeyBalanceRefreshTitle(key)"
@click.stop="handleRefreshKeyBalance(key)"
>
<RefreshCw
class="h-3 w-3"
:class="{ 'animate-spin': refreshingBalanceKeyId === key.id }"
/>
</Button>
</div>
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
<div
v-if="hasCodexQuotaDisplayData(key)"
@@ -1275,7 +1217,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
import { ref, watch, computed, nextTick } from 'vue'
import {
Plus,
Key,
@@ -1294,7 +1236,6 @@ import {
ShieldX,
Globe,
GitBranch,
WalletCards,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1341,12 +1282,10 @@ import {
exportKey,
refreshProviderOAuth,
refreshProviderQuota,
queryProviderKeyBalance,
clearOAuthInvalid,
type ProviderEndpoint,
type EndpointAPIKey,
type Model,
type ProviderKeyBalanceQuery,
API_FORMAT_ORDER,
sortApiFormats,
} from '@/api/endpoints'
@@ -1391,17 +1330,6 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
rpm_limit?: number
}
interface KeyBalanceSummary {
available: number | null
used: number | null
granted: number | null
currency: string
updatedAt: number
templateLabel: string
planName: string | null
architectureId: string
}
interface Props {
providerId: string | null
open: boolean
@@ -1437,8 +1365,6 @@ let keysLoadRequestId = 0
let mappingPreviewLoadRequestId = 0
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
const BALANCE_AUTO_REFRESH_CHECK_MS = 60_000
let balanceAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom'
@@ -1462,7 +1388,6 @@ const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false)
const keyToDelete = ref<EndpointAPIKey | null>(null)
const togglingKeyId = ref<string | null>(null)
const refreshingBalanceKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
@@ -1645,7 +1570,6 @@ watch(
// 仅在抽屉刚打开时启动倒计时
if (newOpen && !oldOpen) {
startCountdownTimer()
startKeyBalanceAutoRefreshTimer()
}
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
} else if (!newOpen && oldOpen) {
@@ -1657,7 +1581,6 @@ watch(
// 停止倒计时定时器
stopCountdownTimer()
stopKeyBalanceAutoRefreshTimer()
// 重置所有状态
loading.value = false
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) {
editingKey.value = key
keyPermissionsDialogOpen.value = true
@@ -2936,7 +2698,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
}
async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
await Promise.all([loadEndpoints(), loadMappingPreview()])
emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
@@ -3407,58 +3169,6 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
return hasAntigravityQuotaData(key.upstream_metadata)
}
function getKeyBalanceSummary(key: EndpointAPIKey): KeyBalanceSummary | null {
const metadata = key.upstream_metadata?.balance_query
if (!metadata) return null
const updatedAt = toFiniteNumber(metadata.updated_at)
const available = toFiniteNumber(metadata.total_available)
const used = toFiniteNumber(metadata.total_used)
const granted = toFiniteNumber(metadata.total_granted)
if (updatedAt === null || (available === null && used === null && granted === null)) {
return null
}
const architectureId = String(metadata.architecture_id || '').trim()
const labels: Record<string, string> = {
new_api: 'NewAPI',
sub2api: 'Sub2API',
generic_api: '自定义'
}
return {
available,
used,
granted,
currency: String(metadata.currency || 'USD').trim() || 'USD',
updatedAt,
templateLabel: labels[architectureId] || architectureId || '余额查询',
architectureId,
planName: typeof metadata.plan_name === 'string' && metadata.plan_name.trim()
? metadata.plan_name.trim()
: null,
}
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
const numberValue = toFiniteNumber(value)
if (numberValue === null) return '未知'
const normalizedCurrency = (currency || 'USD').toUpperCase()
const prefix = normalizedCurrency === 'USD'
? '$'
: normalizedCurrency === 'CNY'
? '¥'
: `${normalizedCurrency} `
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
return `${prefix}${numberValue.toFixed(decimals)}`
}
function formatUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000)
@@ -3975,7 +3685,6 @@ async function loadProviderKeysPage(page = currentKeyPage.value) {
currentKeyPage.value = Math.min(result.page, nextTotalPages)
keyPageSize.value = result.page_size
syncCurrentSelections(endpoints.value, result.keys)
void refreshDueKeyBalances()
} catch (err: unknown) {
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
providerKeys.value = []
@@ -4073,10 +3782,6 @@ useEscapeKey(() => {
disableOnInput: true,
once: false
})
onUnmounted(() => {
stopKeyBalanceAutoRefreshTimer()
})
</script>
<style scoped>

View File

@@ -93,7 +93,7 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="配置用量查询"
title="扩展操作配置"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
@@ -125,9 +125,17 @@
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额加载中 -->
<span
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额从上游 API 查询 -->
<span
v-if="provider.ops_configured && getProviderBalance(provider.id)"
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
@@ -149,29 +157,6 @@
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</span>
<!-- 保存到 Key 的手动余额查询摘要 -->
<span
v-else-if="getSavedKeyBalance(provider)"
class="text-muted-foreground inline-flex items-center gap-1"
:title="getSavedKeyBalanceTitle(provider)"
>
<WalletCards class="h-3 w-3 text-primary" />
余额
<span class="font-semibold text-foreground/90">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span class="text-muted-foreground/70">
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</span>
<!-- 余额加载中 -->
<span
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额查询失败时显示错误 -->
<span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -248,20 +233,13 @@ import {
Check,
X,
Loader2,
WalletCards,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { type ProviderKeyBalanceSummary, type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
} from '@/features/providers/utils/keyBalanceSummary'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -329,19 +307,4 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
}
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

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

View File

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

View File

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