mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -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_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; // 自动查询间隔(分钟)- 别名字段
|
||||
|
||||
Reference in New Issue
Block a user