fix(coding-plan): route Zhipu quota query to the user's configured base URL (#3702)

Fixes #3701.

`query_zhipu` was hard-coded to `https://api.z.ai`, so a user who
configured the mainland China preset (`Zhipu GLM` on
`open.bigmodel.cn`) could not retrieve usage once the international
endpoint became unreachable from their network (or vice versa).

The two endpoints share the same quota path (`/api/monitor/usage/quota/limit`)
and return JSON in the same shape, and — crucially — each user only
ever uses one of them: the quota host is the same host they're already
running coding on. So we can route by the configured `base_url` and
skip the cross-host fallback entirely.

What this PR changes
--------------------

A single helper that maps the user's `base_url` to the matching quota
host, and `query_zhipu` rebuilt to take `base_url` and pick the right
host:

    fn zhipu_quota_base(base_url: &str) -> &'static str {
        if base_url.contains("bigmodel.cn") {
            "https://open.bigmodel.cn"
        } else {
            "https://api.z.ai"
        }
    }

    async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
        let url = format!(
            "{}/api/monitor/usage/quota/limit",
            zhipu_quota_base(base_url),
        );
        // ... original 401/403 -> Expired / make_error / parse path, unchanged
    }

The dispatcher already distinguishes `ZhipuCn` from `ZhipuEn` via
`detect_provider()` and routes the call through
`query_zhipu(base_url, api_key)` in the same match arm.

Why no cross-host fallback
--------------------------

Farion's review pointed out that adding a fallback would be
over-engineered and actively harmful:

1. Reachability is determined by the preset the user chose. Their
   configured host is the host they are already using to run coding;
   if it were unreachable, the user could not have reached the
   "query usage" step at all.

2. The fallback path required distinguishing "both 401/403" (genuine
   bad key) from "one 401/403 + one network error" (regional block),
   which silently misclassified the second case as a generic query
   failure and hid the upstream "Session expired" UX for invalid
   keys.

3. It also cost the worst-case ~10s+10s≈20s serial timeout for users
   on a working primary.

With the URL-based routing in place, 401/403 returns to the original
`CredentialStatus::Expired` semantics — same UX as `query_kimi` and
`query_minimax`.

Files changed
-------------

- `src-tauri/src/services/coding_plan.rs` — 1 file, +35 / -20

Testing
-------

- 3 new `zhipu_quota_base_*` routing tests
- 15 existing `coding_plan` parser tests still pass
- `cargo fmt --check` clean
- `cargo clippy --lib --no-deps -- -D warnings` clean

Co-authored-by: Yongmao Luo <yongmao.luo@columbia.edu>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yongmao Luo
2026-06-04 23:10:45 +08:00
committed by GitHub
parent 8047f95416
commit ad030da3b1
+68 -6
View File
@@ -239,12 +239,31 @@ fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
.collect()
}
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
/// Resolve the Zhipu quota endpoint from the user's configured `base_url`.
///
/// Zhipu ships as two distinct presets (Zhipu GLM = `open.bigmodel.cn`,
/// Zhipu GLM en = `api.z.ai`) that share the same quota path and JSON shape.
/// The quota endpoint lives on the same host as the user's coding endpoint,
/// so we route by `base_url` and let the caller's existing reachability
/// (they're already using this host to run coding) determine success — no
/// cross-host fallback, no auth-error heuristics.
fn zhipu_quota_base(base_url: &str) -> &'static str {
if base_url.to_lowercase().contains("bigmodel.cn") {
"https://open.bigmodel.cn"
} else {
"https://api.z.ai"
}
}
async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
let url = format!(
"{}/api/monitor/usage/quota/limit",
zhipu_quota_base(base_url)
);
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
let resp = client
.get("https://api.z.ai/api/monitor/usage/quota/limit")
.get(&url)
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
.header("Content-Type", "application/json")
.header("Accept-Language", "en-US,en")
@@ -629,7 +648,9 @@ pub async fn get_coding_plan_quota(
let quota = match provider {
CodingPlanProvider::Kimi => query_kimi(api_key).await,
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await,
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
query_zhipu(base_url, api_key).await
}
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,
@@ -640,7 +661,10 @@ pub async fn get_coding_plan_quota(
#[cfg(test)]
mod tests {
use super::{parse_minimax_tiers, parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
use super::{
parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, TIER_FIVE_HOUR,
TIER_WEEKLY_LIMIT,
};
use serde_json::json;
#[test]
@@ -940,4 +964,42 @@ mod tests {
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 20.0);
}
#[test]
fn zhipu_quota_base_routes_bigmodel_url_to_cn_endpoint() {
assert_eq!(
zhipu_quota_base("https://open.bigmodel.cn/api/paas/v4"),
"https://open.bigmodel.cn"
);
}
#[test]
fn zhipu_quota_base_routes_z_ai_url_to_en_endpoint() {
assert_eq!(
zhipu_quota_base("https://api.z.ai/api/paas/v4"),
"https://api.z.ai"
);
}
#[test]
fn zhipu_quota_base_defaults_to_en_for_unknown_url() {
// 没有明显 Zhipu 域名特征时,默认走国际站(更通用的入口)
assert_eq!(
zhipu_quota_base("https://example.com/zhipu"),
"https://api.z.ai"
);
}
#[test]
fn zhipu_quota_base_routes_uppercase_cn_url_to_cn_endpoint() {
// 大小写不敏感:与 detect_provider 保持一致的约定,避免大写 preset URL 静默路由到国际站
assert_eq!(
zhipu_quota_base("HTTPS://OPEN.BIGMODEL.CN/api/paas/v4"),
"https://open.bigmodel.cn"
);
assert_eq!(
zhipu_quota_base("https://Open.BigModel.cn/api/paas/v4"),
"https://open.bigmodel.cn"
);
}
}