v3 M3: z.ai (GLM) provider — Anthropic-compatible poll from a pasted API key
ZaiProvider replaces the zai stub. It reaches z.ai's Anthropic-compatible endpoint (default https://api.z.ai/api/anthropic) exactly like Claude Code with ANTHROPIC_BASE_URL pointed at z.ai: a /v1/messages POST with a Bearer key. The key + base URL come from the config the control panel's z.ai field writes (providers.zai.base_url/.api_key) — no OAuth, no local files. Two z.ai-specific cares: (1) static API key, no self-refresh; (2) the GLM Coding Plan meters by prompts, so a 60s poll would drain quota — the provider self-throttles to one network call per poll_interval_s (default 15 min) and serves a cached status in between. Rate-limit parsing assumes z.ai proxies Anthropic's unified headers (5h->s/sr, 7d->w/wr); if a real response lacks them it still reports connected and logs the limit-ish headers it did return, so the mapping can be finalized against a live key. tools/probe_zai.py dumps a real z.ai response's headers (key via env, never printed) to pin the exact header names. Graceful without a key: selecting z.ai shows the blue theme + "no key", never crashes the loop. Registry: all three providers now real; unknown ids fall back to StubProvider. +7 z.ai tests (mocked transport: header mapping, auth, throttle, fallback); 120 passed, 2 Linux-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""v3 M3 — z.ai (GLM) provider: Anthropic-compatible poll + quota-safe throttle.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from daemon.providers.zai import ZaiProvider, DEFAULT_BASE
|
||||
|
||||
|
||||
def _run(coro):
|
||||
# test_zai sorts last; an earlier module's asyncio.run() can leave the shared
|
||||
# loop closed, so fall back to a fresh loop rather than raising (Python 3.13).
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
raise RuntimeError
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
def _config(tmp_path, monkeypatch, zai: dict):
|
||||
p = tmp_path / "config.json"
|
||||
p.write_text(json.dumps({"version": 2, "providers": {"zai": zai}}), encoding="utf-8")
|
||||
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
|
||||
|
||||
def _transport(handler):
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def test_maps_unified_rate_limit_headers(tmp_path, monkeypatch):
|
||||
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai"})
|
||||
now = time.time()
|
||||
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})
|
||||
|
||||
st = _run(ZaiProvider(transport=_transport(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
|
||||
|
||||
|
||||
def test_no_key_reports_no_key(tmp_path, monkeypatch):
|
||||
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": ""})
|
||||
called = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
called["n"] += 1
|
||||
return httpx.Response(200)
|
||||
|
||||
st = _run(ZaiProvider(transport=_transport(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
|
||||
|
||||
|
||||
def test_auth_rejection(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())
|
||||
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):
|
||||
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, headers={"x-some-quota": "5"}, json={"ok": True})
|
||||
|
||||
st = _run(ZaiProvider(transport=_transport(handler)).poll())
|
||||
assert st.ok is True and st.st == "allowed" and st.s == 0.0
|
||||
|
||||
|
||||
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/"})
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["url"] = str(request.url)
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
_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
|
||||
Reference in New Issue
Block a user