v3 M1a: provider framework — config v2 + Provider abstraction (daemon)

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).
This commit is contained in:
wenil
2026-07-10 00:20:37 +03:00
parent e99c27babd
commit 6e3f8e9084
8 changed files with 405 additions and 55 deletions
+4
View File
@@ -36,6 +36,10 @@ hiddenimports = [
'daemon.server', 'daemon.server',
'daemon.panel', 'daemon.panel',
'daemon.ha_client', '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. # The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media', 'winrt.windows.media',
'winrt.windows.media.control', 'winrt.windows.media.control',
+62 -49
View File
@@ -423,6 +423,14 @@ class Session:
if loop is not None: if loop is not None:
loop.call_soon_threadsafe(self.dim_requested.set) loop.call_soon_threadsafe(self.dim_requested.set)
return 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 # Phase 7 M2: a watch button press carries only its index — map it to the
# configured action/entity/value here (the watch stays dumb). # configured action/entity/value here (the watch stays dumb).
if payload.get("cmd") == "btn": if payload.get("cmd") == "btn":
@@ -440,6 +448,27 @@ class Session:
return return
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop) 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: def _handle_battery(self, payload: dict) -> None:
pct = payload.get("bat") pct = payload.get("bat")
charging = bool(payload.get("chg", 0)) 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) 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: async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
"""Connect to device and poll until disconnected or stopped. """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.refresh_requested.clear()
session.reload_buttons() # pick up desktop-panel edits to the button set session.reload_buttons() # pick up desktop-panel edits to the button set
# Local token usage (Session screen) needs no network — compute it # v3: poll whichever provider is active (config-driven, so an
# every cycle so the watch keeps updating even with no/expired token. # on-watch switch takes effect on the next cycle). The provider
fresh = compute_today_usage() # normalizes its own auth + rate-limit + local usage into a
# ProviderStatus and never raises — a genuine auth failure sets
# Rate-limit utilization is best-effort. A genuine 401/403 flags the # auth_problem, a transient blip just leaves ok=False (SC#5).
# token; a transient failure (network/DNS/5xx) leaves the tray state # Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3.
# alone (SC#5: a DNS blip must not read as "token expired"). active_pv, provider = _active_provider()
rl = None status = await provider.poll()
auth_problem = status.auth_problem
# Self-refresh the OAuth token before using it. In a managed cached = status.to_payload()
# (Agent SDK) environment `claude login` is unavailable, so the cached["pv"] = active_pv # which brand theme the watch wears
# 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
last_claude_poll = now last_claude_poll = now
# Now Playing (Phase 5) — best-effort Windows media session, read every # Now Playing (Phase 5) — best-effort Windows media session, read every
+72 -4
View File
@@ -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 the tray daemon and the FastAPI control panel. Replaces the Phase-6
``ha_config.json`` (auto-migrated on first load). ``ha_config.json`` (auto-migrated on first load).
Shape:: Shape (v2)::
{ {
"version": 1, "version": 2,
"ha": {"url": str, "token": str, "entities": [str, ...]}, "ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str, "buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}], "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} "settings": {"device_address": str, "autostart": bool}
} }
The token is a secret: it lives only in this file (outside the repo, gitignored) 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 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 json
import os import os
from pathlib import Path from pathlib import Path
CONFIG_VERSION = 1 CONFIG_VERSION = 2
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct") 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: def _dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
@@ -43,11 +57,24 @@ def _legacy_ha_path() -> Path:
return _dir() / "ha_config.json" 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: def default_config() -> dict:
return { return {
"version": CONFIG_VERSION, "version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []}, "ha": {"url": "", "token": "", "entities": []},
"buttons": [], "buttons": [],
"providers": default_providers(),
"active_provider": "anthropic",
"display_order": list(PROVIDER_IDS),
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15}, "settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
} }
@@ -83,6 +110,24 @@ def load_config() -> dict:
cfg[section].update(loaded[section]) cfg[section].update(loaded[section])
if isinstance(loaded.get("buttons"), list): if isinstance(loaded.get("buttons"), list):
cfg["buttons"] = loaded["buttons"] 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"]: if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
cfg = _migrate_legacy(cfg) cfg = _migrate_legacy(cfg)
# Normalize the HA section the same way ha_client expects it. # 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_"): if not url or not token or token.startswith("PASTE_"):
return None return None
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])} 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")]
+28
View File
@@ -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"]
+79
View File
@@ -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
+77
View File
@@ -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")
+79
View File
@@ -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
+4 -2
View File
@@ -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. # Must NOT raise OSError — graceful degradation into the poll loop.
result = _run(connect_and_run(device, stop_event)) result = _run(connect_and_run(device, stop_event))
# start_notify was actually attempted (and raised), but was swallowed. # start_notify was actually attempted (and raised), but was swallowed — for
assert mock_client.start_notify.call_count == 1 # 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. # Function returned normally instead of propagating the OSError.
assert result is False assert result is False
# The link was cleaned up via the finally block. # The link was cleaned up via the finally block.