diff --git a/crates/aether-admin/src/provider/ops/actions.rs b/crates/aether-admin/src/provider/ops/actions.rs index cb451a3a5..228bf7a3a 100644 --- a/crates/aether-admin/src/provider/ops/actions.rs +++ b/crates/aether-admin/src/provider/ops/actions.rs @@ -17,6 +17,7 @@ pub fn parse_query_balance_payload( "generic_api" | "new_api" | "anyrouter" | "done_hub" => { parse_new_api_balance_payload(action_config, response_json) } + "usage_api" => parse_usage_api_balance_payload(action_config, response_json), "cubence" => parse_cubence_balance_payload(action_config, response_json), "nekocode" => parse_nekocode_balance_payload(response_json), _ => Err("Provider 操作仅支持 Rust execution runtime".to_string()), @@ -204,6 +205,57 @@ fn parse_new_api_balance_payload( )) } +fn parse_usage_api_balance_payload( + action_config: &Map, + response_json: &Value, +) -> Result { + let data = response_json + .as_object() + .ok_or_else(|| "响应格式无效".to_string())?; + let is_valid = data + .get("is_active") + .and_then(Value::as_bool) + .or_else(|| data.get("isValid").and_then(Value::as_bool)); + if is_valid == Some(false) { + return Err("API Key 无效或已停用".to_string()); + } + + let quota = data.get("quota").and_then(Value::as_object); + let remaining = admin_provider_ops_value_as_f64(data.get("remaining")) + .or_else(|| quota.and_then(|quota| admin_provider_ops_value_as_f64(quota.get("remaining")))) + .or_else(|| admin_provider_ops_value_as_f64(data.get("balance"))) + .ok_or_else(|| "响应缺少余额字段".to_string())?; + let currency = data + .get("unit") + .and_then(Value::as_str) + .or_else(|| quota.and_then(|quota| quota.get("unit").and_then(Value::as_str))) + .or_else(|| action_config.get("currency").and_then(Value::as_str)) + .unwrap_or("USD"); + + let mut extra = Map::new(); + extra.insert( + "is_valid".to_string(), + Value::Bool(is_valid.unwrap_or(true)), + ); + if let Some(value) = data.get("balance") { + extra.insert("balance".to_string(), value.clone()); + } + if let Some(value) = data.get("planName").or_else(|| data.get("plan_name")) { + extra.insert("plan_name".to_string(), value.clone()); + } + if let Some(value) = data.get("mode") { + extra.insert("mode".to_string(), value.clone()); + } + + Ok(build_balance_data( + None, + None, + Some(remaining), + currency, + extra, + )) +} + fn parse_cubence_balance_payload( action_config: &Map, response_json: &Value, @@ -469,7 +521,7 @@ mod tests { attach_balance_checkin_outcome, parse_query_balance_payload, parse_sub2api_balance_payload, ProviderOpsCheckinOutcome, }; - use serde_json::json; + use serde_json::{json, Map}; #[test] fn anyrouter_single_request_parser_uses_usage_fields() { @@ -490,6 +542,48 @@ mod tests { assert_eq!(payload["total_used"], json!(1.0)); } + #[test] + fn usage_api_parser_reads_remaining_and_unit() { + let payload = parse_query_balance_payload( + "usage_api", + &json!({ "currency": "USD" }) + .as_object() + .cloned() + .expect("config"), + &json!({ + "remaining": 42.5, + "balance": 42.5, + "unit": "USD", + "isValid": true, + "planName": "Example Plan" + }), + ) + .expect("payload should parse"); + + assert_eq!(payload["total_available"], json!(42.5)); + assert_eq!(payload["currency"], json!("USD")); + assert_eq!(payload["extra"]["plan_name"], json!("Example Plan")); + assert_eq!(payload["extra"]["is_valid"], json!(true)); + } + + #[test] + fn usage_api_parser_falls_back_to_nested_quota() { + let payload = parse_query_balance_payload( + "usage_api", + &Map::new(), + &json!({ + "quota": { + "remaining": "12.5", + "unit": "CNY" + } + }), + ) + .expect("payload should parse"); + + assert_eq!(payload["total_available"], json!(12.5)); + assert_eq!(payload["currency"], json!("CNY")); + } + #[test] fn done_hub_single_request_parser_reads_wrapped_quota() { let payload = parse_query_balance_payload( diff --git a/crates/aether-admin/src/provider/ops/architectures/mod.rs b/crates/aether-admin/src/provider/ops/architectures/mod.rs index b4173d4e0..1a9102342 100644 --- a/crates/aether-admin/src/provider/ops/architectures/mod.rs +++ b/crates/aether-admin/src/provider/ops/architectures/mod.rs @@ -5,6 +5,7 @@ mod generic_api; mod nekocode; mod new_api; mod sub2api; +mod usage_api; mod yescode; use serde_json::{json, Map, Value}; @@ -98,6 +99,7 @@ static PROVIDER_OPS_ARCHITECTURES: LazyLock> = nekocode::spec(), new_api::spec(), sub2api::spec(), + usage_api::spec(), yescode::spec(), ] }); @@ -129,6 +131,7 @@ pub fn normalize_architecture_id(architecture_id: &str) -> &'static str { "nekocode" => "nekocode", "anyrouter" => "anyrouter", "sub2api" => "sub2api", + "usage_api" => "usage_api", _ => "generic_api", } } @@ -186,6 +189,7 @@ fn default_action_config(architecture_id: &str, action_type: &str) -> Option nekocode::default_action_config(action_type), "new_api" => new_api::default_action_config(action_type), "sub2api" => sub2api::default_action_config(action_type), + "usage_api" => usage_api::default_action_config(action_type), "yescode" => yescode::default_action_config(action_type), _ => None, } @@ -205,13 +209,13 @@ mod tests { #[test] fn list_architectures_hides_generic_api_by_default() { let visible = list_architectures(false); - assert_eq!(visible.len(), 7); + assert_eq!(visible.len(), 8); assert!(visible .iter() .all(|item| item.architecture_id != "generic_api")); let all = list_architectures(true); - assert_eq!(all.len(), 8); + assert_eq!(all.len(), 9); assert!(all.iter().any(|item| item.architecture_id == "generic_api")); } @@ -220,6 +224,7 @@ 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("usage_api"), "usage_api"); assert_eq!(normalize_architecture_id("unknown"), "generic_api"); } diff --git a/crates/aether-admin/src/provider/ops/architectures/usage_api.rs b/crates/aether-admin/src/provider/ops/architectures/usage_api.rs new file mode 100644 index 000000000..0212603d2 --- /dev/null +++ b/crates/aether-admin/src/provider/ops/architectures/usage_api.rs @@ -0,0 +1,92 @@ +use super::{ + json_object, ProviderOpsActionSpec, ProviderOpsArchitectureSpec, ProviderOpsAuthSpec, + ProviderOpsBalanceMode, ProviderOpsCheckinMode, ProviderOpsVerifyMode, +}; +use serde_json::{json, Map, Value}; + +pub(super) fn spec() -> ProviderOpsArchitectureSpec { + let credentials_schema = json!({ + "type": "object", + "properties": { + "api_key": { + "type": "string", + "title": "API Key", + "description": "提供商签发的 API Key", + "x-sensitive": true, + "x-input-type": "password" + }, + "base_url": { + "type": "string", + "title": "站点地址", + "description": "API 基础地址" + } + }, + "required": ["api_key"], + "x-auth-method": "bearer", + "x-auth-type": "api_key", + "x-currency": "USD", + "x-field-groups": [ + { "fields": ["base_url"] }, + { "fields": ["api_key"] } + ], + "x-validation": [ + { + "type": "required", + "fields": ["api_key"], + "message": "请填写 API Key" + } + ] + }); + + ProviderOpsArchitectureSpec { + architecture_id: "usage_api", + display_name: "API Key 用量查询", + description: "使用 Provider API Key 查询兼容 /v1/usage 的用量接口", + hidden: false, + credentials_schema: credentials_schema.clone(), + verify_endpoint: "/v1/usage", + verify_mode: ProviderOpsVerifyMode::DirectGet, + balance_mode: ProviderOpsBalanceMode::SingleRequest, + checkin_mode: ProviderOpsCheckinMode::None, + query_balance_cookie_auth_errors: false, + supported_auth_types: vec![ProviderOpsAuthSpec { + auth_type: "api_key", + display_name: "Provider API Key", + credentials_schema, + }], + supported_actions: vec![ProviderOpsActionSpec { + action_type: "query_balance", + display_name: "查询余额", + description: "查询 API Key 的剩余额度", + config_schema: json!({ + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "title": "API 路径", + "description": "用量查询 API 路径", + "default": "/v1/usage" + }, + "currency": { + "type": "string", + "title": "默认货币单位", + "default": "USD" + } + }, + "required": [] + }), + }], + default_connector: Some("api_key"), + } +} + +pub(super) fn default_action_config(action_type: &str) -> Option> { + match action_type { + "query_balance" => Some(json_object(json!({ + "endpoint": "/v1/usage", + "method": "GET", + "currency": "USD" + }))), + _ => None, + } +} diff --git a/crates/aether-admin/src/provider/ops/verify.rs b/crates/aether-admin/src/provider/ops/verify.rs index fa19f9199..bc4d531ca 100644 --- a/crates/aether-admin/src/provider/ops/verify.rs +++ b/crates/aether-admin/src/provider/ops/verify.rs @@ -35,6 +35,7 @@ pub fn parse_verify_payload( "sub2api" => { admin_provider_ops_sub2api_verify_payload(status, response_json, updated_credentials) } + "usage_api" => admin_provider_ops_usage_api_verify_payload(status, response_json), _ => admin_provider_ops_generic_verify_payload(status, response_json), } } @@ -355,7 +356,7 @@ pub fn admin_provider_ops_verify_headers( ) -> Result { let mut headers = HeaderMap::new(); match normalize_architecture_id(architecture_id) { - "generic_api" => { + "generic_api" | "usage_api" => { let api_key = credentials .get("api_key") .and_then(Value::as_str) @@ -512,6 +513,78 @@ pub fn admin_provider_ops_generic_verify_payload( ) } +pub fn admin_provider_ops_usage_api_verify_payload( + status: StatusCode, + response_json: &Value, +) -> Value { + if status == StatusCode::UNAUTHORIZED { + return admin_provider_ops_verify_failure("认证失败:API Key 无效"); + } + if status == StatusCode::FORBIDDEN { + return admin_provider_ops_verify_failure("认证失败:API Key 无权查询用量"); + } + if status != StatusCode::OK { + return admin_provider_ops_verify_failure(format!("验证失败:HTTP {}", status.as_u16())); + } + + let Some(data) = response_json.as_object() else { + return admin_provider_ops_verify_failure("响应格式无效"); + }; + let is_valid = data + .get("is_active") + .and_then(Value::as_bool) + .or_else(|| data.get("isValid").and_then(Value::as_bool)); + if is_valid == Some(false) { + return admin_provider_ops_verify_failure("API Key 无效或已停用"); + } + + let quota = data.get("quota").and_then(Value::as_object); + let remaining = admin_provider_ops_value_as_f64(data.get("remaining")) + .or_else(|| quota.and_then(|quota| admin_provider_ops_value_as_f64(quota.get("remaining")))) + .or_else(|| admin_provider_ops_value_as_f64(data.get("balance"))); + let Some(remaining) = remaining else { + return admin_provider_ops_verify_failure("响应缺少余额字段"); + }; + let unit = data + .get("unit") + .and_then(Value::as_str) + .or_else(|| quota.and_then(|quota| quota.get("unit").and_then(Value::as_str))) + .unwrap_or("USD") + .to_string(); + let plan_name = data + .get("planName") + .and_then(Value::as_str) + .or_else(|| data.get("plan_name").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("API Key") + .to_string(); + + let mut extra = Map::new(); + extra.insert("unit".to_string(), Value::String(unit)); + if let Some(value) = data.get("balance") { + extra.insert("balance".to_string(), value.clone()); + } + if let Some(value) = data.get("mode") { + extra.insert("mode".to_string(), value.clone()); + } + extra.insert( + "is_valid".to_string(), + Value::Bool(is_valid.unwrap_or(true)), + ); + + admin_provider_ops_verify_success( + admin_provider_ops_verify_user_payload( + Some(plan_name.clone()), + Some(plan_name), + None, + Some(remaining), + Some(extra), + ), + None, + ) +} + pub fn admin_provider_ops_anyrouter_verify_payload( status: StatusCode, response_json: &Value, @@ -843,7 +916,8 @@ mod tests { admin_provider_ops_anyrouter_parse_session_user_id, admin_provider_ops_anyrouter_verify_payload, admin_provider_ops_cubence_verify_payload, admin_provider_ops_frontend_updated_credentials, admin_provider_ops_sub2api_verify_payload, - admin_provider_ops_verify_headers, parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT, + admin_provider_ops_usage_api_verify_payload, admin_provider_ops_verify_headers, + parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT, }; use http::StatusCode; use reqwest::header::COOKIE; @@ -951,6 +1025,57 @@ mod tests { assert_eq!(payload["data"]["email"], json!("user@example.com")); } + #[test] + fn usage_api_headers_use_bearer_api_key() { + let headers = admin_provider_ops_verify_headers( + "usage_api", + &Map::new(), + &Map::from_iter([("api_key".to_string(), json!("example-api-key"))]), + ) + .expect("headers should build"); + + assert_eq!( + headers + .get(reqwest::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer example-api-key") + ); + } + + #[test] + fn usage_api_verify_payload_reads_remaining_and_plan() { + let payload = admin_provider_ops_usage_api_verify_payload( + StatusCode::OK, + &json!({ + "remaining": 42.5, + "balance": 42.5, + "unit": "USD", + "isValid": true, + "planName": "Example Plan" + }), + ); + + assert_eq!(payload["success"], json!(true)); + assert_eq!(payload["data"]["username"], json!("Example Plan")); + assert_eq!(payload["data"]["quota"], json!(42.5)); + assert_eq!(payload["data"]["extra"]["unit"], json!("USD")); + assert_eq!(payload["data"]["extra"]["is_valid"], json!(true)); + } + + #[test] + fn usage_api_verify_payload_rejects_inactive_key() { + let payload = admin_provider_ops_usage_api_verify_payload( + StatusCode::OK, + &json!({ + "remaining": 0, + "isValid": false + }), + ); + + assert_eq!(payload["success"], json!(false)); + assert_eq!(payload["message"], json!("API Key 无效或已停用")); + } + #[test] fn anyrouter_verify_payload_uses_cookie_auth_messages_and_usage_fields() { let payload = admin_provider_ops_anyrouter_verify_payload(