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,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
|
||||
Reference in New Issue
Block a user