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).
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""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
|