mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
feat(provider-ops): add generic usage API template
This commit is contained in:
@@ -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<String, Value>,
|
||||
response_json: &Value,
|
||||
) -> Result<Value, String> {
|
||||
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<String, Value>,
|
||||
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(
|
||||
|
||||
@@ -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<Vec<ProviderOpsArchitectureSpec>> =
|
||||
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<Map
|
||||
"nekocode" => 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Map<String, Value>> {
|
||||
match action_type {
|
||||
"query_balance" => Some(json_object(json!({
|
||||
"endpoint": "/v1/usage",
|
||||
"method": "GET",
|
||||
"currency": "USD"
|
||||
}))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -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<HeaderMap, String> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user