feat(provider-ops): add generic usage API template

This commit is contained in:
Brisbanehuang
2026-08-29 09:04:05 -04:00
parent 6ec0771297
commit 4dbf98163e
4 changed files with 321 additions and 5 deletions
@@ -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,
}
}
+127 -2
View File
@@ -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(