diff --git a/clawdmeter.spec b/clawdmeter.spec index aa5f4c3..32f4554 100644 --- a/clawdmeter.spec +++ b/clawdmeter.spec @@ -41,6 +41,7 @@ hiddenimports = [ 'daemon.providers.base', 'daemon.providers.anthropic', 'daemon.providers.openai_codex', + 'daemon.providers.zai', # The exact winrt media modules read_now_playing() pulls in. 'winrt.windows.media', 'winrt.windows.media.control', diff --git a/daemon/providers/__init__.py b/daemon/providers/__init__.py index 7b99826..33c25d6 100644 --- a/daemon/providers/__init__.py +++ b/daemon/providers/__init__.py @@ -10,12 +10,7 @@ from __future__ import annotations from .base import Provider, ProviderStatus, StubProvider from .anthropic import AnthropicProvider from .openai_codex import OpenAICodexProvider - -# 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"), -} +from .zai import ZaiProvider def get_provider(pid: str) -> Provider: @@ -23,11 +18,13 @@ def get_provider(pid: str) -> Provider: return AnthropicProvider() if pid == "openai": return OpenAICodexProvider() - label, accent = _STUB_META.get(pid, (pid, "d97757")) - return StubProvider(pid, label=label, accent=accent) + if pid == "zai": + return ZaiProvider() + # Unknown id (a config from a newer version, say) — a safe placeholder. + return StubProvider(pid, label=pid, accent="d97757") __all__ = [ "Provider", "ProviderStatus", "StubProvider", - "AnthropicProvider", "OpenAICodexProvider", "get_provider", + "AnthropicProvider", "OpenAICodexProvider", "ZaiProvider", "get_provider", ] diff --git a/daemon/providers/zai.py b/daemon/providers/zai.py new file mode 100644 index 0000000..53960bb --- /dev/null +++ b/daemon/providers/zai.py @@ -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 ''}") + return ProviderStatus(ok=True, st="allowed") diff --git a/daemon/tests/test_providers.py b/daemon/tests/test_providers.py index 2186262..e5d159a 100644 --- a/daemon/tests/test_providers.py +++ b/daemon/tests/test_providers.py @@ -10,7 +10,8 @@ import json from daemon import config from daemon.providers import ( - get_provider, AnthropicProvider, OpenAICodexProvider, StubProvider, ProviderStatus) + get_provider, AnthropicProvider, OpenAICodexProvider, ZaiProvider, + StubProvider, ProviderStatus) def _run(coro): @@ -70,17 +71,19 @@ def test_registry_types(): a = get_provider("anthropic") assert isinstance(a, AnthropicProvider) and a.id == "anthropic" o = get_provider("openai") - assert isinstance(o, OpenAICodexProvider) - assert o.id == "openai" and o.accent == "10a37f" - stub = get_provider("zai") # z.ai is still a stub until M3 - assert isinstance(stub, StubProvider) - assert stub.id == "zai" and stub.accent == "3859ff" + assert isinstance(o, OpenAICodexProvider) and o.id == "openai" and o.accent == "10a37f" + z = get_provider("zai") + assert isinstance(z, ZaiProvider) and z.id == "zai" and z.accent == "3859ff" + stub = get_provider("mystery") # unknown id → safe placeholder, never crashes + 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()) 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(): diff --git a/daemon/tests/test_zai.py b/daemon/tests/test_zai.py new file mode 100644 index 0000000..55e8c4f --- /dev/null +++ b/daemon/tests/test_zai.py @@ -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 diff --git a/tools/probe_zai.py b/tools/probe_zai.py new file mode 100644 index 0000000..ccae356 --- /dev/null +++ b/tools/probe_zai.py @@ -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])