From 4eeb6e242bff31216bb381c2cf663785bec4c8cc Mon Sep 17 00:00:00 2001 From: wenil Date: Fri, 10 Jul 2026 08:53:32 +0300 Subject: [PATCH] v3 M3: finalize z.ai against the real usage endpoint (monitor/quota/limit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probing a live GLM Coding Plan key showed the Anthropic-compat /v1/messages response carries NO rate-limit headers. z.ai instead exposes a dedicated status endpoint (found via github.com/rygel/AIUsageTracker): GET https://api.z.ai/api/monitor/usage/quota/limit Authorization: (no "Bearer") Accept-Language: en-US,en -> data.limits[] of TOKENS_LIMIT windows {percentage 0-100, nextResetTime ms, unit,number} + data.level (plan tier) ZaiProvider now GETs that: the shortest TOKENS_LIMIT window -> the 5h bar (s/sr), the longest -> the weekly bar (w/wr); the monthly TIME_LIMIT (web-tool quota) is ignored; level -> status ("Lite"/"Pro"/…), any window at 100% -> "limited". It's a status GET, so it does NOT spend the prompt-metered plan — no self-throttle needed, polls on the normal cadence. Monitor URL derives from the configured base host. Verified live: s=1% (5h, resets ~4.9h), w=2% (weekly, resets ~6.5d), st="Lite". test_zai rewritten for the JSON envelope (9 tests). probe_zai.py repointed to the monitor endpoint. 122 passed, 2 Linux-only failures (baseline). Co-Authored-By: Claude Opus 4.8 --- daemon/providers/zai.py | 160 +++++++++++++++++++----------------- daemon/tests/test_zai.py | 172 ++++++++++++++++++++++----------------- tools/probe_zai.py | 43 +++------- 3 files changed, 192 insertions(+), 183 deletions(-) diff --git a/daemon/providers/zai.py b/daemon/providers/zai.py index 53960bb..98207a9 100644 --- a/daemon/providers/zai.py +++ b/daemon/providers/zai.py @@ -1,34 +1,51 @@ """z.ai (GLM Coding Plan) provider (v3 M3). -z.ai exposes an Anthropic-compatible endpoint (default -``https://api.z.ai/api/anthropic``), so we reach it the same way Claude Code does -with ``ANTHROPIC_BASE_URL`` pointed at z.ai: a ``/v1/messages`` POST with a Bearer -API key. The key + base URL come from the config the control panel writes -(``providers.zai.base_url`` / ``.api_key``) — the "paste your key" field. +z.ai exposes a dedicated usage endpoint that returns the Coding Plan's rate-limit +windows directly — no need to send a (quota-consuming) chat request: -Two z.ai-specific cares vs. Anthropic: - * Auth is a static API key (Bearer), not the OAuth token — no self-refresh. - * The GLM Coding Plan meters by *prompts*, so polling every 60s would burn the - user's quota. We self-throttle: hit the network at most once per - ``poll_interval_s`` (default 15 min) and serve a cached status in between. + GET https://api.z.ai/api/monitor/usage/quota/limit + Authorization: # raw key, NOT "Bearer …" + Accept-Language: en-US,en -Rate-limit parsing assumes z.ai proxies Anthropic's unified rate-limit headers. -If a real z.ai response doesn't carry them, poll() still reports "connected" and -logs the limit-ish headers it DID return, so the mapping can be finalized against -a live key (see tools/probe_zai.py). + {"code":200,"success":true,"data":{"level":"lite","limits":[ + {"type":"TIME_LIMIT", "unit":5,"number":1, ...}, # monthly web-tool quota (ignored) + {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":1,"nextResetTime":}, # 5h window + {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":2,"nextResetTime":}]}} # weekly window + +We surface the two TOKENS_LIMIT windows as Claude's short (5h) + weekly bars: of +the token windows, the shortest is the 5h bar and the longest the weekly bar. +``percentage`` is already 0-100; ``nextResetTime`` is epoch ms. The key + base URL +come from the config the control panel's z.ai field writes. This is a status GET, +so — unlike a chat call — it doesn't spend the prompt-metered plan, and can poll +on the normal cadence. + +Discovered from github.com/rygel/AIUsageTracker (ZaiProvider.cs). """ from __future__ import annotations import time +from urllib.parse import urlsplit import httpx from .base import Provider, ProviderStatus -DEFAULT_BASE = "https://api.z.ai/api/anthropic" -DEFAULT_MODEL = "glm-4.6" -DEFAULT_POLL_INTERVAL_S = 900 # protect the prompt-metered plan: >= 15 min between calls +DEFAULT_HOST = "https://api.z.ai" +_MONITOR_PATH = "/api/monitor/usage/quota/limit" + +# z.ai window "unit" enum → seconds, used only to rank the token windows by length +# (shortest = the 5h bar, longest = the weekly bar). Best-effort; unknown → hours. +_UNIT_SECONDS = {1: 1, 2: 60, 3: 3600, 4: 86400, 5: 2_592_000, 6: 604_800} + + +def _monitor_url(base_url: str) -> str: + """The usage endpoint on the same host as the configured (Anthropic-compat) + base URL, or the public z.ai host when unset.""" + parts = urlsplit(base_url or "") + if parts.scheme and parts.netloc: + return f"{parts.scheme}://{parts.netloc}{_MONITOR_PATH}" + return DEFAULT_HOST + _MONITOR_PATH class ZaiProvider(Provider): @@ -38,8 +55,6 @@ class ZaiProvider(Provider): def __init__(self, transport: httpx.BaseTransport | None = None) -> None: self._transport = transport # tests inject an httpx.MockTransport - self._cache: ProviderStatus | None = None - self._cache_at = 0.0 async def poll(self) -> ProviderStatus: from daemon.claude_usage_daemon_windows import log @@ -50,45 +65,20 @@ class ZaiProvider(Provider): conf = provider_conf(load_config(), "zai") key = (conf.get("api_key") or "").strip() - base = (conf.get("base_url") or "").strip().rstrip("/") or DEFAULT_BASE - model = (conf.get("model") or "").strip() or DEFAULT_MODEL - try: - interval = float(conf.get("poll_interval_s") or DEFAULT_POLL_INTERVAL_S) - except (TypeError, ValueError): - interval = DEFAULT_POLL_INTERVAL_S - + base = (conf.get("base_url") or "").strip() if not key: return ProviderStatus(ok=False, st="no key", auth_problem=True) - # Self-throttle: reuse the cached reading until the interval elapses, so the - # prompt-metered plan isn't drained by the 60s daemon poll. - now = time.time() - if self._cache is not None and (now - self._cache_at) < interval: - return self._cache - - st = await self._fetch(base, key, model, log) - # Cache stable outcomes (fresh data or a definitive auth failure); leave a - # transient blip uncached so it retries on the next tick. - if st.ok or st.auth_problem: - self._cache, self._cache_at = st, now - return st - - async def _fetch(self, base: str, key: str, model: str, log) -> ProviderStatus: - url = f"{base}/v1/messages" - headers = { - "Authorization": f"Bearer {key}", - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - } - body = {"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]} + url = _monitor_url(base) + headers = {"Authorization": key, "Accept-Language": "en-US,en"} client_kw = {"timeout": 20.0} if self._transport is not None: client_kw["transport"] = self._transport try: async with httpx.AsyncClient(**client_kw) as http: - resp = await http.post(url, headers=headers, json=body) + resp = await http.get(url, headers=headers) except httpx.HTTPError as e: - log(f"z.ai call failed: {e}") # transient — no auth flag + log(f"z.ai call failed: {e}") # transient — retry next tick return ProviderStatus(ok=False, st="offline") if resp.status_code in (401, 403): log(f"z.ai auth rejected: HTTP {resp.status_code}") @@ -96,40 +86,58 @@ class ZaiProvider(Provider): if resp.status_code >= 400: log(f"z.ai HTTP {resp.status_code}: {resp.text[:150]}") return ProviderStatus(ok=False, st=f"http {resp.status_code}") - return self._parse(resp, log) + try: + body = resp.json() + except ValueError: + log("z.ai: non-JSON usage response") + return ProviderStatus(ok=False, st="error") + if not body.get("success") or not isinstance(body.get("data"), dict): + # e.g. {"code":401,...,"success":false} → treat as an auth/config problem + code = body.get("code") + log(f"z.ai usage error: code={code} msg={body.get('msg')!r}") + return ProviderStatus(ok=False, st="bad key", + auth_problem=code in (401, 403)) + return self._to_status(body["data"]) @staticmethod - def _parse(resp: httpx.Response, log) -> ProviderStatus: - h = resp.headers - now = time.time() + def _to_status(data: dict) -> ProviderStatus: + limits = data.get("limits") or [] + # Only the token windows are the coding rate limit; TIME_LIMIT is the + # separate monthly web-tool quota. + toks = [l for l in limits if isinstance(l, dict) and l.get("type") == "TOKENS_LIMIT"] - def pct(util: str) -> float: - # Anthropic reports utilization as a 0-1 fraction. + def win_seconds(l: dict) -> int: + return _UNIT_SECONDS.get(l.get("unit"), 3600) * int(l.get("number") or 1) + + def pct(l: dict | None) -> float: + if not l: + return 0.0 try: - return round(float(util) * 100.0, 1) + return round(float(l.get("percentage") or 0.0), 1) except (TypeError, ValueError): return 0.0 - def reset_min(ts: str) -> int: - try: - m = (float(ts) - now) / 60.0 - except (TypeError, ValueError): + def reset_min(l: dict | None) -> int: + ts = l.get("nextResetTime") if l else None + if not isinstance(ts, (int, float)): return -1 + if ts > 1e10: # epoch ms → s + ts = ts / 1000.0 + m = (ts - time.time()) / 60.0 return int(round(m)) if m > 0 else -1 - # Assume z.ai proxies Anthropic's unified rate-limit headers. - if h.get("anthropic-ratelimit-unified-5h-utilization") is not None: - return ProviderStatus( - s=pct(h.get("anthropic-ratelimit-unified-5h-utilization")), - sr=reset_min(h.get("anthropic-ratelimit-unified-5h-reset")), - w=pct(h.get("anthropic-ratelimit-unified-7d-utilization")), - wr=reset_min(h.get("anthropic-ratelimit-unified-7d-reset")), - st=h.get("anthropic-ratelimit-unified-5h-status", "allowed"), - ok=True, - ) - # Connected but no known limit headers — surface what z.ai DID return so the - # mapping can be finalized against a live response (tools/probe_zai.py). - limitish = sorted(k for k in h.keys() - if any(w in k.lower() for w in ("limit", "rate", "quota", "usage"))) - log(f"z.ai: no unified rate-limit headers; limit-ish headers = {limitish or ''}") - return ProviderStatus(ok=True, st="allowed") + short = min(toks, key=win_seconds) if toks else None + weekly = max(toks, key=win_seconds) if len(toks) > 1 else None + + level = str(data.get("level") or "").strip() + st = level.capitalize() if level else "allowed" + if pct(short) >= 100 or pct(weekly) >= 100: + st = "limited" + + if not toks: # authenticated but no token windows reported + return ProviderStatus(ok=True, st=st or "allowed") + return ProviderStatus( + s=pct(short), sr=reset_min(short), + w=pct(weekly), wr=reset_min(weekly), + st=st, ok=True, + ) diff --git a/daemon/tests/test_zai.py b/daemon/tests/test_zai.py index 55e8c4f..28b2bee 100644 --- a/daemon/tests/test_zai.py +++ b/daemon/tests/test_zai.py @@ -1,7 +1,8 @@ -"""v3 M3 — z.ai (GLM) provider: Anthropic-compatible poll + quota-safe throttle. +"""v3 M3 — z.ai (GLM) provider: usage from the monitor/quota endpoint. -Uses httpx.MockTransport so no real z.ai call is made. Covers header mapping, -auth rejection, the no-known-headers fallback, and the self-throttle cache. +Uses httpx.MockTransport so no real z.ai call is made. Covers the token-window +mapping (5h -> s, weekly -> w), raw-key auth, the ignored monthly TIME_LIMIT, +and auth/error handling. """ import asyncio @@ -10,7 +11,7 @@ import time import httpx -from daemon.providers.zai import ZaiProvider, DEFAULT_BASE +from daemon.providers.zai import ZaiProvider, DEFAULT_HOST, _MONITOR_PATH def _run(coro): @@ -33,108 +34,127 @@ def _config(tmp_path, monkeypatch, zai: dict): monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) -def _transport(handler): - return httpx.MockTransport(handler) +def _tok(pct, reset_ms, unit=3, number=5): + return {"type": "TOKENS_LIMIT", "unit": unit, "number": number, + "percentage": pct, "nextResetTime": reset_ms} -def test_maps_unified_rate_limit_headers(tmp_path, monkeypatch): - _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai"}) - now = time.time() +def _time_limit(pct=0): + return {"type": "TIME_LIMIT", "unit": 5, "number": 1, "usage": 100, + "currentValue": 0, "remaining": 100, "percentage": pct, + "nextResetTime": 9999999999999, "usageDetails": []} + + +def _ok_resp(limits, level="lite"): + return httpx.Response(200, json={"code": 200, "msg": "Operation successful", + "success": True, "data": {"level": level, "limits": limits}}) + + +def _provider_returning(resp_or_fn): + def handler(request): + return resp_or_fn(request) if callable(resp_or_fn) else resp_or_fn + return ZaiProvider(transport=httpx.MockTransport(handler)) + + +def test_maps_token_windows(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai", + "base_url": "https://api.z.ai/api/anthropic"}) + now_ms = time.time() * 1000 seen = {} def handler(request: httpx.Request) -> httpx.Response: seen["url"] = str(request.url) seen["auth"] = request.headers.get("authorization") - return httpx.Response(200, headers={ - "anthropic-ratelimit-unified-5h-utilization": "0.42", - "anthropic-ratelimit-unified-5h-reset": str(now + 3600), - "anthropic-ratelimit-unified-5h-status": "allowed", - "anthropic-ratelimit-unified-7d-utilization": "0.10", - "anthropic-ratelimit-unified-7d-reset": str(now + 7200), - }, json={"ok": True}) + seen["lang"] = request.headers.get("accept-language") + seen["method"] = request.method + return _ok_resp([ + _time_limit(), # ignored monthly web-tool quota + _tok(1, now_ms + 3600_000, unit=3, number=5), # 5h window -> short + _tok(2, now_ms + 6 * 86400_000, unit=6, number=1), # weekly window -> weekly + ]) - st = _run(ZaiProvider(transport=_transport(handler)).poll()) + st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll()) assert st.ok is True - assert st.s == 42.0 and 58 <= st.sr <= 60 - assert st.w == 10.0 and 118 <= st.wr <= 120 - assert st.st == "allowed" - assert seen["url"] == f"{DEFAULT_BASE}/v1/messages" # default base used - assert seen["auth"] == "Bearer sk-zai" # Bearer auth + assert st.s == 1.0 and 58 <= st.sr <= 60 + assert st.w == 2.0 and 8000 <= st.wr <= 8700 # ~6 days in minutes + assert st.st == "Lite" + # raw key auth (no "Bearer"), monitor endpoint on the configured host, GET + assert seen["method"] == "GET" + assert seen["url"] == f"{DEFAULT_HOST}{_MONITOR_PATH}" + assert seen["auth"] == "sk-zai" + assert seen["lang"] == "en-US,en" -def test_no_key_reports_no_key(tmp_path, monkeypatch): +def test_no_key(tmp_path, monkeypatch): _config(tmp_path, monkeypatch, {"enabled": True, "api_key": ""}) - called = {"n": 0} + calls = {"n": 0} def handler(request): - called["n"] += 1 - return httpx.Response(200) + calls["n"] += 1 + return _ok_resp([]) - st = _run(ZaiProvider(transport=_transport(handler)).poll()) + st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll()) assert st.ok is False and st.st == "no key" and st.auth_problem is True - assert called["n"] == 0 # never hit the network + assert calls["n"] == 0 -def test_auth_rejection(tmp_path, monkeypatch): +def test_http_401_is_bad_key(tmp_path, monkeypatch): _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"}) - - def handler(request): - return httpx.Response(401, json={"error": "unauthorized"}) - - st = _run(ZaiProvider(transport=_transport(handler)).poll()) + p = _provider_returning(httpx.Response(401, json={"error": "unauthorized"})) + st = _run(p.poll()) assert st.ok is False and st.st == "bad key" and st.auth_problem is True -def test_connected_without_known_headers(tmp_path, monkeypatch): +def test_body_success_false_is_bad_key(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"}) + p = _provider_returning(httpx.Response(200, json={"code": 401, "msg": "auth", "success": False, "data": None})) + st = _run(p.poll()) + assert st.ok is False and st.st == "bad key" and st.auth_problem is True + + +def test_single_token_window(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) + now_ms = time.time() * 1000 + p = _provider_returning(_ok_resp([_tok(37, now_ms + 3600_000)])) + st = _run(p.poll()) + assert st.s == 37.0 and st.w == 0.0 and st.wr == -1 and st.ok is True + + +def test_hundred_percent_is_limited(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) + now_ms = time.time() * 1000 + p = _provider_returning(_ok_resp([ + _tok(100, now_ms + 60_000, unit=3, number=5), + _tok(40, now_ms + 6 * 86400_000, unit=6, number=1)])) + st = _run(p.poll()) + assert st.st == "limited" and st.s == 100.0 + + +def test_only_time_limit_reports_connected(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) + p = _provider_returning(_ok_resp([_time_limit(5)], level="pro")) + st = _run(p.poll()) + assert st.ok is True and st.st == "Pro" and st.s == 0.0 + + +def test_transient_error_reported(tmp_path, monkeypatch): _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) def handler(request): - return httpx.Response(200, headers={"x-some-quota": "5"}, json={"ok": True}) + raise httpx.ConnectError("boom") - st = _run(ZaiProvider(transport=_transport(handler)).poll()) - assert st.ok is True and st.st == "allowed" and st.s == 0.0 + st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll()) + assert st.ok is False and st.st == "offline" -def test_custom_base_url_and_model(tmp_path, monkeypatch): - _config(tmp_path, monkeypatch, { - "enabled": True, "api_key": "sk", "base_url": "https://zzz.example/anthropic/"}) +def test_custom_base_host(tmp_path, monkeypatch): + _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk", + "base_url": "https://zzz.example/api/anthropic"}) seen = {} def handler(request): seen["url"] = str(request.url) - seen["body"] = json.loads(request.content) - return httpx.Response(200, json={"ok": True}) + return _ok_resp([]) - _run(ZaiProvider(transport=_transport(handler)).poll()) - assert seen["url"] == "https://zzz.example/anthropic/v1/messages" # trailing slash trimmed - assert seen["body"]["model"] == "glm-4.6" - - -def test_self_throttle_serves_cache(tmp_path, monkeypatch): - _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) - calls = {"n": 0} - - def handler(request): - calls["n"] += 1 - return httpx.Response(200, json={"ok": True}) - - p = ZaiProvider(transport=_transport(handler)) - st1 = _run(p.poll()) - st2 = _run(p.poll()) # within the 15-min interval - assert calls["n"] == 1 # second served from cache - assert st1 is st2 - - -def test_transient_error_not_cached(tmp_path, monkeypatch): - _config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"}) - calls = {"n": 0} - - def handler(request): - calls["n"] += 1 - raise httpx.ConnectError("boom") - - p = ZaiProvider(transport=_transport(handler)) - st1 = _run(p.poll()) - st2 = _run(p.poll()) - assert st1.ok is False and st1.st == "offline" - assert calls["n"] == 2 # blip retried, not cached + _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll()) + assert seen["url"] == f"https://zzz.example{_MONITOR_PATH}" # monitor path on the same host diff --git a/tools/probe_zai.py b/tools/probe_zai.py index ccae356..c6b2cf0 100644 --- a/tools/probe_zai.py +++ b/tools/probe_zai.py @@ -1,17 +1,13 @@ #!/usr/bin/env python3 -"""One-off diagnostic: see exactly what z.ai's Anthropic-compatible endpoint -returns, so the ZaiProvider's rate-limit header mapping can be finalized against -a real response (the header names aren't documented). +"""One-off diagnostic for the z.ai GLM Coding Plan usage endpoint (the source +ZaiProvider reads). Dumps the raw quota JSON so the window mapping can be +checked against a live plan. Usage (key via env so it never lands in shell history): - ZAI_API_KEY=... python tools/probe_zai.py [base_url] [model] + ZAI_API_KEY=... python tools/probe_zai.py [host] -Defaults: base = https://api.z.ai/api/anthropic , model = glm-4.6 - -Prints the HTTP status and the FULL response header set (highlighting any header -mentioning limit/rate/quota/usage/reset), plus a short body preview. Sends a -1-token "hi" — on a prompt-metered plan that is one prompt, so run it sparingly. -The API key is read from the environment and never printed. +Default host = https://api.z.ai . It's a status GET — it does NOT spend the +prompt-metered plan. The API key is read from the environment and never printed. """ import json import os @@ -19,36 +15,21 @@ import sys import httpx -BASE = (sys.argv[1] if len(sys.argv) > 1 else "https://api.z.ai/api/anthropic").rstrip("/") -MODEL = sys.argv[2] if len(sys.argv) > 2 else "glm-4.6" +HOST = (sys.argv[1] if len(sys.argv) > 1 else "https://api.z.ai").rstrip("/") KEY = os.environ.get("ZAI_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN") if not KEY: sys.exit("Set ZAI_API_KEY in the environment first (it is not printed).") -url = f"{BASE}/v1/messages" -headers = { - "Authorization": f"Bearer {KEY}", - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", -} -body = {"model": MODEL, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]} - -print(f"POST {url} (model={MODEL})") +url = f"{HOST}/api/monitor/usage/quota/limit" +print(f"GET {url}") try: - resp = httpx.post(url, headers=headers, json=body, timeout=30.0) + resp = httpx.get(url, headers={"Authorization": KEY, "Accept-Language": "en-US,en"}, timeout=30.0) except httpx.HTTPError as e: sys.exit(f"request failed: {e}") print(f"HTTP {resp.status_code}\n") -INTEREST = ("limit", "rate", "quota", "usage", "reset", "remaining") -print("--- response headers ---") -for k in sorted(resp.headers.keys()): - mark = " <<<" if any(w in k.lower() for w in INTEREST) else "" - print(f" {k}: {resp.headers[k]}{mark}") - -print("\n--- body preview ---") try: - print(json.dumps(resp.json(), ensure_ascii=False)[:600]) + print(json.dumps(resp.json(), indent=2, ensure_ascii=False)) except ValueError: - print(resp.text[:600]) + print(resp.text[:1000])