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:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user