@@ -4,7 +4,7 @@
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
use super ::subscription ::{
CredentialStatus , QuotaTier , SubscriptionQuota , TIER_FIVE_HOUR , TIER_WEEKLY_LIMIT ,
CredentialStatus , QuotaTier , SubscriptionQuota , TIER_FIVE_HOUR , TIER_MONTHLY , TIER_WEEKLY_LIMIT ,
} ;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
@@ -17,6 +17,9 @@ enum CodingPlanProvider {
MiniMaxCn ,
MiniMaxEn ,
ZenMux ,
/// 火山方舟 Agent Plan / Coding Plan( base_url 形如
/// `https://ark.cn-beijing.volces.com/api/coding[/v3]`)。
Volcengine ,
}
fn detect_provider ( base_url : & str ) -> Option < CodingPlanProvider > {
@@ -33,6 +36,10 @@ fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
Some ( CodingPlanProvider ::MiniMaxEn )
} else if url . contains ( " zenmux " ) {
Some ( CodingPlanProvider ::ZenMux )
} else if url . contains ( " volces.com/api/coding " ) {
// 仅匹配 Coding/Agent Plan 入口;DouBaoSeed 按量付费走 /api/v3 与
// /api/compatible,没有套餐额度,不在此命中。
Some ( CodingPlanProvider ::Volcengine )
} else {
None
}
@@ -59,6 +66,10 @@ fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
return Some ( s . to_string ( ) ) ;
}
if let Some ( n ) = value . as_i64 ( ) {
// 0/负时间戳(如火山 session 无活跃窗口回 -1)视为无重置时间
if n < = 0 {
return None ;
}
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n } ;
return millis_to_iso8601 ( ms ) ;
@@ -652,43 +663,517 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec<QuotaTier> {
tiers
}
// ── 火山方舟 Agent Plan / Coding Plan ───────────────────────
//
// 与 Kimi/MiniMax(数据面 Bearer 余额接口)不同,火山用量接口是**控制面
// OpenAPI**:统一网关 `open.volcengineapi.com`( **不是**数据面推理域名
// `ark.cn-beijing.volces.com`),形如
// `POST https://open.volcengineapi.com/?Action=...&Version=2024-01-01&Region=cn-beijing`,
// **强制火山引擎签名 V4(AK/SK)**——实测复用推理 Bearer Key 会被网关以
// `400 InvalidAuthorization` 拒绝(格式层拒绝,非权限问题)。因此用户需在用量查询
// 里另填火山账号的 AccessKey ID + Secret(与推理 Key 是两套凭据)。两个 plan 用
// 同一份 AK/SK,故鉴权类错误直接停、不再试另一个 plan。
//
// 自动探测:先调 `GetAFPUsage`( Agent Plan,回绝对额度 Quota/Used),未订阅再调
// `GetCodingPlanUsage`( Coding Plan,回百分比)。
/// 控制面 OpenAPI 统一网关(区别于数据面推理域名 ark.cn-beijing.volces.com)。
const VOLCENGINE_OPENAPI_HOST : & str = " open.volcengineapi.com " ;
const VOLCENGINE_API_VERSION : & str = " 2024-01-01 " ;
/// ark 控制面 OpenAPI 的默认 Region( Agent/Coding Plan 目前在 cn-beijing)。
const VOLCENGINE_DEFAULT_REGION : & str = " cn-beijing " ;
/// 单次 OpenAPI 调用的归类结果。
enum VolcCall {
/// 2xx 且 JSON 可解析、无 OpenAPI 级错误(业务 Result 仍可能为空=未订阅)。
Body ( serde_json ::Value ) ,
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
/// 共用凭据,命中即停。
Auth ( String ) ,
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
Soft ( String ) ,
}
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
/// `ark.cn-beijing.volces.com` → `cn-beijing`);无法识别时回落 cn-beijing。
/// 控制面 Host 是固定网关(`VOLCENGINE_OPENAPI_HOST`),不随 base_url 变化。
fn volcengine_region ( base_url : & str ) -> String {
let host = base_url
. split_once ( " :// " )
. map ( | ( _ , rest ) | rest )
. unwrap_or ( base_url )
. split ( '/' )
. next ( )
. unwrap_or ( " " ) ;
host . split ( '.' )
. find ( | p | p . starts_with ( " cn- " ) | | p . starts_with ( " ap- " ) )
. map ( | p | p . to_string ( ) )
. unwrap_or_else ( | | VOLCENGINE_DEFAULT_REGION . to_string ( ) )
}
/// 判断 OpenAPI 错误码是否属于鉴权类(需要硬停并提示换 AK/SK)。
fn volcengine_is_auth_error_code ( code : & str ) -> bool {
let c = code . to_lowercase ( ) ;
c . contains ( " auth " )
| | c . contains ( " signature " )
| | c . contains ( " accessdenied " )
| | c . contains ( " denied " )
| | c . contains ( " unauthorized " )
| | c . contains ( " forbidden " )
| | c . contains ( " credential " )
| | c . contains ( " token " )
}
/// 提取火山 OpenAPI 响应里的 `ResponseMetadata.Error`(或顶层 `Error`)。
fn volcengine_response_error ( body : & serde_json ::Value ) -> Option < ( String , String ) > {
let err = body
. get ( " ResponseMetadata " )
. and_then ( | m | m . get ( " Error " ) )
. or_else ( | | body . get ( " Error " ) ) ? ;
let code = err
. get ( " Code " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " )
. to_string ( ) ;
let msg = err
. get ( " Message " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " )
. to_string ( ) ;
if code . is_empty ( ) & & msg . is_empty ( ) {
None
} else {
Some ( ( code , msg ) )
}
}
/// 鉴权失败时的引导文案,附加在错误后。
const VOLCENGINE_AKSK_HINT : & str =
" Check the AccessKey ID / Secret are correct and the account has Ark usage-query (OpenAPI) permission. " ;
// ── 火山引擎签名 V4(AK/SK)─────────────────────────────────
//
// 算法是 AWS SigV4 的火山变体(对照官方 volc-openapi-demos/signature/java/Sign.java)。
// **两处致命差异,照搬 s3.rs 的标准 SigV4 会签名失败**:
// 1. canonical headers 与 SignedHeaders 用**固定顺序**
// `host;x-date;x-content-sha256;content-type`( **不按字母序**, s3.rs 是字母序);
// 2. algorithm 串 `HMAC-SHA256`(无 `AWS4` 前缀)、credential scope 结尾 `request`
// (非 `aws4_request`)、签名密钥 `kDate=HMAC(SK, date)`( SK 不加 `AWS4` 前缀)。
// canonical query 仍按 key 字母序(与标准 SigV4 一致);service=`ark`、POST、空 body。
const VOLCENGINE_SERVICE : & str = " ark " ;
const VOLCENGINE_CONTENT_TYPE : & str = " application/json; charset=utf-8 " ;
const VOLCENGINE_SIGNED_HEADERS : & str = " host;x-date;x-content-sha256;content-type " ;
fn volc_hmac_sha256 ( key : & [ u8 ] , data : & [ u8 ] ) -> Vec < u8 > {
use hmac ::{ Hmac , Mac } ;
type HmacSha256 = Hmac < sha2 ::Sha256 > ;
let mut mac = HmacSha256 ::new_from_slice ( key ) . expect ( " HMAC accepts any key length " ) ;
mac . update ( data ) ;
mac . finalize ( ) . into_bytes ( ) . to_vec ( )
}
fn volc_sha256_hex ( data : & [ u8 ] ) -> String {
use sha2 ::{ Digest , Sha256 } ;
format! ( " {:x} " , Sha256 ::digest ( data ) )
}
/// RFC3986 unreserved 之外全部按 `%XX` 编码(用于 canonical query string)。
fn volc_uri_encode ( input : & str ) -> String {
let mut out = String ::with_capacity ( input . len ( ) ) ;
for byte in input . bytes ( ) {
match byte {
b 'A' ..= b 'Z' | b 'a' ..= b 'z' | b '0' ..= b '9' | b '-' | b '_' | b '.' | b '~' = > {
out . push ( byte as char )
}
_ = > {
use std ::fmt ::Write ;
let _ = write! ( out , " %{byte:02X} " ) ;
}
}
}
out
}
/// 构造按 key 字母序排序、逐段 URL 编码的 canonical query string。
/// 同一份字符串既用于签名也用于实际请求 URL,保证两者完全一致。
fn volcengine_canonical_query ( action : & str , region : & str ) -> String {
let mut pairs = [
( " Action " , action ) ,
( " Region " , region ) ,
( " Version " , VOLCENGINE_API_VERSION ) ,
] ;
pairs . sort_by ( | a , b | a . 0. cmp ( b . 0 ) ) ;
pairs
. iter ( )
. map ( | ( k , v ) | format! ( " {} = {} " , volc_uri_encode ( k ) , volc_uri_encode ( v ) ) )
. collect ::< Vec < _ > > ( )
. join ( " & " )
}
/// 生成火山引擎签名 V4 的鉴权头,返回 `(Authorization, X-Date, X-Content-Sha256)`,
/// 三者都要塞进请求头;`canonical_query` 必须与实际请求 URL 的 query 完全一致。
/// `now` 作参数传入便于写确定性单测。
fn volcengine_sign (
access_key_id : & str ,
secret_access_key : & str ,
region : & str ,
canonical_query : & str ,
body : & [ u8 ] ,
now : chrono ::DateTime < chrono ::Utc > ,
) -> ( String , String , String ) {
let x_date = now . format ( " %Y%m%dT%H%M%SZ " ) . to_string ( ) ;
let short_date = now . format ( " %Y%m%d " ) . to_string ( ) ;
let x_content_sha256 = volc_sha256_hex ( body ) ;
// 固定顺序 canonical headers(火山特有,**不排序**)。
let canonical_headers = format! (
" host: {VOLCENGINE_OPENAPI_HOST} \n x-date: {x_date} \n x-content-sha256: {x_content_sha256} \n content-type: {VOLCENGINE_CONTENT_TYPE} \n "
) ;
let canonical_request = format! (
" POST \n / \n {canonical_query} \n {canonical_headers} \n {VOLCENGINE_SIGNED_HEADERS} \n {x_content_sha256} "
) ;
let credential_scope = format! ( " {short_date} / {region} / {VOLCENGINE_SERVICE} /request " ) ;
let string_to_sign = format! (
" HMAC-SHA256 \n {x_date} \n {credential_scope} \n {} " ,
volc_sha256_hex ( canonical_request . as_bytes ( ) )
) ;
// 签名密钥派生:kDate=HMAC(SK, date)( SK **不加** AWS4 前缀),终止串 `request`。
let k_date = volc_hmac_sha256 ( secret_access_key . as_bytes ( ) , short_date . as_bytes ( ) ) ;
let k_region = volc_hmac_sha256 ( & k_date , region . as_bytes ( ) ) ;
let k_service = volc_hmac_sha256 ( & k_region , VOLCENGINE_SERVICE . as_bytes ( ) ) ;
let k_signing = volc_hmac_sha256 ( & k_service , b " request " ) ;
let signature : String = volc_hmac_sha256 ( & k_signing , string_to_sign . as_bytes ( ) )
. iter ( )
. map ( | b | format! ( " {b:02x} " ) )
. collect ( ) ;
let authorization = format! (
" HMAC-SHA256 Credential= {access_key_id} / {credential_scope} , SignedHeaders= {VOLCENGINE_SIGNED_HEADERS} , Signature= {signature} "
) ;
( authorization , x_date , x_content_sha256 )
}
async fn volcengine_openapi_call (
region : & str ,
access_key_id : & str ,
secret_access_key : & str ,
action : & str ,
) -> VolcCall {
let client = crate ::proxy ::http_client ::get ( ) ;
// canonical query 同时用于签名与实际 URL,确保两者逐字一致(否则签名不匹配)。
let canonical_query = volcengine_canonical_query ( action , region ) ;
let url = format! ( " https:// {VOLCENGINE_OPENAPI_HOST} /? {canonical_query} " ) ;
let body : & [ u8 ] = b " " ;
let ( authorization , x_date , x_content_sha256 ) = volcengine_sign (
access_key_id ,
secret_access_key ,
region ,
& canonical_query ,
body ,
chrono ::Utc ::now ( ) ,
) ;
let resp = client
. post ( & url )
. header ( " X-Date " , x_date )
. header ( " X-Content-Sha256 " , x_content_sha256 )
. header ( " Content-Type " , VOLCENGINE_CONTENT_TYPE )
. header ( " Authorization " , authorization )
. body ( body . to_vec ( ) )
. timeout ( std ::time ::Duration ::from_secs ( 15 ) )
. send ( )
. await ;
let resp = match resp {
Ok ( r ) = > r ,
Err ( e ) = > return VolcCall ::Soft ( format! ( " Network error: {e} " ) ) ,
} ;
let status = resp . status ( ) ;
if status = = reqwest ::StatusCode ::UNAUTHORIZED | | status = = reqwest ::StatusCode ::FORBIDDEN {
return VolcCall ::Auth ( format! (
" Authentication failed (HTTP {status} ). {VOLCENGINE_AKSK_HINT} "
) ) ;
}
if ! status . is_success ( ) {
// 火山 OpenAPI 网关对签名/凭据类错误常返 4xx(多为 HTTP 400)并携带与 200
// 路径相同的 ResponseMetadata.Error 信封,而非 401/403。这里也解析信封,让
// Bearer 被拒时仍能给出 AK/SK 引导并标记凭据失效,而不是当成普通 API 错误。
let raw = resp . text ( ) . await . unwrap_or_default ( ) ;
if let Ok ( body ) = serde_json ::from_str ::< serde_json ::Value > ( & raw ) {
if let Some ( ( code , msg ) ) = volcengine_response_error ( & body ) {
if volcengine_is_auth_error_code ( & code ) {
return VolcCall ::Auth ( format! (
" Authentication failed (HTTP {status} , {code} ): {msg} . {VOLCENGINE_AKSK_HINT} "
) ) ;
}
return VolcCall ::Soft ( format! ( " API error (HTTP {status} , {code} ): {msg} " ) ) ;
}
}
return VolcCall ::Soft ( format! ( " API error (HTTP {status} ): {raw} " ) ) ;
}
let body : serde_json ::Value = match resp . json ( ) . await {
Ok ( v ) = > v ,
Err ( e ) = > return VolcCall ::Soft ( format! ( " Failed to parse response: {e} " ) ) ,
} ;
// 火山 OpenAPI 业务错误常以 200 + ResponseMetadata.Error 返回。
if let Some ( ( code , msg ) ) = volcengine_response_error ( & body ) {
if volcengine_is_auth_error_code ( & code ) {
return VolcCall ::Auth ( format! (
" Authentication failed ( {code} ): {msg} . {VOLCENGINE_AKSK_HINT} "
) ) ;
}
return VolcCall ::Soft ( format! ( " API error ( {code} ): {msg} " ) ) ;
}
VolcCall ::Body ( body )
}
/// 解析 `GetAFPUsage` 的 `Result` 为 tier 列表。
///
/// 展示 5h / 周 / 月三个窗口(与控制台一致);`AFPDaily` 被官方控制台隐藏
/// (其 Quota 常高于周上限,属历史默认值而非强制限额),故跳过。
/// `Quota`/`Used` 是绝对 AFP 值,已用百分比 = Used/Quota× 100; `Quota<=0` 视为
/// 该窗口未订阅/未启用,跳过——也用于把"已鉴权但无 Agent Plan"识别为空结果,
/// 从而回落到 Coding Plan 探测。
fn parse_afp_tiers ( result : & serde_json ::Value ) -> Vec < QuotaTier > {
let mut tiers = Vec ::new ( ) ;
for ( key , name ) in [
( " AFPFiveHour " , TIER_FIVE_HOUR ) ,
( " AFPWeekly " , TIER_WEEKLY_LIMIT ) ,
( " AFPMonthly " , TIER_MONTHLY ) ,
] {
let Some ( win ) = result . get ( key ) else { continue } ;
let quota = win . get ( " Quota " ) . and_then ( parse_f64 ) . unwrap_or ( 0.0 ) ;
if quota < = 0.0 {
continue ;
}
let used = win . get ( " Used " ) . and_then ( parse_f64 ) . unwrap_or ( 0.0 ) ;
// 已用百分比;不做范围裁剪,与 parse_zhipu_token_tiers/parse_minimax_tiers
// 的约定一致(下游渲染层负责显示策略)。
let utilization = used / quota * 100.0 ;
let resets_at = win . get ( " ResetTime " ) . and_then ( extract_reset_time ) ;
tiers . push ( QuotaTier {
name : name . to_string ( ) ,
utilization ,
resets_at ,
used_value_usd : None ,
max_value_usd : None ,
} ) ;
}
tiers
}
/// 把 `GetCodingPlanUsage` 的 window 标签归一到 tier 名。
fn volcengine_coding_window ( label : & str ) -> Option < & 'static str > {
match label . to_lowercase ( ) . as_str ( ) {
" session " | " 5h " | " fivehour " | " five_hour " | " rolling_5h " = > Some ( TIER_FIVE_HOUR ) ,
" weekly " | " week " | " 7d " = > Some ( TIER_WEEKLY_LIMIT ) ,
" monthly " | " month " = > Some ( TIER_MONTHLY ) ,
_ = > None ,
}
}
/// 解析 `GetCodingPlanUsage` 的 `Result` 为 tier 列表(防御式)。
///
/// 该接口官方文档未给出逐字段规格,依据官方 ark-cli 描述:回 session/weekly/
/// monthly 窗口、**只给百分比**(已用)、重置时间是秒级。这里宽松匹配
/// `QuotaUsage`/`Usages`/`Details` 数组及多种字段名,命中即用、未命中跳过。
fn parse_coding_plan_tiers ( result : & serde_json ::Value ) -> Vec < QuotaTier > {
let mut tiers = Vec ::new ( ) ;
let arr = result
. get ( " QuotaUsage " )
. and_then ( | v | v . as_array ( ) )
. or_else ( | | result . get ( " Usages " ) . and_then ( | v | v . as_array ( ) ) )
. or_else ( | | result . get ( " Details " ) . and_then ( | v | v . as_array ( ) ) ) ;
let Some ( arr ) = arr else { return tiers } ;
for item in arr {
// 真实字段是 `Level`(实测 2026-06-21: session/weekly/monthly);其余作防御式 fallback。
let label = item
. get ( " Level " )
. and_then ( | v | v . as_str ( ) )
. or_else ( | | item . get ( " Type " ) . and_then ( | v | v . as_str ( ) ) )
. or_else ( | | item . get ( " Period " ) . and_then ( | v | v . as_str ( ) ) )
. or_else ( | | item . get ( " Label " ) . and_then ( | v | v . as_str ( ) ) )
. or_else ( | | item . get ( " Window " ) . and_then ( | v | v . as_str ( ) ) )
. unwrap_or ( " " ) ;
let Some ( name ) = volcengine_coding_window ( label ) else {
continue ;
} ;
let utilization = item
. get ( " Percent " )
. and_then ( parse_f64 )
. or_else ( | | item . get ( " UsedPercent " ) . and_then ( parse_f64 ) )
. or_else ( | | item . get ( " UsagePercent " ) . and_then ( parse_f64 ) )
. unwrap_or ( 0.0 ) ;
// 兼容秒/毫秒/字符串(extract_reset_time 内部已区分秒与毫秒)。
let resets_at = item
. get ( " ResetTime " )
. or_else ( | | item . get ( " ResetTimestamp " ) )
. and_then ( extract_reset_time ) ;
tiers . push ( QuotaTier {
name : name . to_string ( ) ,
utilization ,
resets_at ,
used_value_usd : None ,
max_value_usd : None ,
} ) ;
}
tiers
}
fn volcengine_success ( tiers : Vec < QuotaTier > , plan : Option < String > ) -> SubscriptionQuota {
SubscriptionQuota {
tool : " coding_plan " . to_string ( ) ,
credential_status : CredentialStatus ::Valid ,
credential_message : plan ,
success : true ,
tiers ,
extra_usage : None ,
error : None ,
queried_at : Some ( now_millis ( ) ) ,
}
}
fn volcengine_auth_error ( detail : String ) -> SubscriptionQuota {
SubscriptionQuota {
tool : " coding_plan " . to_string ( ) ,
credential_status : CredentialStatus ::Expired ,
credential_message : Some ( " Invalid API key " . to_string ( ) ) ,
success : false ,
tiers : vec ! [ ] ,
extra_usage : None ,
error : Some ( detail ) ,
queried_at : Some ( now_millis ( ) ) ,
}
}
async fn query_volcengine (
base_url : & str ,
access_key_id : & str ,
secret_access_key : & str ,
) -> SubscriptionQuota {
let region = volcengine_region ( base_url ) ;
let mut soft_errors : Vec < String > = Vec ::new ( ) ;
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
// 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。
let mut empty_responses : Vec < String > = Vec ::new ( ) ;
let summarize = | action : & str , body : & serde_json ::Value | -> String {
let raw : String = body . to_string ( ) . chars ( ) . take ( 700 ) . collect ( ) ;
format! ( " {action} = {raw} " )
} ;
// 1) Agent Plan: GetAFPUsage
match volcengine_openapi_call ( & region , access_key_id , secret_access_key , " GetAFPUsage " ) . await {
VolcCall ::Auth ( detail ) = > return volcengine_auth_error ( detail ) ,
VolcCall ::Soft ( detail ) = > soft_errors . push ( format! ( " GetAFPUsage: {detail} " ) ) ,
VolcCall ::Body ( body ) = > {
let result = body . get ( " Result " ) . unwrap_or ( & body ) ;
let tiers = parse_afp_tiers ( result ) ;
if ! tiers . is_empty ( ) {
let plan = result
. get ( " PlanType " )
. and_then ( | v | v . as_str ( ) )
. map ( str ::trim )
. filter ( | s | ! s . is_empty ( ) )
. map ( | s | format! ( " Agent Plan {s} " ) ) ;
return volcengine_success ( tiers , plan ) ;
}
empty_responses . push ( summarize ( " GetAFPUsage " , & body ) ) ;
}
}
// 2) Coding Plan: GetCodingPlanUsage
match volcengine_openapi_call (
& region ,
access_key_id ,
secret_access_key ,
" GetCodingPlanUsage " ,
)
. await
{
VolcCall ::Auth ( detail ) = > return volcengine_auth_error ( detail ) ,
VolcCall ::Soft ( detail ) = > soft_errors . push ( format! ( " GetCodingPlanUsage: {detail} " ) ) ,
VolcCall ::Body ( body ) = > {
let result = body . get ( " Result " ) . unwrap_or ( & body ) ;
let tiers = parse_coding_plan_tiers ( result ) ;
if ! tiers . is_empty ( ) {
return volcengine_success ( tiers , Some ( " Coding Plan " . to_string ( ) ) ) ;
}
empty_responses . push ( summarize ( " GetCodingPlanUsage " , & body ) ) ;
}
}
if ! soft_errors . is_empty ( ) {
make_error ( soft_errors . join ( " ; " ) )
} else if ! empty_responses . is_empty ( ) {
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
// 便于核对真实字段名/包裹层,或确认确实未订阅。
make_error ( format! (
" No active subscription found (signature OK). Raw: {} " ,
empty_responses . join ( " || " )
) )
} else {
make_error (
" No active Agent Plan or Coding Plan subscription found for this credential "
. to_string ( ) ,
)
}
}
// ── 公开入口 ────────────────────────────────────────────────
/// 构造"凭据缺失 / 域名未命中"的失败结果(NotFound 状态 + 明确错误文案)。
fn coding_plan_not_found ( error : & str ) -> SubscriptionQuota {
SubscriptionQuota {
tool : " coding_plan " . to_string ( ) ,
credential_status : CredentialStatus ::NotFound ,
credential_message : None ,
success : false ,
tiers : vec ! [ ] ,
extra_usage : None ,
error : Some ( error . to_string ( ) ) ,
queried_at : None ,
}
}
pub async fn get_coding_plan_quota (
base_url : & str ,
api_key : & str ,
access_key_id : Option < & str > ,
secret_access_key : Option < & str > ,
) -> Result < SubscriptionQuota , String > {
if api_key . trim ( ) . is_empty ( ) {
return Ok ( SubscriptionQuota {
tool : " coding_plan " . to_string ( ) ,
credential_status : CredentialStatus ::NotFound ,
credential_message : None ,
success : false ,
tiers : vec ! [ ] ,
extra_usage : None ,
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
error : Some ( " API key is empty " . to_string ( ) ) ,
queried_at : None ,
} ) ;
}
let provider = match detect_provider ( base_url ) {
Some ( p ) = > p ,
None = > {
return Ok ( SubscriptionQuota {
tool : " coding_plan " . to_string ( ) ,
credential_status : CredentialStatus ::NotFound ,
credential_message : None ,
success : false ,
tiers : vec ! [ ] ,
extra_usage : None ,
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
error : Some ( " Unknown coding plan provider " . to_string ( ) ) ,
queried_at : None ,
} ) ;
}
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
None = > return Ok ( coding_plan_not_found ( " Unknown coding plan provider " ) ) ,
} ;
// 火山方舟走控制面 AK/SK 签名(区别于其他供应商的数据面 Bearer api_key),凭据
// 校验与查询路径都不同,单独分支提前处理。
if let CodingPlanProvider ::Volcengine = provider {
let ak = access_key_id . unwrap_or ( " " ) . trim ( ) ;
let sk = secret_access_key . unwrap_or ( " " ) . trim ( ) ;
if ak . is_empty ( ) | | sk . is_empty ( ) {
return Ok ( coding_plan_not_found (
" Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key) " ,
) ) ;
}
return Ok ( query_volcengine ( base_url , ak , sk ) . await ) ;
}
// 其余供应商:数据面 Bearer api_key。
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
if api_key . trim ( ) . is_empty ( ) {
return Ok ( coding_plan_not_found ( " API key is empty " ) ) ;
}
let quota = match provider {
CodingPlanProvider ::Kimi = > query_kimi ( api_key ) . await ,
CodingPlanProvider ::ZhipuCn | CodingPlanProvider ::ZhipuEn = > {
@@ -697,6 +1182,10 @@ pub async fn get_coding_plan_quota(
CodingPlanProvider ::MiniMaxCn = > query_minimax ( api_key , true ) . await ,
CodingPlanProvider ::MiniMaxEn = > query_minimax ( api_key , false ) . await ,
CodingPlanProvider ::ZenMux = > query_zenmux ( base_url , api_key ) . await ,
// 火山已在上面的 AK/SK 分支提前返回,此处不可达。
CodingPlanProvider ::Volcengine = > {
unreachable! ( " volcengine handled via AK/SK branch above " )
}
} ;
Ok ( quota )
@@ -705,7 +1194,9 @@ pub async fn get_coding_plan_quota(
#[ cfg(test) ]
mod tests {
use super ::{
parse_minimax_tiers , parse_zhipu_token_tiers , zhipu_quota_base , TIER_FIVE_HOUR ,
parse_afp_tiers , parse_coding_plan_tiers , parse_minimax_tiers , parse_zhipu_token_tiers ,
volcengine_canonical_query , volcengine_is_auth_error_code , volcengine_region ,
volcengine_response_error , volcengine_sign , zhipu_quota_base , TIER_FIVE_HOUR , TIER_MONTHLY ,
TIER_WEEKLY_LIMIT ,
} ;
use serde_json ::json ;
@@ -1135,4 +1626,184 @@ mod tests {
" https://open.bigmodel.cn "
) ;
}
// ── 火山方舟 Agent Plan / Coding Plan ──
#[ test ]
fn volcengine_afp_three_windows_from_official_example ( ) {
// 官方文档 GetAFPUsage 返回示例(逐字):5h 25% / weekly 30% / monthly
// 42.525%; AFPDaily 被控制台隐藏,应跳过。
let result = json! ( {
" PlanType " : " Large " ,
" AFPFiveHour " : { " Quota " : 50.0 , " Used " : 12.5 , " SubscribeTime " : 1778788800000_ i64 , " ResetTime " : 1778806800000_ i64 } ,
" AFPDaily " : { " Quota " : 100.0 , " Used " : 22.5 , " SubscribeTime " : 1778716800000_ i64 , " ResetTime " : 1778803200000_ i64 } ,
" AFPWeekly " : { " Quota " : 500.0 , " Used " : 150.0 , " SubscribeTime " : 1778457600000_ i64 , " ResetTime " : 1779062400000_ i64 } ,
" AFPMonthly " : { " Quota " : 2000.0 , " Used " : 850.5 , " SubscribeTime " : 1777939200000_ i64 , " ResetTime " : 1780531200000_ i64 }
} ) ;
let tiers = parse_afp_tiers ( & result ) ;
assert_eq! ( tiers . len ( ) , 3 , " daily 应被跳过,只剩 5h/周/月 " ) ;
assert_eq! ( tiers [ 0 ] . name , TIER_FIVE_HOUR ) ;
assert! ( ( tiers [ 0 ] . utilization - 25.0 ) . abs ( ) < 1e-9 ) ;
assert! ( tiers [ 0 ] . resets_at . is_some ( ) ) ;
assert_eq! ( tiers [ 1 ] . name , TIER_WEEKLY_LIMIT ) ;
assert! ( ( tiers [ 1 ] . utilization - 30.0 ) . abs ( ) < 1e-9 ) ;
assert_eq! ( tiers [ 2 ] . name , TIER_MONTHLY ) ;
assert! ( ( tiers [ 2 ] . utilization - 42.525 ) . abs ( ) < 1e-9 ) ;
assert! ( tiers [ 2 ] . resets_at . is_some ( ) ) ;
}
#[ test ]
fn volcengine_afp_zero_quota_windows_treated_as_unbound ( ) {
// 已鉴权但无 Agent Plan:窗口 Quota=0 → 空结果,调用方据此回落 Coding Plan。
let result = json! ( {
" PlanType " : " " ,
" AFPFiveHour " : { " Quota " : 0.0 , " Used " : 0.0 } ,
" AFPWeekly " : { " Quota " : 0.0 , " Used " : 0.0 } ,
" AFPMonthly " : { " Quota " : 0.0 , " Used " : 0.0 }
} ) ;
assert! ( parse_afp_tiers ( & result ) . is_empty ( ) ) ;
}
#[ test ]
fn volcengine_afp_partial_windows_only_subscribed_ones ( ) {
// 仅 5h 窗口有额度(缺周/月)→ 只产出一个 tier。
let result = json! ( {
" AFPFiveHour " : { " Quota " : 40.0 , " Used " : 10.0 , " ResetTime " : 1778806800000_ i64 } ,
" AFPWeekly " : { " Quota " : 0.0 , " Used " : 0.0 }
} ) ;
let tiers = parse_afp_tiers ( & result ) ;
assert_eq! ( tiers . len ( ) , 1 ) ;
assert_eq! ( tiers [ 0 ] . name , TIER_FIVE_HOUR ) ;
assert! ( ( tiers [ 0 ] . utilization - 25.0 ) . abs ( ) < 1e-9 ) ;
}
#[ test ]
fn volcengine_coding_plan_real_response_levels ( ) {
// 真实 GetCodingPlanUsage 响应(用户实测 2026-06-21):字段名是 `Level`(非 `Type`),
// 仅百分比,秒级 ResetTimestamp; session 无活跃窗口回 -1 → 无重置时间。
let result = json! ( {
" Status " : " Running " ,
" UpdateTimestamp " : 1782053286_ i64 ,
" QuotaUsage " : [
{ " Level " : " session " , " Percent " : 0.0 , " ResetTimestamp " : - 1_ i64 } ,
{ " Level " : " weekly " , " Percent " : 1.672568 , " ResetTimestamp " : 1782057600_ i64 } ,
{ " Level " : " monthly " , " Percent " : 0.836284 , " ResetTimestamp " : 1784303999_ i64 }
]
} ) ;
let tiers = parse_coding_plan_tiers ( & result ) ;
assert_eq! ( tiers . len ( ) , 3 ) ;
assert_eq! ( tiers [ 0 ] . name , TIER_FIVE_HOUR ) ;
assert! ( ( tiers [ 0 ] . utilization - 0.0 ) . abs ( ) < 1e-9 ) ;
assert! (
tiers [ 0 ] . resets_at . is_none ( ) ,
" session ResetTimestamp=-1 应无重置时间 "
) ;
assert_eq! ( tiers [ 1 ] . name , TIER_WEEKLY_LIMIT ) ;
assert! ( ( tiers [ 1 ] . utilization - 1.672568 ) . abs ( ) < 1e-6 ) ;
assert! ( tiers [ 1 ] . resets_at . is_some ( ) ) ;
assert_eq! ( tiers [ 2 ] . name , TIER_MONTHLY ) ;
assert! ( ( tiers [ 2 ] . utilization - 0.836284 ) . abs ( ) < 1e-6 ) ;
}
#[ test ]
fn volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty ( ) {
let result = json! ( {
" QuotaUsage " : [
{ " Level " : " daily " , " Percent " : 9.0 } ,
{ " Level " : " weekly " , " Percent " : 20.0 }
]
} ) ;
let tiers = parse_coding_plan_tiers ( & result ) ;
assert_eq! ( tiers . len ( ) , 1 , " 未知 daily 窗口跳过 " ) ;
assert_eq! ( tiers [ 0 ] . name , TIER_WEEKLY_LIMIT ) ;
assert! ( parse_coding_plan_tiers ( & json! ( { } ) ) . is_empty ( ) ) ;
}
#[ test ]
fn volcengine_region_derivation ( ) {
assert_eq! (
volcengine_region ( " https://ark.cn-beijing.volces.com/api/coding " ) ,
" cn-beijing "
) ;
// 其他 region 的数据面域名按段提取。
assert_eq! (
volcengine_region ( " https://ark.cn-shanghai.volces.com/api/coding/v3 " ) ,
" cn-shanghai "
) ;
// 无可识别 region 段时回落默认 cn-beijing。
assert_eq! (
volcengine_region ( " https://example.com/api/coding " ) ,
" cn-beijing "
) ;
}
#[ test ]
fn volcengine_canonical_query_is_sorted_and_encoded ( ) {
// 按 key 字母序:Action < Region < Version;值含 `-` 属 unreserved,不编码。
assert_eq! (
volcengine_canonical_query ( " GetAFPUsage " , " cn-beijing " ) ,
" Action=GetAFPUsage&Region=cn-beijing&Version=2024-01-01 "
) ;
}
#[ test ]
fn volcengine_sign_structure_and_determinism ( ) {
// 没有服务端金标准向量时,锁定签名的结构契约 + 确定性(足以抓住 header 顺序、
// scope 后缀、algorithm 前缀、空 body hash 等实现错误)。真实正确性靠用户实测。
let now = chrono ::DateTime ::parse_from_rfc3339 ( " 2024-06-21T00:00:00Z " )
. unwrap ( )
. with_timezone ( & chrono ::Utc ) ;
let region = " cn-beijing " ;
let query = volcengine_canonical_query ( " GetAFPUsage " , region ) ;
let ( auth , x_date , x_content ) =
volcengine_sign ( " AKLTtest " , " secretkey " , region , & query , b " " , now ) ;
// 空 body 的 SHA-256(固定值),证明走的是空 body。
assert_eq! (
x_content ,
" e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 "
) ;
// X-Date 形如 20240621T000000Z。
assert_eq! ( x_date , " 20240621T000000Z " ) ;
// Authorization 结构:算法无 AWS4 前缀、scope 结尾 ark/request、固定 SignedHeaders。
assert! (
auth . starts_with ( " HMAC-SHA256 Credential=AKLTtest/20240621/cn-beijing/ark/request, " ) ,
" unexpected credential/scope: {auth} "
) ;
assert! (
auth . contains ( " SignedHeaders=host;x-date;x-content-sha256;content-type, " ) ,
" unexpected signed headers: {auth} "
) ;
// Signature 是 64 位十六进制。
let sig = auth . rsplit ( " Signature= " ) . next ( ) . unwrap ( ) ;
assert_eq! ( sig . len ( ) , 64 ) ;
assert! ( sig . bytes ( ) . all ( | b | b . is_ascii_hexdigit ( ) ) ) ;
// 确定性:同输入同输出。
let ( auth2 , _ , _ ) = volcengine_sign ( " AKLTtest " , " secretkey " , region , & query , b " " , now ) ;
assert_eq! ( auth , auth2 ) ;
}
#[ test ]
fn volcengine_auth_error_code_detection_and_extraction ( ) {
assert! ( volcengine_is_auth_error_code ( " AccessDenied " ) ) ;
assert! ( volcengine_is_auth_error_code ( " SignatureDoesNotMatch " ) ) ;
assert! ( volcengine_is_auth_error_code ( " InvalidAuthorization " ) ) ;
assert! ( volcengine_is_auth_error_code ( " Unauthorized " ) ) ;
assert! ( ! volcengine_is_auth_error_code ( " InvalidParameter.Action " ) ) ;
assert! ( ! volcengine_is_auth_error_code ( " InternalError " ) ) ;
// ResponseMetadata.Error 抽取
let body = json! ( {
" ResponseMetadata " : { " RequestId " : " x " , " Error " : { " Code " : " AccessDenied " , " Message " : " no permission " } }
} ) ;
let ( code , msg ) = volcengine_response_error ( & body ) . expect ( " 应抽到 Error " ) ;
assert_eq! ( code , " AccessDenied " ) ;
assert_eq! ( msg , " no permission " ) ;
// 无 Error 时返回 None
let ok_body = json! ( { " ResponseMetadata " : { " RequestId " : " x " } , " Result " : { } } ) ;
assert! ( volcengine_response_error ( & ok_body ) . is_none ( ) ) ;
}
}