"""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")