feat(provider): add Zhipu team plan quota query support (#5128)

智谱团队套餐(Team Plan)的额度查询与个人版不同:同一 quota 端点加
`?type=2`,需额外 `bigmodel-organization` / `bigmodel-project` 请求头
(api_key + 组织 ID + 项目 ID 三者缺一不可),且仅存在于国内站
open.bigmodel.cn。参考 token-monitor/src/shared/zaiTeamLimits.js 实现。

- Backend (services/coding_plan.rs): 新增 query_zhipu_team(固定 CN 站、
  ?type=2、org/project 头);抽出 zhipu_quota_from_body 与个人版共用解析;
  入口 get_coding_plan_quota 靠显式 coding_plan_provider == "zhipu_team"
  路由(base_url 与个人版智谱相同,detect_provider 无法区分)。新字段经
  UsageScript、IPC 命令、后台查询路径(query_provider_usage_inner)透传。
- Frontend: UsageScriptModal 新增「Zhipu GLM Team」选项 + 组织/项目 ID
  输入;模板切换时保留 team 字段;测试与保存逻辑按 team 传参。
- i18n: en/zh/zh-TW/ja 四个 locale 更新。
- Tests: 凭据校验/路由(缺任一凭据 → NotFound,标识大小写不敏感)+ 本地
  server 验证 ?type=2 与 org/project 请求头形状。

Co-authored-by: XuZhanXin <1239576606@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zhanxin.xu
2026-07-09 09:21:10 +08:00
committed by GitHub
parent 3538b39246
commit 95c917b337
14 changed files with 426 additions and 17 deletions
+7
View File
@@ -7,12 +7,19 @@ pub async fn get_coding_plan_quota(
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
access_key_id: Option<String>,
secret_access_key: Option<String>,
// 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。
coding_plan_provider: Option<String>,
team_organization_id: Option<String>,
team_project_id: Option<String>,
) -> Result<SubscriptionQuota, String> {
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
}
+10
View File
@@ -516,12 +516,20 @@ async fn query_provider_usage_inner(
// 其他供应商为 Noneservice 层沿用 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,
}
}
+2
View File
@@ -267,6 +267,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
coding_plan_provider: None,
access_key_id: None,
secret_access_key: None,
team_organization_id: None,
team_project_id: None,
};
Ok(Some(ProviderMeta {
+8
View File
@@ -259,6 +259,14 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "secretAccessKey")]
pub secret_access_key: Option<String>,
/// 智谱团队套餐(Team Plan)的组织 ID(用量查询请求头 bigmodel-organization
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "teamOrganizationId")]
pub team_organization_id: Option<String>,
/// 智谱团队套餐(Team Plan)的项目 ID(用量查询请求头 bigmodel-project
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "teamProjectId")]
pub team_project_id: Option<String>,
}
/// 用量数据
+271 -15
View File
@@ -364,18 +364,25 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> Result<SubscriptionQuota,
Err(e) => 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<SubscriptionQuota,
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(SubscriptionQuota {
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: level,
@@ -395,7 +402,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> Result<SubscriptionQuota,
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
})
}
}
// ── MiniMax ─────────────────────────────────────────────────
@@ -1178,15 +1185,115 @@ fn coding_plan_not_found(error: &str) -> 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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
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<SubscriptionQuota, String> {
// 智谱团队版: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>,
) -> (
String,
std::sync::Arc<std::sync::Mutex<Option<String>>>,
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::<String>));
let captured_clone = captured.clone();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf: Vec<u8> = 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_LIMITunit 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);
}
}
+2
View File
@@ -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,
}
}
+82 -1
View File
@@ -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<UsageScriptModalProps> = ({
// 另用账号 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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
</div>
)}
{/* 智谱团队套餐:需组织 ID + 项目 ID(api_key 沿用供应商推理凭据) */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zhipu_team" && (
<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.zhipuTeamHint")}
</p>
<p className="text-xs text-muted-foreground mt-1.5">
{t("usageScript.zhipuTeamConsoleLink")}{" "}
<button
type="button"
onClick={() =>
settingsApi.openExternal(ZHIPU_TEAM_USAGE_URL)
}
className="inline-flex items-center gap-1 text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors break-all align-baseline underline-offset-2 hover:underline"
>
{ZHIPU_TEAM_USAGE_URL}
<ExternalLink size={12} className="shrink-0" />
</button>
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="usage-zhipu-team-org">
{t("usageScript.organizationId")}
</Label>
<Input
id="usage-zhipu-team-org"
type="text"
value={script.teamOrganizationId || ""}
onChange={(e) =>
setScript({
...script,
teamOrganizationId: e.target.value,
})
}
placeholder={t("usageScript.organizationIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-zhipu-team-project">
{t("usageScript.projectId")}
</Label>
<Input
id="usage-zhipu-team-project"
type="text"
value={script.teamProjectId || ""}
onChange={(e) =>
setScript({
...script,
teamProjectId: e.target.value,
})
}
placeholder={t("usageScript.projectIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</div>
</div>
)}
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
+11 -1
View File
@@ -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",
+6
View File
@@ -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)",
+6
View File
@@ -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)",
+6
View File
@@ -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",
+6
View File
@@ -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",
+7
View File
@@ -12,12 +12,19 @@ export const subscriptionApi = {
// 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。
accessKeyId?: string,
secretAccessKey?: string,
// 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。
codingPlanProvider?: string,
teamOrganizationId?: string,
teamProjectId?: string,
): Promise<SubscriptionQuota> =>
invoke("get_coding_plan_quota", {
baseUrl,
apiKey,
accessKeyId,
secretAccessKey,
codingPlanProvider,
teamOrganizationId,
teamProjectId,
}),
getBalance: (
baseUrl: string,
+2
View File
@@ -64,6 +64,8 @@ export interface UsageScript {
userId?: string; // 用户IDNewAPI 模板使用)
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; // 自动查询间隔(分钟)- 别名字段