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>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""v3 provider layer + config v2 tests.
|
|
|
|
Covers the framework M1 lands: config v1->v2 migration keeps Anthropic behaviour,
|
|
the provider registry hands out the right implementations, and ProviderStatus
|
|
maps to the compact BLE fields the firmware parser expects.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
from daemon import config
|
|
from daemon.providers import (
|
|
get_provider, AnthropicProvider, OpenAICodexProvider, ZaiProvider,
|
|
StubProvider, ProviderStatus)
|
|
|
|
|
|
def _run(coro):
|
|
# Reuse the suite's shared event loop (never closed) like the other test
|
|
# modules — asyncio.run() would close it and set the current loop to None,
|
|
# breaking every later test that calls get_event_loop() (Python 3.13).
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _write(tmp_path, monkeypatch, data: dict):
|
|
"""Point the config module at an isolated temp config (and LOCALAPPDATA, so
|
|
the legacy-ha_config migration can't reach the real machine's file)."""
|
|
p = tmp_path / "config.json"
|
|
p.write_text(json.dumps(data), encoding="utf-8")
|
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
return p
|
|
|
|
|
|
def test_v1_config_migrates_to_provider_defaults(tmp_path, monkeypatch):
|
|
# A v1 config has no providers/active_provider; load_config fills them so
|
|
# behaviour is unchanged — Anthropic enabled and active.
|
|
_write(tmp_path, monkeypatch, {
|
|
"version": 1, "ha": {"url": "", "token": "", "entities": []},
|
|
"buttons": [], "settings": {}})
|
|
cfg = config.load_config()
|
|
assert cfg["active_provider"] == "anthropic"
|
|
assert cfg["providers"]["anthropic"]["enabled"] is True
|
|
assert config.enabled_providers(cfg) == ["anthropic"]
|
|
|
|
|
|
def test_unknown_active_provider_falls_back(tmp_path, monkeypatch):
|
|
_write(tmp_path, monkeypatch, {"version": 2, "active_provider": "bogus", "providers": {}})
|
|
cfg = config.load_config()
|
|
assert cfg["active_provider"] == "anthropic"
|
|
assert config.active_provider_id(cfg) == "anthropic"
|
|
|
|
|
|
def test_display_order_covers_all_and_filters_unknown(tmp_path, monkeypatch):
|
|
_write(tmp_path, monkeypatch, {"version": 2, "display_order": ["zai", "bogus", "openai"]})
|
|
cfg = config.load_config()
|
|
# "bogus" dropped; the missing "anthropic" appended at the end.
|
|
assert cfg["display_order"] == ["zai", "openai", "anthropic"]
|
|
|
|
|
|
def test_enabled_providers_respects_order_and_flag(tmp_path, monkeypatch):
|
|
_write(tmp_path, monkeypatch, {
|
|
"version": 2, "display_order": ["zai", "openai", "anthropic"],
|
|
"providers": {"anthropic": {"enabled": True},
|
|
"openai": {"enabled": True},
|
|
"zai": {"enabled": False}}})
|
|
cfg = config.load_config()
|
|
assert config.enabled_providers(cfg) == ["openai", "anthropic"]
|
|
|
|
|
|
def test_registry_types():
|
|
a = get_provider("anthropic")
|
|
assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
|
|
o = get_provider("openai")
|
|
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_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 and st.st == "no key"
|
|
|
|
|
|
def test_provider_status_payload_keys():
|
|
st = ProviderStatus(s=50, w=10, ok=True, tokens_today=5, cost_cents_today=3)
|
|
p = st.to_payload()
|
|
assert set(p) == {"s", "sr", "w", "wr", "st", "ok", "tk", "to", "tc", "tn"}
|
|
assert p["s"] == 50 and p["tk"] == 5 and p["tc"] == 3 and p["ok"] is True
|