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:
wenil
2026-07-10 07:27:34 +03:00
co-authored by Claude Opus 4.8
parent b63a6a62ae
commit ac0742d92c
6 changed files with 347 additions and 17 deletions
+1
View File
@@ -41,6 +41,7 @@ hiddenimports = [
'daemon.providers.base', 'daemon.providers.base',
'daemon.providers.anthropic', 'daemon.providers.anthropic',
'daemon.providers.openai_codex', 'daemon.providers.openai_codex',
'daemon.providers.zai',
# The exact winrt media modules read_now_playing() pulls in. # The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media', 'winrt.windows.media',
'winrt.windows.media.control', 'winrt.windows.media.control',
+6 -9
View File
@@ -10,12 +10,7 @@ from __future__ import annotations
from .base import Provider, ProviderStatus, StubProvider from .base import Provider, ProviderStatus, StubProvider
from .anthropic import AnthropicProvider from .anthropic import AnthropicProvider
from .openai_codex import OpenAICodexProvider from .openai_codex import OpenAICodexProvider
from .zai import ZaiProvider
# Brand accents / labels for the not-yet-implemented providers, so the stub still
# carries the right theme hint to the watch once firmware theming lands.
_STUB_META = {
"zai": ("z.ai GLM", "3859ff"),
}
def get_provider(pid: str) -> Provider: def get_provider(pid: str) -> Provider:
@@ -23,11 +18,13 @@ def get_provider(pid: str) -> Provider:
return AnthropicProvider() return AnthropicProvider()
if pid == "openai": if pid == "openai":
return OpenAICodexProvider() return OpenAICodexProvider()
label, accent = _STUB_META.get(pid, (pid, "d97757")) if pid == "zai":
return StubProvider(pid, label=label, accent=accent) return ZaiProvider()
# Unknown id (a config from a newer version, say) — a safe placeholder.
return StubProvider(pid, label=pid, accent="d97757")
__all__ = [ __all__ = [
"Provider", "ProviderStatus", "StubProvider", "Provider", "ProviderStatus", "StubProvider",
"AnthropicProvider", "OpenAICodexProvider", "get_provider", "AnthropicProvider", "OpenAICodexProvider", "ZaiProvider", "get_provider",
] ]
+135
View File
@@ -0,0 +1,135 @@
"""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.
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.
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).
"""
from __future__ import annotations
import time
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
class ZaiProvider(Provider):
id = "zai"
label = "z.ai GLM"
accent = "3859ff" # brand blue
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
try:
from daemon.config import load_config, provider_conf
except ImportError:
from config import load_config, provider_conf
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
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"}]}
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)
except httpx.HTTPError as e:
log(f"z.ai call failed: {e}") # transient — no auth flag
return ProviderStatus(ok=False, st="offline")
if resp.status_code in (401, 403):
log(f"z.ai auth rejected: HTTP {resp.status_code}")
return ProviderStatus(ok=False, st="bad key", auth_problem=True)
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)
@staticmethod
def _parse(resp: httpx.Response, log) -> ProviderStatus:
h = resp.headers
now = time.time()
def pct(util: str) -> float:
# Anthropic reports utilization as a 0-1 fraction.
try:
return round(float(util) * 100.0, 1)
except (TypeError, ValueError):
return 0.0
def reset_min(ts: str) -> int:
try:
m = (float(ts) - now) / 60.0
except (TypeError, ValueError):
return -1
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 '<none>'}")
return ProviderStatus(ok=True, st="allowed")
+11 -8
View File
@@ -10,7 +10,8 @@ import json
from daemon import config from daemon import config
from daemon.providers import ( from daemon.providers import (
get_provider, AnthropicProvider, OpenAICodexProvider, StubProvider, ProviderStatus) get_provider, AnthropicProvider, OpenAICodexProvider, ZaiProvider,
StubProvider, ProviderStatus)
def _run(coro): def _run(coro):
@@ -70,17 +71,19 @@ def test_registry_types():
a = get_provider("anthropic") a = get_provider("anthropic")
assert isinstance(a, AnthropicProvider) and a.id == "anthropic" assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
o = get_provider("openai") o = get_provider("openai")
assert isinstance(o, OpenAICodexProvider) assert isinstance(o, OpenAICodexProvider) and o.id == "openai" and o.accent == "10a37f"
assert o.id == "openai" and o.accent == "10a37f" z = get_provider("zai")
stub = get_provider("zai") # z.ai is still a stub until M3 assert isinstance(z, ZaiProvider) and z.id == "zai" and z.accent == "3859ff"
assert isinstance(stub, StubProvider) stub = get_provider("mystery") # unknown id → safe placeholder, never crashes
assert stub.id == "zai" and stub.accent == "3859ff" assert isinstance(stub, StubProvider) and stub.id == "mystery"
def test_stub_provider_poll_is_safe(): def test_zai_poll_without_key_is_safe(tmp_path, monkeypatch):
# No api_key configured => "no key", no network call.
_write(tmp_path, monkeypatch, {"version": 2, "providers": {"zai": {"enabled": True}}})
st = _run(get_provider("zai").poll()) st = _run(get_provider("zai").poll())
assert isinstance(st, ProviderStatus) assert isinstance(st, ProviderStatus)
assert st.ok is False assert st.ok is False and st.st == "no key"
def test_provider_status_payload_keys(): def test_provider_status_payload_keys():
+140
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
#!/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).
Usage (key via env so it never lands in shell history):
ZAI_API_KEY=... python tools/probe_zai.py [base_url] [model]
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.
"""
import json
import os
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"
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})")
try:
resp = httpx.post(url, headers=headers, json=body, 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])
except ValueError:
print(resp.text[:600])