v3 M3: finalize z.ai against the real usage endpoint (monitor/quota/limit)
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: <raw key> (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 <noreply@anthropic.com>
This commit is contained in:
+82
-74
@@ -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: <api_key> # 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":<ms>}, # 5h window
|
||||
{"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":2,"nextResetTime":<ms>}]}} # 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:
|
||||
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(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,
|
||||
s=pct(short), sr=reset_min(short),
|
||||
w=pct(weekly), wr=reset_min(weekly),
|
||||
st=st, 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 '<none>'}")
|
||||
return ProviderStatus(ok=True, st="allowed")
|
||||
|
||||
+96
-76
@@ -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
|
||||
|
||||
+12
-31
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user