mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(providers): add Volcengine Ark usage query with AK/SK signing
Query coding-plan / agent-plan usage for Volcengine Ark via the control-plane OpenAPI (open.volcengineapi.com), which requires account-level AccessKey signing rather than the inference API key. - Implement Volcengine Signature V4 (an AWS SigV4 variant: fixed canonical header order, "HMAC-SHA256" algorithm without the AWS4 prefix, scope ending in "request", service "ark") in services/coding_plan.rs - Auto-detect the plan by probing GetAFPUsage (Agent Plan, AFP five-hour/weekly/monthly quotas) first, falling back to GetCodingPlanUsage (Coding Plan, session/weekly/monthly percentages); a single credential covers whichever plan the account holds - Parse the real response shape: the window label lives in the `Level` field; guard ResetTimestamp <= 0 (session returns -1) - Store account-level credentials on UsageScript (accessKeyId / secretAccessKey), threaded through both the test command and the TOKEN_PLAN auto-refresh path - Add an independent AK/SK input block with a detailed hint pointing to the Volcengine console -> API Access Keys - Add the `monthly` tier label (frontend TIER_I18N_KEYS + tray TIER_LABEL_GROUPS + subscription service) - Sync zh / en / ja / zh-TW locales and the usage-query docs
This commit is contained in:
@@ -43,7 +43,7 @@ v3.13.0 provides **ready-to-use built-in templates** for the following categorie
|
||||
|
||||
| Category | Covered Providers | Template Type |
|
||||
| ------------------ | --------------------------------------------------------- | ------------------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | Plan quota (with usage progress) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine | Plan quota (with usage progress) |
|
||||
| Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query |
|
||||
|
||||
> **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic.
|
||||
|
||||
@@ -43,7 +43,7 @@ v3.13.0 では以下のカテゴリに **すぐに使える内蔵テンプレー
|
||||
|
||||
| カテゴリ | 対象プロバイダー | テンプレートタイプ |
|
||||
| --------------- | --------------------------------------------------------- | ------------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | プランクォータ(使用進捗付き) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine(火山方舟) | プランクォータ(使用進捗付き) |
|
||||
| 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ |
|
||||
|
||||
> **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。
|
||||
|
||||
@@ -43,7 +43,7 @@ v3.13.0 为以下类别提供了**开箱即用的内置模板**,启用后无
|
||||
|
||||
| 类别 | 覆盖供应商 | 模板类型 |
|
||||
| ---------- | --------------------------------------------------------- | ----------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | 套餐配额(带使用进度) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / 火山方舟 | 套餐配额(带使用进度) |
|
||||
| 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 |
|
||||
|
||||
> 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。
|
||||
|
||||
@@ -4,6 +4,15 @@ use crate::services::subscription::SubscriptionQuota;
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -514,7 +514,17 @@ async fn query_provider_usage_inner(
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
|
||||
// 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离);
|
||||
// 其他供应商为 None,service 层沿用 api_key。
|
||||
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
|
||||
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
@@ -1086,6 +1096,8 @@ mod native_query_credentials_tests {
|
||||
template_type: Some("token_plan".to_string()),
|
||||
auto_query_interval: None,
|
||||
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -251,6 +251,14 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "accessKeyId")]
|
||||
pub access_key_id: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "secretAccessKey")]
|
||||
pub secret_access_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -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}\nx-date:{x_date}\nx-content-sha256:{x_content_sha256}\ncontent-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(®ion, 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(
|
||||
®ion,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +307,11 @@ pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet";
|
||||
/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。
|
||||
pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
/// 月窗口 tier 名。火山方舟 Agent Plan / Coding Plan 有 5h / 周 / 月 三个展示
|
||||
/// 窗口(Kimi / MiniMax 只有 5h + 周),月窗口共用此标识;前端 `TIER_I18N_KEYS`
|
||||
/// 映射到 `subscription.monthly`。
|
||||
pub const TIER_MONTHLY: &str = "monthly";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
|
||||
+34
-1
@@ -19,6 +19,8 @@ const W_TIER_NAMES: &[&str] = &[
|
||||
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
|
||||
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
|
||||
];
|
||||
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。
|
||||
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
|
||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
@@ -26,6 +28,7 @@ const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
||||
("h", H_TIER_NAMES),
|
||||
("w", W_TIER_NAMES),
|
||||
("m", M_TIER_NAMES),
|
||||
("p", GEMINI_PRO_TIER_NAMES),
|
||||
("f", GEMINI_FLASH_TIER_NAMES),
|
||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||
@@ -878,7 +881,7 @@ mod tests {
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
@@ -1094,6 +1097,36 @@ mod tests {
|
||||
assert!(s.contains("w50%"), "expected w50% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_volcengine_three_tiers_with_monthly() {
|
||||
// 火山方舟 Agent Plan 回 5h/周/月三档,托盘应包含 m(月)窗口,
|
||||
// 不再静默丢弃。
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 25.0),
|
||||
usage_data(Some(TIER_WEEKLY_LIMIT), 30.0),
|
||||
usage_data(Some(TIER_MONTHLY), 42.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h25%"), "expected h25% in {s}");
|
||||
assert!(s.contains("w30%"), "expected w30% in {s}");
|
||||
assert!(s.contains("m42%"), "expected m42% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_monthly_only_renders_label_not_raw_name() {
|
||||
// 仅月窗口激活时不应回落到原始 "monthly" 机器名,而是走 m 标签。
|
||||
let r = usage_result(true, vec![usage_data(Some(TIER_MONTHLY), 60.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("m60%"), "expected m60% in {s}");
|
||||
assert!(
|
||||
!s.contains("monthly"),
|
||||
"raw tier name should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_official_subscription_claude_uses_h_and_w_labels() {
|
||||
let r = usage_result(
|
||||
|
||||
@@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
gemini_flash_lite: "subscription.geminiFlashLite",
|
||||
// Token Plan(five_hour 已在上方官方映射中)
|
||||
weekly_limit: "subscription.sevenDay",
|
||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||
monthly: "subscription.monthly",
|
||||
// GitHub Copilot
|
||||
premium: "subscription.copilotPremium",
|
||||
};
|
||||
|
||||
@@ -538,8 +538,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
// Coding Plan 模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
|
||||
// ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL
|
||||
// ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置,
|
||||
// 另用账号 AK/SK 签名查询控制面用量。
|
||||
const isZenMux = script.codingPlanProvider === "zenmux";
|
||||
const isVolcengine = script.codingPlanProvider === "volcengine";
|
||||
const baseUrl = isZenMux
|
||||
? (script.baseUrl ?? "")
|
||||
: (providerCredentials.baseUrl ?? "");
|
||||
@@ -547,7 +549,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
? (script.apiKey ?? "")
|
||||
: (providerCredentials.apiKey ?? "");
|
||||
const { subscriptionApi } = await import("@/lib/api/subscription");
|
||||
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
|
||||
const quota = await subscriptionApi.getCodingPlanQuota(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isVolcengine ? script.accessKeyId : undefined,
|
||||
isVolcengine ? script.secretAccessKey : undefined,
|
||||
);
|
||||
if (quota.success && quota.tiers.length > 0) {
|
||||
const summary = quota.tiers
|
||||
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
|
||||
@@ -726,8 +733,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
providerCredentials.baseUrl,
|
||||
);
|
||||
const provider = script.codingPlanProvider || autoDetected || "kimi";
|
||||
// ZenMux 允许手动填写 API Key 和 Base URL,不清除
|
||||
// ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除。
|
||||
const isZenMux = provider === "zenmux";
|
||||
const isVolcengine = provider === "volcengine";
|
||||
setScript({
|
||||
...script,
|
||||
code: "",
|
||||
@@ -735,6 +743,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
baseUrl: isZenMux ? script.baseUrl : undefined,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
accessKeyId: isVolcengine ? script.accessKeyId : undefined,
|
||||
secretAccessKey: isVolcengine ? script.secretAccessKey : undefined,
|
||||
codingPlanProvider: provider,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
|
||||
@@ -1246,6 +1256,83 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
script.codingPlanProvider === "volcengine" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t("usageScript.credentialsConfig")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("usageScript.volcengineAkSkHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-volcengine-ak">
|
||||
{t("usageScript.accessKeyId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-volcengine-ak"
|
||||
type="text"
|
||||
value={script.accessKeyId || ""}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
accessKeyId: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="AKLT..."
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-volcengine-sk">
|
||||
{t("usageScript.secretAccessKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="usage-volcengine-sk"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={script.secretAccessKey || ""}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
secretAccessKey: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="••••••••"
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
{script.secretAccessKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t("apiKeyInput.hide")
|
||||
: t("apiKeyInput.show")
|
||||
}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用配置(始终显示) */}
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
|
||||
{/* 超时时间 */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants";
|
||||
|
||||
export interface CodingPlanProviderEntry {
|
||||
/** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */
|
||||
id: "kimi" | "zhipu" | "minimax" | "zenmux";
|
||||
id: "kimi" | "zhipu" | "minimax" | "zenmux" | "volcengine";
|
||||
/** UsageScriptModal 下拉显示用 */
|
||||
label: string;
|
||||
/** base_url 匹配规则 */
|
||||
@@ -35,6 +35,14 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [
|
||||
label: "ZenMux",
|
||||
pattern: /zenmux\./i,
|
||||
},
|
||||
{
|
||||
// 火山方舟 Agent Plan / Coding Plan。base_url 形如
|
||||
// ark.cn-beijing.volces.com/api/coding[/v3];与后端 detect_provider 的
|
||||
// `volces.com/api/coding` 子串判断同效。
|
||||
id: "volcengine",
|
||||
label: "火山方舟 (Volcengine)",
|
||||
pattern: /volces\.com\/api\/coding/i,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */
|
||||
|
||||
@@ -1557,6 +1557,9 @@
|
||||
"accessTokenPlaceholder": "Generate in 'Security Settings'",
|
||||
"userId": "User ID",
|
||||
"userIdPlaceholder": "e.g., 114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.",
|
||||
"defaultPlan": "Default Plan",
|
||||
"queryFailedMessage": "Query failed",
|
||||
"queryScript": "Query script (JavaScript)",
|
||||
@@ -2856,6 +2859,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"copilotPremium": "Premium",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
|
||||
@@ -1557,6 +1557,9 @@
|
||||
"accessTokenPlaceholder": "「Security Settings」で生成",
|
||||
"userId": "ユーザー ID",
|
||||
"userIdPlaceholder": "例: 114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。",
|
||||
"defaultPlan": "デフォルトプラン",
|
||||
"queryFailedMessage": "照会に失敗しました",
|
||||
"queryScript": "照会スクリプト (JavaScript)",
|
||||
@@ -2856,6 +2859,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"monthly": "月間",
|
||||
"copilotPremium": "プレミアム",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
|
||||
@@ -1529,6 +1529,9 @@
|
||||
"accessTokenPlaceholder": "在「安全設定」裡產生",
|
||||
"userId": "使用者 ID",
|
||||
"userIdPlaceholder": "例如:114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。",
|
||||
"defaultPlan": "預設方案",
|
||||
"queryFailedMessage": "查詢失敗",
|
||||
"queryScript": "查詢腳本(JavaScript)",
|
||||
@@ -2828,6 +2831,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每週",
|
||||
"monthly": "每月",
|
||||
"copilotPremium": "進階請求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}} 後重設",
|
||||
|
||||
@@ -1557,6 +1557,9 @@
|
||||
"accessTokenPlaceholder": "在'安全设置'里生成",
|
||||
"userId": "用户 ID",
|
||||
"userIdPlaceholder": "例如:114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。",
|
||||
"defaultPlan": "默认套餐",
|
||||
"queryFailedMessage": "查询失败",
|
||||
"queryScript": "查询脚本(JavaScript)",
|
||||
@@ -2856,6 +2859,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"monthly": "每月",
|
||||
"copilotPremium": "高级请求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
|
||||
@@ -9,8 +9,16 @@ export const subscriptionApi = {
|
||||
getCodingPlanQuota: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
// 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。
|
||||
accessKeyId?: string,
|
||||
secretAccessKey?: string,
|
||||
): Promise<SubscriptionQuota> =>
|
||||
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
|
||||
invoke("get_coding_plan_quota", {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
}),
|
||||
getBalance: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface UsageScript {
|
||||
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
||||
accessToken?: string; // 访问令牌(NewAPI 模板使用)
|
||||
userId?: string; // 用户ID(NewAPI 模板使用)
|
||||
accessKeyId?: string; // 火山方舟 AccessKey ID(用量查询签名用,与推理 Key 分离)
|
||||
secretAccessKey?: string; // 火山方舟 SecretAccessKey
|
||||
codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
|
||||
autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段
|
||||
|
||||
Reference in New Issue
Block a user