diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs index 49b9b20be..9125bb00e 100644 --- a/src-tauri/src/commands/coding_plan.rs +++ b/src-tauri/src/commands/coding_plan.rs @@ -7,12 +7,19 @@ pub async fn get_coding_plan_quota( // 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。 access_key_id: Option, secret_access_key: Option, + // 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。 + coding_plan_provider: Option, + team_organization_id: Option, + team_project_id: Option, ) -> Result { crate::services::coding_plan::get_coding_plan_quota( &base_url, &api_key, access_key_id.as_deref(), secret_access_key.as_deref(), + coding_plan_provider.as_deref(), + team_organization_id.as_deref(), + team_project_id.as_deref(), ) .await } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 7a90e2e87..fa6952e4c 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -516,12 +516,20 @@ async fn query_provider_usage_inner( // 其他供应商为 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()); + // 智谱团队版:显式 provider 标识 + 组织/项目 ID(与个人版智谱 base_url 相同, + // 靠 coding_plan_provider == "zhipu_team" 在 service 层路由)。 + let coding_plan_provider = usage_script.and_then(|s| s.coding_plan_provider.clone()); + let team_organization_id = usage_script.and_then(|s| s.team_organization_id.clone()); + let team_project_id = usage_script.and_then(|s| s.team_project_id.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(), + coding_plan_provider.as_deref(), + team_organization_id.as_deref(), + team_project_id.as_deref(), ) .await .map_err(|e| format!("Failed to query coding plan: {e}"))?; @@ -1079,6 +1087,8 @@ mod native_query_credentials_tests { coding_plan_provider: coding_plan_provider.map(str::to_string), access_key_id: None, secret_access_key: None, + team_organization_id: None, + team_project_id: None, } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d41e4654d..80cd8eb21 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -267,6 +267,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, + /// 智谱团队套餐(Team Plan)的组织 ID(用量查询请求头 bigmodel-organization) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "teamOrganizationId")] + pub team_organization_id: Option, + /// 智谱团队套餐(Team Plan)的项目 ID(用量查询请求头 bigmodel-project) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "teamProjectId")] + pub team_project_id: Option, } /// 用量数据 diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 172e2df69..63bd727d2 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -364,18 +364,25 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> Result return Ok(make_error(format!("Failed to parse response: {e}"))), }; + Ok(zhipu_quota_from_body(&body)) +} + +/// 解析智谱额度响应体(个人版与团队版共用同一 shape)。 +/// 仅在 HTTP 成功、body 已完整读取并解析为 JSON 后调用——本函数不做任何网络 IO, +/// 故无瞬时失败通道,确定性失败直接落进 `Ok(success:false)`。 +fn zhipu_quota_from_body(body: &serde_json::Value) -> SubscriptionQuota { // 检查业务级别错误 if body.get("success").and_then(|v| v.as_bool()) == Some(false) { let msg = body .get("msg") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return Ok(make_error(format!("API error: {msg}"))); + return make_error(format!("API error: {msg}")); } let data = match body.get("data") { Some(d) => d, - None => return Ok(make_error("Missing 'data' field in response".to_string())), + None => return make_error("Missing 'data' field in response".to_string()), }; let tiers = parse_zhipu_token_tiers(data); @@ -386,7 +393,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> Result Result SubscriptionQuota { } } +// ── 智谱团队套餐(Team Plan)────────────────────────────────── +// +// 与个人版的差异仅在请求构造(参考 token-monitor/src/shared/zaiTeamLimits.js): +// - 固定走国内站 open.bigmodel.cn(团队版仅存在于国内站,z.ai 国际站无 team 档) +// - 同一 quota 路径加 `?type=2` +// - 额外请求头 bigmodel-organization / bigmodel-project(两者 + api_key 缺一不可) +// 响应 shape 与个人版完全一致 → 复用 zhipu_quota_from_body / parse_zhipu_token_tiers。 +const ZHIPU_TEAM_QUOTA_URL: &str = "https://open.bigmodel.cn/api/monitor/usage/quota/limit"; + +async fn query_zhipu_team( + api_key: &str, + organization_id: &str, + project_id: &str, +) -> Result { + query_zhipu_team_at(ZHIPU_TEAM_QUOTA_URL, api_key, organization_id, project_id).await +} + +/// 团队版额度查询。`quota_url_base` 为不含 query 的 quota 端点;团队版与个人版同路径, +/// 靠 `?type=2` 区分(在此拼上)。拆出 url 参数便于用本地 server 测试请求形状。 +async fn query_zhipu_team_at( + quota_url_base: &str, + api_key: &str, + organization_id: &str, + project_id: &str, +) -> Result { + let client = crate::proxy::http_client::get(); + let url = format!("{quota_url_base}?type=2"); + + let resp = client + .get(&url) + .header("Authorization", api_key) // 与个人版一致:智谱不加 Bearer 前缀 + .header("bigmodel-organization", organization_id) + .header("bigmodel-project", project_id) + .header("Content-Type", "application/json") + .header("Accept-Language", "en-US,en") + .timeout(std::time::Duration::from_secs(15)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return Err(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Ok(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(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); + } + + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { + Ok(v) => v, + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), + }; + + Ok(zhipu_quota_from_body(&body)) +} + /// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → /// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误) /// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。 +/// +/// `coding_plan_provider` 显式标识用于无法靠 base_url 区分的供应商(当前为智谱团队版 +/// `zhipu_team`——其 base_url 与个人版智谱相同);其余情况走 `detect_provider`。 pub async fn get_coding_plan_quota( base_url: &str, api_key: &str, access_key_id: Option<&str>, secret_access_key: Option<&str>, + coding_plan_provider: Option<&str>, + team_organization_id: Option<&str>, + team_project_id: Option<&str>, ) -> Result { + // 智谱团队版:base_url 与个人版智谱(open.bigmodel.cn)相同,detect_provider 无法 + // 区分,必须靠显式 coding_plan_provider == "zhipu_team" 路由。需 api_key + 组织 ID + // + 项目 ID 三者齐全,缺任一返回 NotFound 引导补全。 + if coding_plan_provider + .map(|p| p.eq_ignore_ascii_case("zhipu_team")) + .unwrap_or(false) + { + let organization_id = team_organization_id.unwrap_or("").trim(); + let project_id = team_project_id.unwrap_or("").trim(); + if api_key.trim().is_empty() || organization_id.is_empty() || project_id.is_empty() { + return Ok(coding_plan_not_found( + "Zhipu team plan needs the API key + organization ID + project ID", + )); + } + return query_zhipu_team(api_key, organization_id, project_id).await; + } + let provider = match detect_provider(base_url) { Some(p) => p, // 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败 @@ -1231,9 +1338,9 @@ pub async fn get_coding_plan_quota( mod tests { use super::{ 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, + query_zhipu_team_at, 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; @@ -1897,9 +2004,16 @@ mod tests { let port = listener.local_addr().expect("local addr").port(); drop(listener); - let result = - get_coding_plan_quota(&format!("http://127.0.0.1:{port}/zenmux"), "k", None, None) - .await; + let result = get_coding_plan_quota( + &format!("http://127.0.0.1:{port}/zenmux"), + "k", + None, + None, + None, + None, + None, + ) + .await; let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)"); assert!(err.contains("Network error"), "err={err}"); } @@ -1909,7 +2023,7 @@ mod tests { ensure_no_proxy_for_loopback(); let (base_url, handle) = spawn_once_server(None); - let result = get_coding_plan_quota(&base_url, "k", None, None).await; + let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await; let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)"); assert!(err.contains("Network error"), "err={err}"); handle.join().expect("server thread"); @@ -1926,7 +2040,7 @@ mod tests { .to_string(), )); - let result = get_coding_plan_quota(&base_url, "k", None, None).await; + let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await; let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)"); assert!(err.contains("Failed to read response"), "err={err}"); handle.join().expect("server thread"); @@ -1937,7 +2051,7 @@ mod tests { ensure_no_proxy_for_loopback(); let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}"))); - let quota = get_coding_plan_quota(&base_url, "k", None, None) + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) .await .expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案"); assert!(!quota.success); @@ -1953,7 +2067,7 @@ mod tests { let (base_url, handle) = spawn_once_server(Some(http_response("429 Too Many Requests", "slow down"))); - let quota = get_coding_plan_quota(&base_url, "k", None, None) + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) .await .expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类"); assert!(!quota.success); @@ -1970,7 +2084,7 @@ mod tests { // 完整读到响应体但不是 JSON → is_decode → 确定性解析失败 let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json"))); - let quota = get_coding_plan_quota(&base_url, "k", None, None) + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) .await .expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)"); assert!(!quota.success); @@ -1978,4 +2092,146 @@ mod tests { assert!(err.contains("Failed to parse response"), "err={err}"); handle.join().expect("server thread"); } + + // ── 智谱团队套餐(Team Plan)── + + /// 起一个只服务一次连接的本地 HTTP server,捕获原始请求文本(请求行 + 头), + /// 用于断言 team 查询发出的 URL query 与组织/项目请求头。`response=None` 时只捕获不回包。 + fn spawn_request_capturing_server( + response: Option, + ) -> ( + String, + std::sync::Arc>>, + std::thread::JoinHandle<()>, + ) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("local addr").port(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let captured_clone = captured.clone(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf: Vec = Vec::new(); + let mut tmp = [0u8; 4096]; + // GET 无 body,读到 header 末尾(\r\n\r\n)即可 + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut tmp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.len() > 16 * 1024 { + break; + } + } + } + } + *captured_clone.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned()); + if let Some(resp) = response { + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + } + }); + (format!("http://127.0.0.1:{port}/team"), captured, handle) + } + + #[tokio::test] + async fn zhipu_team_missing_creds_returns_not_found() { + // 团队版必需 api_key + 组织 ID + 项目 ID,缺任一在触网前返回 NotFound(不联网)。 + let cases: [(&str, Option<&str>, Option<&str>); 3] = [ + ("key", Some("org"), None), // 缺 project + ("key", None, Some("proj")), // 缺 organization + (" ", Some("org"), Some("proj")), // 空 api_key + ]; + for (api_key, org, project) in cases { + let q = get_coding_plan_quota( + "https://open.bigmodel.cn/api/coding", + api_key, + None, + None, + Some("zhipu_team"), + org, + project, + ) + .await + .expect("凭据缺失是确定性失败,保持 Ok(success:false)"); + assert!(!q.success, "应失败: api_key={api_key:?}"); + assert!( + matches!(q.credential_status, CredentialStatus::NotFound), + "应为 NotFound: api_key={api_key:?}" + ); + } + + // 标识大小写不敏感(eq_ignore_ascii_case),大写仍命中 team 分支并返回引导文案。 + let msg = get_coding_plan_quota( + "https://open.bigmodel.cn/api/coding", + "key", + None, + None, + Some("Zhipu_Team"), + None, + None, + ) + .await + .expect("ok") + .error + .expect("应有错误文案"); + assert!( + msg.contains("API key + organization ID + project ID"), + "err={msg}" + ); + } + + #[tokio::test] + async fn zhipu_team_request_carries_type2_and_org_project_headers() { + ensure_no_proxy_for_loopback(); + // 响应 shape 与个人版一致:两条 TOKENS_LIMIT(unit 3/6)→ five_hour + weekly。 + let body = serde_json::json!({ + "success": true, + "data": { + "level": "max", + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 26.0 }, + { "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 5.0 } + ] + } + }); + let body_str = body.to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body_str}", + body_str.len() + ); + let (base, captured, handle) = spawn_request_capturing_server(Some(resp)); + + let quota = query_zhipu_team_at(&base, "team-key", "org-xxx", "proj_xxx") + .await + .expect("2xx + 合法 body 应成功"); + handle.join().expect("server thread"); + + // 请求形状契约(与 token-monitor zaiTeamLimits 对齐): + // 同路径加 ?type=2 + bigmodel-organization / bigmodel-project 头 + 鉴权头。 + // reqwest/hyper 发头会小写化,故整体转小写做包含匹配。 + let raw = captured.lock().unwrap().clone().expect("应捕获到请求"); + let raw_lc = raw.to_lowercase(); + assert!(raw_lc.contains("/team?type=2"), "缺 ?type=2: {raw}"); + assert!( + raw_lc.contains("bigmodel-organization: org-xxx"), + "缺组织头: {raw}" + ); + assert!( + raw_lc.contains("bigmodel-project: proj_xxx"), + "缺项目头: {raw}" + ); + assert!( + raw_lc.contains("authorization: team-key"), + "缺鉴权头: {raw}" + ); + + // 解析复用个人版 zhipu_quota_from_body / parse_zhipu_token_tiers。 + assert!(quota.success); + assert_eq!(quota.tiers.len(), 2); + assert_eq!(quota.tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(quota.tiers[0].utilization, 26.0); + assert_eq!(quota.tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(quota.tiers[1].utilization, 5.0); + } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 8bbec5e33..f95d463a9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -273,6 +273,8 @@ mod tests { coding_plan_provider: None, access_key_id: Some("ak-test".to_string()), secret_access_key: Some("sk-test".to_string()), + team_organization_id: None, + team_project_id: None, } } diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 27488f353..ec827b2fd 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -39,6 +39,8 @@ import { formatUsageDataSummary } from "@/utils/usageDisplay"; */ const VOLCENGINE_KEY_CONSOLE_URL = "https://console.volcengine.com/iam/keymanage"; +// 智谱团队套餐用量页(组织 ID / 项目 ID 在此页 URL 或管理后台可见) +const ZHIPU_TEAM_USAGE_URL = "https://bigmodel.cn/coding-plan/team/usage-stats"; interface UsageScriptModalProps { provider: Provider; @@ -553,6 +555,7 @@ const UsageScriptModal: React.FC = ({ // 另用账号 AK/SK 签名查询控制面用量。 const isZenMux = script.codingPlanProvider === "zenmux"; const isVolcengine = script.codingPlanProvider === "volcengine"; + const isZhipuTeam = script.codingPlanProvider === "zhipu_team"; const baseUrl = isZenMux ? (script.baseUrl ?? "") : (providerCredentials.baseUrl ?? ""); @@ -565,6 +568,9 @@ const UsageScriptModal: React.FC = ({ apiKey, isVolcengine ? script.accessKeyId : undefined, isVolcengine ? script.secretAccessKey : undefined, + isZhipuTeam ? script.codingPlanProvider : undefined, + isZhipuTeam ? script.teamOrganizationId : undefined, + isZhipuTeam ? script.teamProjectId : undefined, ); if (quota.success && quota.tiers.length > 0) { const summary = quota.tiers @@ -746,9 +752,10 @@ const UsageScriptModal: React.FC = ({ providerCredentials.baseUrl, ); const provider = script.codingPlanProvider || autoDetected || "kimi"; - // ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除。 + // ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;智谱团队保留组织/项目 ID;其余清除。 const isZenMux = provider === "zenmux"; const isVolcengine = provider === "volcengine"; + const isZhipuTeam = provider === "zhipu_team"; setScript({ ...script, code: "", @@ -758,6 +765,10 @@ const UsageScriptModal: React.FC = ({ userId: undefined, accessKeyId: isVolcengine ? script.accessKeyId : undefined, secretAccessKey: isVolcengine ? script.secretAccessKey : undefined, + teamOrganizationId: isZhipuTeam + ? script.teamOrganizationId + : undefined, + teamProjectId: isZhipuTeam ? script.teamProjectId : undefined, codingPlanProvider: provider, }); } else if (presetName === TEMPLATE_TYPES.BALANCE) { @@ -1359,6 +1370,76 @@ const UsageScriptModal: React.FC = ({ )} + {/* 智谱团队套餐:需组织 ID + 项目 ID(api_key 沿用供应商推理凭据) */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "zhipu_team" && ( +
+
+

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

+

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

+

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

+
+ +
+
+ + + setScript({ + ...script, + teamOrganizationId: e.target.value, + }) + } + placeholder={t("usageScript.organizationIdPlaceholder")} + autoComplete="off" + className="border-white/10" + /> +
+ +
+ + + setScript({ + ...script, + teamProjectId: e.target.value, + }) + } + placeholder={t("usageScript.projectIdPlaceholder")} + autoComplete="off" + className="border-white/10" + /> +
+
+
+ )} + {/* 通用配置(始终显示) */}
{/* 超时时间 */} diff --git a/src/config/codingPlanProviders.ts b/src/config/codingPlanProviders.ts index 849d2bf3c..86ef36fc9 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" | "volcengine"; + id: "kimi" | "zhipu" | "zhipu_team" | "minimax" | "zenmux" | "volcengine"; /** UsageScriptModal 下拉显示用 */ label: string; /** base_url 匹配规则 */ @@ -25,6 +25,16 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [ label: "Zhipu GLM (智谱)", pattern: /bigmodel\.cn|api\.z\.ai/i, }, + { + // 智谱团队套餐(Team Plan)。base_url 与个人版智谱(open.bigmodel.cn)相同, + // 无法靠 base_url 自动区分——靠显式 codingPlanProvider === "zhipu_team" 路由。 + // 个人版 zhipu 排在前面,detectCodingPlanProvider 首匹配仍命中个人版, + // 故团队版永不被 injectCodingPlanUsageScript 自动注入(必须用户手动选)。 + // pattern 仅占位(下拉展示用),实际不参与自动检测。 + id: "zhipu_team", + label: "Zhipu GLM Team (智谱团队)", + pattern: /bigmodel\.cn/i, + }, { id: "minimax", label: "MiniMax", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 73ba13faf..e0e7aca32 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1592,6 +1592,12 @@ "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'.", "volcengineKeyConsoleLink": "Create keys at:", + "zhipuTeamHint": "Zhipu team plan usage query needs the team API key (reuses this provider's credentials) + organization ID + project ID (sent as bigmodel-organization / bigmodel-project request headers). Find the organization and project IDs in the team admin console's usage page URL.", + "zhipuTeamConsoleLink": "Team usage page:", + "organizationId": "Organization ID", + "organizationIdPlaceholder": "e.g. org-xxxxxx", + "projectId": "Project ID", + "projectIdPlaceholder": "e.g. proj-xxxxxx", "defaultPlan": "Default Plan", "queryFailedMessage": "Query failed", "queryScript": "Query script (JavaScript)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 36a46cd80..834d6dd0c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1592,6 +1592,12 @@ "secretAccessKey": "SecretAccessKey", "volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。", "volcengineKeyConsoleLink": "キー作成ページ:", + "zhipuTeamHint": "智譜チームプランの使用量照会には、チーム API キー(このプロバイダーの認証情報を流用)+ 組織 ID + プロジェクト ID が必要です(リクエストヘッダー bigmodel-organization / bigmodel-project として送信)。組織 ID とプロジェクト ID はチーム管理コンソールの使用量ページの URL で確認できます。", + "zhipuTeamConsoleLink": "チーム使用量ページ:", + "organizationId": "組織 ID", + "organizationIdPlaceholder": "例: org-xxxxxx", + "projectId": "プロジェクト ID", + "projectIdPlaceholder": "例: proj-xxxxxx", "defaultPlan": "デフォルトプラン", "queryFailedMessage": "照会に失敗しました", "queryScript": "照会スクリプト (JavaScript)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8ce60dbd6..1d3212828 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1564,6 +1564,12 @@ "secretAccessKey": "SecretAccessKey", "volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。", "volcengineKeyConsoleLink": "金鑰建立網址:", + "zhipuTeamHint": "智譜團隊套餐用量查詢需團隊 API Key(沿用本供應商憑據)+ 組織 ID + 專案 ID(作為請求標頭 bigmodel-organization / bigmodel-project)。組織與專案 ID 可在團隊管理後台用量頁的 URL 中找到。", + "zhipuTeamConsoleLink": "團隊用量頁:", + "organizationId": "組織 ID", + "organizationIdPlaceholder": "例如:org-xxxxxx", + "projectId": "專案 ID", + "projectIdPlaceholder": "例如:proj-xxxxxx", "defaultPlan": "預設方案", "queryFailedMessage": "查詢失敗", "queryScript": "查詢腳本(JavaScript)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6c93a8b9e..774bae37e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1592,6 +1592,12 @@ "secretAccessKey": "SecretAccessKey", "volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。", "volcengineKeyConsoleLink": "密钥创建地址:", + "zhipuTeamHint": "智谱团队套餐用量查询需团队 API Key(沿用本供应商凭据)+ 组织 ID + 项目 ID(作为请求头 bigmodel-organization / bigmodel-project)。组织与项目 ID 可在团队管理后台用量页的 URL 中找到。", + "zhipuTeamConsoleLink": "团队用量页:", + "organizationId": "组织 ID", + "organizationIdPlaceholder": "例如:org-xxxxxx", + "projectId": "项目 ID", + "projectIdPlaceholder": "例如:proj-xxxxxx", "defaultPlan": "默认套餐", "queryFailedMessage": "查询失败", "queryScript": "查询脚本(JavaScript)", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 0d2436308..728387223 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -12,12 +12,19 @@ export const subscriptionApi = { // 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。 accessKeyId?: string, secretAccessKey?: string, + // 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。 + codingPlanProvider?: string, + teamOrganizationId?: string, + teamProjectId?: string, ): Promise => invoke("get_coding_plan_quota", { baseUrl, apiKey, accessKeyId, secretAccessKey, + codingPlanProvider, + teamOrganizationId, + teamProjectId, }), getBalance: ( baseUrl: string, diff --git a/src/types.ts b/src/types.ts index ecc1e524b..6dcb66af6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,8 @@ export interface UsageScript { userId?: string; // 用户ID(NewAPI 模板使用) accessKeyId?: string; // 火山方舟 AccessKey ID(用量查询签名用,与推理 Key 分离) secretAccessKey?: string; // 火山方舟 SecretAccessKey + teamOrganizationId?: string; // 智谱团队套餐组织 ID(请求头 bigmodel-organization) + teamProjectId?: string; // 智谱团队套餐项目 ID(请求头 bigmodel-project) codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用) autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段