From c4630b5c26dc89dfc651d6fce7295ee2ced81a17 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 21 Jun 2026 23:13:03 +0800 Subject: [PATCH] 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 --- .../en/2-providers/2.5-usage-query.md | 2 +- .../ja/2-providers/2.5-usage-query.md | 2 +- .../zh/2-providers/2.5-usage-query.md | 2 +- src-tauri/src/commands/coding_plan.rs | 11 +- src-tauri/src/commands/provider.rs | 18 +- src-tauri/src/deeplink/provider.rs | 2 + src-tauri/src/provider.rs | 8 + src-tauri/src/services/coding_plan.rs | 729 +++++++++++++++++- src-tauri/src/services/subscription.rs | 5 + src-tauri/src/tray.rs | 35 +- src/components/SubscriptionQuotaFooter.tsx | 2 + src/components/UsageScriptModal.tsx | 93 ++- src/config/codingPlanProviders.ts | 10 +- src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/api/subscription.ts | 10 +- src/types.ts | 2 + 19 files changed, 905 insertions(+), 42 deletions(-) diff --git a/docs/user-manual/en/2-providers/2.5-usage-query.md b/docs/user-manual/en/2-providers/2.5-usage-query.md index 2b66e613f..d31c767ad 100644 --- a/docs/user-manual/en/2-providers/2.5-usage-query.md +++ b/docs/user-manual/en/2-providers/2.5-usage-query.md @@ -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. diff --git a/docs/user-manual/ja/2-providers/2.5-usage-query.md b/docs/user-manual/ja/2-providers/2.5-usage-query.md index 7adc23e50..6a6de6a44 100644 --- a/docs/user-manual/ja/2-providers/2.5-usage-query.md +++ b/docs/user-manual/ja/2-providers/2.5-usage-query.md @@ -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 | 公式残高クエリ | > **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。 diff --git a/docs/user-manual/zh/2-providers/2.5-usage-query.md b/docs/user-manual/zh/2-providers/2.5-usage-query.md index e78548e69..e3c6d2b8b 100644 --- a/docs/user-manual/zh/2-providers/2.5-usage-query.md +++ b/docs/user-manual/zh/2-providers/2.5-usage-query.md @@ -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 | 官方余额查询 | > 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。 diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs index 0654cd8e8..49b9b20be 100644 --- a/src-tauri/src/commands/coding_plan.rs +++ b/src-tauri/src/commands/coding_plan.rs @@ -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, + secret_access_key: Option, ) -> Result { - 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 } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 8c726af08..d61d677fb 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -514,9 +514,19 @@ 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) - .await - .map_err(|e| format!("Failed to query coding plan: {e}"))?; + // 火山方舟用账号 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}"))?; // 将 SubscriptionQuota 转换为 UsageResult if !quota.success { @@ -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, } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 84f467cd0..821475ccb 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, + /// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "accessKeyId")] + pub access_key_id: Option, + /// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "secretAccessKey")] + pub secret_access_key: Option, } /// 用量数据 diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 25c2f009b..19098db67 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -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 { @@ -33,6 +36,10 @@ fn detect_provider(base_url: &str) -> Option { 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 { 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 { 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 { + use hmac::{Hmac, Mac}; + type HmacSha256 = Hmac; + 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::>() + .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, +) -> (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::(&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 { + 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 { + 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, plan: Option) -> 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 = Vec::new(); + // 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅" + // 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。 + let mut empty_responses: Vec = 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 { - 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()); + } } diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 092100349..5528455e6 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -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"; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 61c2aa91e..ea614838a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -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( diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 65471ca8a..7d46bc376 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record = { 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", }; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index e05e1c733..44818e347 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -538,8 +538,10 @@ const UsageScriptModal: React.FC = ({ // 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 = ({ ? (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 = ({ 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 = ({ 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 = ({ )} + {/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "volcengine" && ( +
+
+

+ {t("usageScript.credentialsConfig")} +

+

+ {t("usageScript.volcengineAkSkHint")} +

+
+ +
+
+ + + setScript({ + ...script, + accessKeyId: e.target.value, + }) + } + placeholder="AKLT..." + autoComplete="off" + className="border-white/10" + /> +
+ +
+ +
+ + setScript({ + ...script, + secretAccessKey: e.target.value, + }) + } + placeholder="••••••••" + autoComplete="off" + className="border-white/10" + /> + {script.secretAccessKey && ( + + )} +
+
+
+
+ )} + {/* 通用配置(始终显示) */}
{/* 超时时间 */} diff --git a/src/config/codingPlanProviders.ts b/src/config/codingPlanProviders.ts index fee5b908e..849d2bf3c 100644 --- a/src/config/codingPlanProviders.ts +++ b/src/config/codingPlanProviders.ts @@ -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 */ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b7c3140c..2e80bd705 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ce7cec695..bfefcad50 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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}}後にリセット", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 58df9bb2f..ef2bb63c9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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}} 後重設", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0f6dc2cc0..d52bb694b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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}}后重置", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index d11000ffa..0d2436308 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -9,8 +9,16 @@ export const subscriptionApi = { getCodingPlanQuota: ( baseUrl: string, apiKey: string, + // 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。 + accessKeyId?: string, + secretAccessKey?: string, ): Promise => - invoke("get_coding_plan_quota", { baseUrl, apiKey }), + invoke("get_coding_plan_quota", { + baseUrl, + apiKey, + accessKeyId, + secretAccessKey, + }), getBalance: ( baseUrl: string, apiKey: string, diff --git a/src/types.ts b/src/types.ts index 0c3c33b0e..6e5f80274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; // 自动查询间隔(分钟)- 别名字段