Panel: new Providers tab (data-driven from GET /api/providers — labels/accents from the daemon registry, enable/active/creds from config). z.ai base_url + key, api_key masked and preserved on save like the HA token. Daemon: request_provider_switch() lets the panel push an active-provider change into the live BLE session (same _set_active_provider path as the watch) so it takes effect now, not on the next 60s poll; _active_session exposes the session to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider (daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the watch's Provider screen can show the name + "1/3". server.py: ConfigIn gains providers/active_provider/display_order; secrets masked in _masked; active_provider change notifies the injected _command_sink. tray wires set_command_sink(request_provider_switch). Tests: test_server_providers (masking + sink), test_provider_switch (cycle + offline persist). 106 passed, 2 Linux-only failures (baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
2.6 KiB
Python
74 lines
2.6 KiB
Python
"""v3 M1c — control-panel provider API: secret masking + live-switch sink.
|
|
|
|
Provider api_keys (z.ai) get the same never-leave-the-process treatment as the
|
|
HA token, and flipping active_provider notifies the injected command sink so a
|
|
connected watch switches without waiting for the ~60s poll.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from daemon import config, server
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
cfg = {
|
|
"version": 2,
|
|
"providers": {
|
|
"anthropic": {"enabled": True},
|
|
"openai": {"enabled": False},
|
|
"zai": {"enabled": False, "base_url": "https://api.z.ai", "api_key": "secret-key"},
|
|
},
|
|
"active_provider": "anthropic",
|
|
"display_order": ["anthropic", "openai", "zai"],
|
|
}
|
|
p = tmp_path / "config.json"
|
|
p.write_text(json.dumps(cfg), encoding="utf-8")
|
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
monkeypatch.setattr(server, "_command_sink", None)
|
|
return TestClient(server.app)
|
|
|
|
|
|
def test_zai_api_key_masked_in_get(client):
|
|
r = client.get("/api/config").json()
|
|
assert r["providers"]["zai"]["api_key"] == server.MASK
|
|
assert r["providers"]["zai"]["base_url"] == "https://api.z.ai" # non-secret shown
|
|
|
|
|
|
def test_masked_api_key_preserved_on_put(client):
|
|
# Echo the mask back unchanged => keep the stored secret; other fields apply.
|
|
client.put("/api/config",
|
|
json={"providers": {"zai": {"api_key": server.MASK, "enabled": True}}})
|
|
saved = config.load_config()["providers"]["zai"]
|
|
assert saved["api_key"] == "secret-key"
|
|
assert saved["enabled"] is True
|
|
|
|
|
|
def test_real_api_key_stored_on_put(client):
|
|
client.put("/api/config", json={"providers": {"zai": {"api_key": "new-key"}}})
|
|
assert config.load_config()["providers"]["zai"]["api_key"] == "new-key"
|
|
|
|
|
|
def test_active_provider_switch_calls_sink(client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
|
client.put("/api/config", json={"active_provider": "openai"})
|
|
assert calls == ["openai"]
|
|
assert config.active_provider_id() == "openai"
|
|
|
|
|
|
def test_no_switch_when_active_unchanged(client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
|
client.put("/api/config", json={"active_provider": "anthropic"}) # already active
|
|
assert calls == []
|
|
|
|
|
|
def test_unknown_provider_id_ignored_on_put(client):
|
|
client.put("/api/config", json={"providers": {"bogus": {"enabled": True}}})
|
|
assert "bogus" not in config.load_config()["providers"]
|