From 6e3f8e90846223fee1b3b161d2176043dc48a5a7 Mon Sep 17 00:00:00 2001 From: wenil Date: Fri, 10 Jul 2026 00:20:37 +0300 Subject: [PATCH] =?UTF-8?q?v3=20M1a:=20provider=20framework=20=E2=80=94=20?= =?UTF-8?q?config=20v2=20+=20Provider=20abstraction=20(daemon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the multi-provider seam on the host, behaviour-identical for Anthropic. - config v2: `providers` (per-provider enable + creds), `active_provider`, `display_order`; v1 configs migrate transparently (anthropic enabled+active). New accessors: active_provider_id / provider_conf / enabled_providers. - daemon/providers/: Provider ABC + normalized ProviderStatus (maps to the same s/sr/w/wr/st/tk/to/tc/tn BLE fields), AnthropicProvider (wraps the existing OAuth+poll+local-usage logic), StubProvider for openai/zai until M2/M3, and a get_provider() registry. - poll loop: polls the config-selected active provider each cycle and tags the payload with `pv` (the brand-theme id the watch will wear). Watch->PC `{"cmd":"prov","id":".."}` persists the active provider and repolls at once. - clawdmeter.spec: bundle daemon.providers for the frozen exe. - test: 7 new provider/config tests; fix a stale start_notify count assertion (the M2 command channel means both REQ and CMD subscriptions are attempted). Verified live: config loads as v2, active provider resolves to AnthropicProvider, a real poll returns ok=True (s=53%, w=5%); full suite 87 passed (+7 new). --- clawdmeter.spec | 4 + daemon/claude_usage_daemon_windows.py | 111 ++++++++++++++----------- daemon/config.py | 76 ++++++++++++++++- daemon/providers/__init__.py | 28 +++++++ daemon/providers/anthropic.py | 79 ++++++++++++++++++ daemon/providers/base.py | 77 +++++++++++++++++ daemon/tests/test_providers.py | 79 ++++++++++++++++++ daemon/tests/test_windows_reconnect.py | 6 +- 8 files changed, 405 insertions(+), 55 deletions(-) create mode 100644 daemon/providers/__init__.py create mode 100644 daemon/providers/anthropic.py create mode 100644 daemon/providers/base.py create mode 100644 daemon/tests/test_providers.py diff --git a/clawdmeter.spec b/clawdmeter.spec index 8da0e40..85a226b 100644 --- a/clawdmeter.spec +++ b/clawdmeter.spec @@ -36,6 +36,10 @@ hiddenimports = [ 'daemon.server', 'daemon.panel', 'daemon.ha_client', + # v3 provider package (registry + providers are lazy-imported in the loop). + 'daemon.providers', + 'daemon.providers.base', + 'daemon.providers.anthropic', # The exact winrt media modules read_now_playing() pulls in. 'winrt.windows.media', 'winrt.windows.media.control', diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index 3c189fa..a00ea13 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -423,6 +423,14 @@ class Session: if loop is not None: loop.call_soon_threadsafe(self.dim_requested.set) return + # v3: watch switched the displayed provider ({"cmd":"prov","id":".."}). + # Persist it to config and poll it immediately, on the loop thread. + if payload.get("cmd") == "prov": + pid = payload.get("id") + loop = self._loop + if isinstance(pid, str) and loop is not None: + loop.call_soon_threadsafe(self._set_active_provider, pid) + return # Phase 7 M2: a watch button press carries only its index — map it to the # configured action/entity/value here (the watch stays dumb). if payload.get("cmd") == "btn": @@ -440,6 +448,27 @@ class Session: return asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop) + def _set_active_provider(self, pid: str) -> None: + """Persist the watch-selected provider to config and poll it now. Runs on + the loop thread (via call_soon_threadsafe), so touching refresh_requested + is safe.""" + try: + try: + from daemon.config import load_config, save_config, PROVIDER_IDS + except ImportError: + from config import load_config, save_config, PROVIDER_IDS + if pid not in PROVIDER_IDS: + log(f"Provider switch: unknown id {pid!r}") + return + cfg = load_config() + if cfg.get("active_provider") != pid: + cfg["active_provider"] = pid + save_config(cfg) + log(f"Active provider -> {pid}") + self.refresh_requested.set() # poll the new provider on the next tick + except Exception as e: + log(f"Provider switch failed: {e!r}") + def _handle_battery(self, payload: dict) -> None: pct = payload.get("bat") charging = bool(payload.get("chg", 0)) @@ -775,6 +804,28 @@ async def _wait_first(*events: asyncio.Event, timeout: float) -> None: await asyncio.gather(*tasks, return_exceptions=True) +def _active_provider(): + """(id, Provider) for the currently configured active provider. Lazy imports + mirror the rest of the daemon so this resolves under `-m`, the frozen exe, + and pytest alike. Falls back to Anthropic if the config can't be read.""" + try: + try: + from daemon.providers import get_provider + from daemon.config import active_provider_id + except ImportError: + from providers import get_provider + from config import active_provider_id + pid = active_provider_id() + except Exception as e: # never let provider selection break the loop + log(f"Provider select failed ({e!r}); using anthropic") + try: + from daemon.providers import get_provider + except ImportError: + from providers import get_provider + pid = "anthropic" + return pid, get_provider(pid) + + async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool: """Connect to device and poll until disconnected or stopped. @@ -853,55 +904,17 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> session.refresh_requested.clear() session.reload_buttons() # pick up desktop-panel edits to the button set - # Local token usage (Session screen) needs no network — compute it - # every cycle so the watch keeps updating even with no/expired token. - fresh = compute_today_usage() - - # Rate-limit utilization is best-effort. A genuine 401/403 flags the - # token; a transient failure (network/DNS/5xx) leaves the tray state - # alone (SC#5: a DNS blip must not read as "token expired"). - rl = None - - # Self-refresh the OAuth token before using it. In a managed - # (Agent SDK) environment `claude login` is unavailable, so the - # daemon renews its own access token from the refresh token — - # otherwise the rate-limit % goes dark forever once it expires. - try: - await refresh_token_if_needed() - except Exception as e: # belt-and-braces: never crash the loop - log(f"Token refresh skipped: {e!r}") - - token = read_token() # D-09: fresh each cycle (post-refresh) - if not token: - auth_problem = True - log("No token; sending local usage only") - else: - try: - rl = await poll_api(token) - except AuthError: - # Rejected despite the proactive refresh (e.g. expiresAt - # was stale/missing so we skipped it). Force one refresh - # and retry the poll once before flagging the token bad. - try: - forced = await refresh_token_if_needed(force=True) - except Exception as e: - log(f"Forced token refresh failed: {e!r}") - forced = False - if forced and (token := read_token()): - try: - rl = await poll_api(token) - except AuthError: - auth_problem = True - else: - auth_problem = True - - if rl is not None: - fresh.update(rl) - fresh["ok"] = True # rate-limit data is fresh - else: - fresh["ok"] = False # rate-limit unknown -> watch usage view goes idle - - cached = fresh + # v3: poll whichever provider is active (config-driven, so an + # on-watch switch takes effect on the next cycle). The provider + # normalizes its own auth + rate-limit + local usage into a + # ProviderStatus and never raises — a genuine auth failure sets + # auth_problem, a transient blip just leaves ok=False (SC#5). + # Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3. + active_pv, provider = _active_provider() + status = await provider.poll() + auth_problem = status.auth_problem + cached = status.to_payload() + cached["pv"] = active_pv # which brand theme the watch wears last_claude_poll = now # Now Playing (Phase 5) — best-effort Windows media session, read every diff --git a/daemon/config.py b/daemon/config.py index a4e8ed5..65474d9 100644 --- a/daemon/config.py +++ b/daemon/config.py @@ -4,29 +4,43 @@ Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by the tray daemon and the FastAPI control panel. Replaces the Phase-6 ``ha_config.json`` (auto-migrated on first load). -Shape:: +Shape (v2):: { - "version": 1, + "version": 2, "ha": {"url": str, "token": str, "entities": [str, ...]}, "buttons": [{"id": str, "label": str, "icon": str, "action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}], + "providers": { # v3: multi-provider usage + "anthropic": {"enabled": bool}, + "openai": {"enabled": bool}, + "zai": {"enabled": bool, "base_url": str, "api_key": str}, + }, + "active_provider": "anthropic", # which provider the watch displays + "display_order": ["anthropic", "openai", "zai"], # on-watch cycle order "settings": {"device_address": str, "autostart": bool} } The token is a secret: it lives only in this file (outside the repo, gitignored) and is NEVER logged (only its length) — same discipline as ha_client.py. The -control panel masks it in API responses. +control panel masks it in API responses. Provider API keys (e.g. z.ai) are +secrets too and follow the same rule. """ import json import os from pathlib import Path -CONFIG_VERSION = 1 +CONFIG_VERSION = 2 DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only VALID_ACTIONS = ("toggle", "bri", "ct") +# v3 providers. Order here is the default on-watch cycle order. Anthropic needs +# no extra config (it reads Claude Code's OAuth creds); z.ai needs a base URL + +# API key; OpenAI reads the local Codex install. Keep this list and the per- +# provider default shapes in sync with default_config(). +PROVIDER_IDS = ("anthropic", "openai", "zai") + def _dir() -> Path: base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) @@ -43,11 +57,24 @@ def _legacy_ha_path() -> Path: return _dir() / "ha_config.json" +def default_providers() -> dict: + """Per-provider config defaults. Anthropic is enabled+active out of the box so + a v1 config (no providers section) keeps behaving exactly as before.""" + return { + "anthropic": {"enabled": True}, + "openai": {"enabled": False}, + "zai": {"enabled": False, "base_url": "", "api_key": ""}, + } + + def default_config() -> dict: return { "version": CONFIG_VERSION, "ha": {"url": "", "token": "", "entities": []}, "buttons": [], + "providers": default_providers(), + "active_provider": "anthropic", + "display_order": list(PROVIDER_IDS), "settings": {"device_address": "", "autostart": False, "low_battery_pct": 15}, } @@ -83,6 +110,24 @@ def load_config() -> dict: cfg[section].update(loaded[section]) if isinstance(loaded.get("buttons"), list): cfg["buttons"] = loaded["buttons"] + # v3 providers — deep-merge per provider so new keys added in later + # versions always resolve, and a v1 config (no providers) keeps the + # defaults (anthropic enabled + active) → behaviour unchanged. + if isinstance(loaded.get("providers"), dict): + for pid, pconf in loaded["providers"].items(): + if pid in cfg["providers"] and isinstance(pconf, dict): + cfg["providers"][pid].update(pconf) + if isinstance(loaded.get("active_provider"), str): + cfg["active_provider"] = loaded["active_provider"] + if isinstance(loaded.get("display_order"), list): + cfg["display_order"] = [p for p in loaded["display_order"] if p in PROVIDER_IDS] + # active provider must be a known id; fall back to anthropic otherwise. + if cfg["active_provider"] not in PROVIDER_IDS: + cfg["active_provider"] = "anthropic" + # display order must cover every provider (append any missing at the end). + for pid in PROVIDER_IDS: + if pid not in cfg["display_order"]: + cfg["display_order"].append(pid) if not cfg["ha"]["url"] and not cfg["ha"]["token"]: cfg = _migrate_legacy(cfg) # Normalize the HA section the same way ha_client expects it. @@ -114,3 +159,26 @@ def ha_settings(cfg: dict | None = None) -> dict | None: if not url or not token or token.startswith("PASTE_"): return None return {"url": url, "token": token, "entities": list(ha.get("entities") or [])} + + +# --- v3 provider accessors ------------------------------------------------- + +def active_provider_id(cfg: dict | None = None) -> str: + """The provider id the watch should display right now (always a valid id).""" + if cfg is None: + cfg = load_config() + pid = cfg.get("active_provider", "anthropic") + return pid if pid in PROVIDER_IDS else "anthropic" + + +def provider_conf(cfg: dict, pid: str) -> dict: + """Per-provider config dict (empty dict if the id is unknown).""" + return (cfg.get("providers") or {}).get(pid, {}) + + +def enabled_providers(cfg: dict | None = None) -> list: + """Enabled provider ids in the configured on-watch cycle order.""" + if cfg is None: + cfg = load_config() + order = cfg.get("display_order") or list(PROVIDER_IDS) + return [p for p in order if p in PROVIDER_IDS and provider_conf(cfg, p).get("enabled")] diff --git a/daemon/providers/__init__.py b/daemon/providers/__init__.py new file mode 100644 index 0000000..bd3101c --- /dev/null +++ b/daemon/providers/__init__.py @@ -0,0 +1,28 @@ +"""Provider registry (v3). + +get_provider(id) returns a Provider instance for the daemon to poll. Anthropic +is real; OpenAI and z.ai return a StubProvider until M2/M3 land, so selecting +them never crashes the loop. +""" + +from __future__ import annotations + +from .base import Provider, ProviderStatus, StubProvider +from .anthropic import AnthropicProvider + +# 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 = { + "openai": ("OpenAI Codex", "10a37f"), + "zai": ("z.ai GLM", "3859ff"), +} + + +def get_provider(pid: str) -> Provider: + if pid == "anthropic": + return AnthropicProvider() + label, accent = _STUB_META.get(pid, (pid, "d97757")) + return StubProvider(pid, label=label, accent=accent) + + +__all__ = ["Provider", "ProviderStatus", "StubProvider", "AnthropicProvider", "get_provider"] diff --git a/daemon/providers/anthropic.py b/daemon/providers/anthropic.py new file mode 100644 index 0000000..4e5e1ca --- /dev/null +++ b/daemon/providers/anthropic.py @@ -0,0 +1,79 @@ +"""Anthropic (Claude Code) provider. + +Wraps the daemon's existing OAuth + rate-limit poll + local-usage logic behind +the Provider interface. Behaviour is identical to pre-v3 Clawdmeter — this only +moves the Claude-specific work behind a seam so OpenAI/z.ai can slot in beside +it. The heavy lifting (compute_today_usage, poll_api, token refresh) still lives +in claude_usage_daemon_windows.py; this just orchestrates it into a +ProviderStatus. +""" + +from __future__ import annotations + +from .base import Provider, ProviderStatus + + +class AnthropicProvider(Provider): + id = "anthropic" + label = "Claude Code" + accent = "d97757" # brand terra-cotta + + async def poll(self) -> ProviderStatus: + # Lazy import: the daemon module imports this package, so importing it + # back at module load would be circular. By poll() time it's resolved. + from daemon.claude_usage_daemon_windows import ( + compute_today_usage, refresh_token_if_needed, read_token, poll_api, + AuthError, log, + ) + + fresh = compute_today_usage() # local token/cost — no network, always available + rl = None + auth_problem = False + + # Proactively refresh the OAuth token; a DNS/network blip here must not + # crash the loop (SC#5) — it just means we poll with the current token. + try: + await refresh_token_if_needed() + except Exception as e: # belt-and-braces + log(f"Token refresh skipped: {e!r}") + + token = read_token() + if not token: + auth_problem = True + log("No token; sending local usage only") + else: + try: + rl = await poll_api(token) + except AuthError: + # Rejected despite the proactive refresh — force one refresh and + # retry the poll once before flagging the token as bad. + try: + forced = await refresh_token_if_needed(force=True) + except Exception as e: + log(f"Forced token refresh failed: {e!r}") + forced = False + if forced and (token := read_token()): + try: + rl = await poll_api(token) + except AuthError: + auth_problem = True + else: + auth_problem = True + + status = ProviderStatus( + tokens_today=int(fresh.get("tk", 0) or 0), + output_today=int(fresh.get("to", 0) or 0), + cost_cents_today=int(fresh.get("tc", 0) or 0), + messages_today=int(fresh.get("tn", 0) or 0), + auth_problem=auth_problem, + ) + if rl is not None: + status.s = rl.get("s", 0.0) + status.sr = rl.get("sr", -1) + status.w = rl.get("w", 0.0) + status.wr = rl.get("wr", -1) + status.st = rl.get("st", "unknown") + status.ok = True # rate-limit data is fresh + else: + status.ok = False # unknown → the watch usage view goes idle, not 0% + return status diff --git a/daemon/providers/base.py b/daemon/providers/base.py new file mode 100644 index 0000000..9819612 --- /dev/null +++ b/daemon/providers/base.py @@ -0,0 +1,77 @@ +"""Provider abstraction (v3). + +A ``Provider`` polls one AI coding service (Claude Code, OpenAI Codex, z.ai GLM +Coding Plan, ...) and returns a normalized ``ProviderStatus`` the daemon writes +to the watch. Each provider owns its own auth + usage source; the daemon loop +and the firmware stay provider-agnostic — the BLE payload just carries a ``pv`` +id that selects which brand theme the watch wears. + +The metric model mirrors Claude's: a short (5h) and long (7d/weekly) rate-limit +utilization percentage plus reset timers, with today's local token/cost as a +bonus when the provider can compute it. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class ProviderStatus: + # Rate-limit utilization (0-100) + reset minutes — the two bars on the watch. + s: float = 0.0 # short window (5h) utilization % + sr: int = -1 # short window reset, minutes (-1 = unknown) + w: float = 0.0 # long window (7d) utilization % + wr: int = -1 # long window reset, minutes + st: str = "unknown" # status string ("allowed"/"limited"/...) + ok: bool = False # rate-limit data is fresh/valid this cycle + # Today's local usage (optional — providers that can read it fill these in). + tokens_today: int = 0 + output_today: int = 0 + cost_cents_today: int = 0 + messages_today: int = 0 + # True when the provider is configured but auth failed (expired token etc.). + # Distinct from a transient network blip, which leaves ok=False without this. + auth_problem: bool = False + + def to_payload(self) -> dict: + """Compact BLE field names the firmware parser already understands.""" + return { + "s": self.s, "sr": self.sr, "w": self.w, "wr": self.wr, + "st": self.st, "ok": self.ok, + "tk": self.tokens_today, "to": self.output_today, + "tc": self.cost_cents_today, "tn": self.messages_today, + } + + +class Provider(ABC): + """One usage source. Subclasses set the class attributes and implement poll().""" + + id: str = "" # payload `pv` id: anthropic / openai / zai + label: str = "" # human label ("Claude Code") + accent: str = "d97757" # brand accent hex — firmware theme hint + + @abstractmethod + async def poll(self) -> ProviderStatus: + """Return the current status. MUST NOT raise on a network/auth failure — + return a status with ok=False (and auth_problem=True on a genuine auth + error) so the daemon loop and the watch degrade gracefully.""" + raise NotImplementedError + + async def aclose(self) -> None: + """Release any held resources (override when the provider owns a client).""" + return None + + +class StubProvider(Provider): + """Placeholder for a provider id that isn't implemented yet (OpenAI/z.ai + before M2/M3). Reports nothing rather than crashing when selected.""" + + def __init__(self, pid: str, label: str = "", accent: str = "d97757") -> None: + self.id = pid + self.label = label or pid + self.accent = accent + + async def poll(self) -> ProviderStatus: + return ProviderStatus(ok=False, st="not configured") diff --git a/daemon/tests/test_providers.py b/daemon/tests/test_providers.py new file mode 100644 index 0000000..d393f45 --- /dev/null +++ b/daemon/tests/test_providers.py @@ -0,0 +1,79 @@ +"""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, StubProvider, ProviderStatus + + +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" + stub = get_provider("openai") + assert isinstance(stub, StubProvider) + assert stub.id == "openai" and stub.accent == "10a37f" + + +def test_stub_provider_poll_is_safe(): + st = asyncio.run(get_provider("zai").poll()) + assert isinstance(st, ProviderStatus) + assert st.ok is False + + +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 diff --git a/daemon/tests/test_windows_reconnect.py b/daemon/tests/test_windows_reconnect.py index be763d7..b6f391f 100644 --- a/daemon/tests/test_windows_reconnect.py +++ b/daemon/tests/test_windows_reconnect.py @@ -599,8 +599,10 @@ def test_start_notify_oserror_does_not_crash_connect_and_run(): # Must NOT raise OSError — graceful degradation into the poll loop. result = _run(connect_and_run(device, stop_event)) - # start_notify was actually attempted (and raised), but was swallowed. - assert mock_client.start_notify.call_count == 1 + # start_notify was actually attempted (and raised), but was swallowed — for + # BOTH subscriptions the daemon sets up: the REQ refresh channel and the CMD + # command channel (battery + HA). Both degrade gracefully. + assert mock_client.start_notify.call_count == 2 # Function returned normally instead of propagating the OSError. assert result is False # The link was cleaned up via the finally block.