diff --git a/.github/workflows/catalog-package.yml b/.github/workflows/catalog-package.yml index 1e85b50..e278490 100644 --- a/.github/workflows/catalog-package.yml +++ b/.github/workflows/catalog-package.yml @@ -15,7 +15,7 @@ jobs: - run: python -m py_compile catalog/__init__.py - run: node --check catalog/desktop/plugin.js - run: python -m pip install httpx==0.28.1 PyYAML==6.0.3 - - run: python -m unittest test_deepseek_pricing.py test_profile_switch.py test_probe_install.py test_probe_runtime.py test_provider_controls.py test_probe_env_fallback.py test_opencode_go_multi.py test_probe_slicing.py test_codex_accounts.py + - run: python -m unittest test_deepseek_pricing.py test_profile_switch.py test_probe_install.py test_probe_runtime.py test_provider_controls.py test_probe_env_fallback.py test_opencode_go_multi.py test_probe_slicing.py test_codex_accounts.py test_profile_pool_fallback.py - run: node --test test_profile_routing.cjs test_probe_pagination.cjs test_quick_usage.cjs test_i18n.cjs - run: python -m unittest test_catalog_readonly.py test_deepseek_balance.py test_grok_usage.py test_ollama_balance.py test_glm_region.py - run: node --test test_profile_routing.cjs test_quick_usage.cjs test_i18n.cjs diff --git a/catalog/desktop/plugin.js b/catalog/desktop/plugin.js index 58a089c..a59c385 100644 --- a/catalog/desktop/plugin.js +++ b/catalog/desktop/plugin.js @@ -300,7 +300,7 @@ function saveQuotaCache(key, payload) { used: card.used, resetAt: card.resetAt, resetText: card.resetText, detail: card.detail })) remember(QUOTA_CACHE_KEY, { ...freshQuotaCacheEntries(), [JSON.stringify(key)]: { cards, errors: [], - checkedAt: payload.checkedAt, hadSession: payload.hadSession, haveAccountRpc: payload.haveAccountRpc } }) + checkedAt: payload.checkedAt, observedAt: observedTime(payload), hadSession: payload.hadSession, haveAccountRpc: payload.haveAccountRpc } }) } function saveQuickUsage(on) { @@ -1085,6 +1085,22 @@ function go(route) { if (typeof host.navigate === 'function') host.navigate(route) } +// The probe can answer from its own cache, so the oldest vendor observation, +// not the time of this request, is when the shown numbers were checked. +function probeObservedAt(snapshots, now) { + let oldest = now + for (const snap of snapshots || []) { + const at = Number(snap && snap.observed_at) * 1000 + if (Number.isFinite(at) && at > 0 && at < oldest) oldest = at + } + return oldest +} + +function observedTime(payload) { + if (!payload) return undefined + return Number.isFinite(payload.observedAt) ? payload.observedAt : payload.checkedAt +} + async function fetchLiveCards(sessionId, opts, connectionId, profile) { const cards = [] const errors = [] @@ -1176,6 +1192,8 @@ async function fetchLiveCards(sessionId, opts, connectionId, profile) { } if (extra.length) cards.push(...cardsFromAccountSnapshots(extra)) } + const checkedAt = Date.now() + const observedAt = probeObservedAt(probed, checkedAt) // Claude often comes from Hermes /usage when the probe has Codex/etc. but // no Anthropic row. Only skip that fallback once Claude is already present. @@ -1201,7 +1219,7 @@ async function fetchLiveCards(sessionId, opts, connectionId, profile) { seen.add(card.id) unique.push(card) } - return { cards: arrangeProviderCards(unique, { disabled }), errors, hadSession: Boolean(sid), haveAccountRpc, checkedAt: Date.now() } + return { cards: arrangeProviderCards(unique, { disabled }), errors, hadSession: Boolean(sid), haveAccountRpc, checkedAt, observedAt } } function SmallButton({ onClick, children, active, title, disabled }) { @@ -1812,8 +1830,8 @@ function PluginPageContent() { }), jsx('span', { style: { fontSize: '0.6875rem', color: text.tertiary }, - children: Number.isFinite(payload.checkedAt) - ? t('page.gatewayChecked', gateway || 'idle', checkedTime(payload.checkedAt, nowMs)) + children: Number.isFinite(observedTime(payload)) + ? t('page.gatewayChecked', gateway || 'idle', checkedTime(observedTime(payload), nowMs)) : t('page.gateway', gateway || 'idle') }), quickUsageSupported @@ -2032,7 +2050,7 @@ function QuickUsage() { const coolingDown = useCoolingDown(live.cooldownUntil || 0) const lowest = lowestQuotaCard(cards) const lowestPercent = lowest ? quotaPercent(lowest, mode) : null - const checkedAt = live.data?.checkedAt + const checkedAt = observedTime(live.data) const stale = checkedAt && nowMs - checkedAt >= POLL_MS const label = t(mode === 'used' ? 'popup.used' : 'popup.remaining') return jsxs(Popover, { open, onOpenChange: value => $quickOpen.set(value), children: [ diff --git a/catalog/desktop/probe.py b/catalog/desktop/probe.py index c947bd8..0ab609e 100644 --- a/catalog/desktop/probe.py +++ b/catalog/desktop/probe.py @@ -403,6 +403,10 @@ def _read_probe_result_cache(*, cli_only: bool, max_age: float = PROBE_MIN_INTER age = datetime.now(timezone.utc).timestamp() - float(fetched_at) if age < 0 or age > float(max_age): return None + # Caches written before observed_at existed were observed at fetched_at. + for snap in snapshots: + if isinstance(snap, dict): + snap.setdefault("observed_at", float(fetched_at)) return snapshots @@ -517,6 +521,7 @@ def _cached_anthropic_snapshot(*, scope: Optional[str] = None, max_age: Optional "windows": list(payload.get("windows") or []), } if isinstance(fetched_at, (int, float)) and math.isfinite(fetched_at): + out["observed_at"] = float(fetched_at) try: stamp = datetime.fromtimestamp(float(fetched_at), tz=timezone.utc).astimezone() as_of = stamp.strftime("%b %d, %I:%M %p").lstrip("0") @@ -2060,7 +2065,12 @@ def _pool_entries(provider: str) -> list[dict]: name = str(provider or "").strip() if not name: return [] - for home in _hermes_homes(): + homes = _hermes_homes() + # Hermes falls back per provider: a profile with no rows for a provider + # uses the base home's rows, even when it has an auth.json of its own. + if _profile_home is not None and _profile_home.parent.name == "profiles": + homes = [*homes, _profile_home.parent.parent] + for home in homes: store = _read_auth_store(home / "auth.json") if not store: continue @@ -3818,6 +3828,11 @@ def emit_page(snapshots: list) -> None: cli_snaps, cli_complete = _collect_cli() for snap in cli_snaps: _keep_snapshot(snapshots, have, labelled, snap) + # When the vendor answered, so cached reads report the real check time. + observed_at = datetime.now(timezone.utc).timestamp() + for snap in snapshots: + if isinstance(snap, dict): + snap.setdefault("observed_at", observed_at) if cli_complete and snapshots: _store_probe_result_cache(snapshots, cli_only=cli_only) emit_page(snapshots) diff --git a/plugin.js b/plugin.js index a5f7245..c00843f 100644 --- a/plugin.js +++ b/plugin.js @@ -300,7 +300,7 @@ function saveQuotaCache(key, payload) { used: card.used, resetAt: card.resetAt, resetText: card.resetText, detail: card.detail })) remember(QUOTA_CACHE_KEY, { ...freshQuotaCacheEntries(), [JSON.stringify(key)]: { cards, errors: [], - checkedAt: payload.checkedAt, hadSession: payload.hadSession, haveAccountRpc: payload.haveAccountRpc } }) + checkedAt: payload.checkedAt, observedAt: observedTime(payload), hadSession: payload.hadSession, haveAccountRpc: payload.haveAccountRpc } }) } function saveQuickUsage(on) { @@ -1085,6 +1085,22 @@ function go(route) { if (typeof host.navigate === 'function') host.navigate(route) } +// The probe can answer from its own cache, so the oldest vendor observation, +// not the time of this request, is when the shown numbers were checked. +function probeObservedAt(snapshots, now) { + let oldest = now + for (const snap of snapshots || []) { + const at = Number(snap && snap.observed_at) * 1000 + if (Number.isFinite(at) && at > 0 && at < oldest) oldest = at + } + return oldest +} + +function observedTime(payload) { + if (!payload) return undefined + return Number.isFinite(payload.observedAt) ? payload.observedAt : payload.checkedAt +} + async function fetchLiveCards(sessionId, opts, connectionId, profile) { const cards = [] const errors = [] @@ -1176,6 +1192,8 @@ async function fetchLiveCards(sessionId, opts, connectionId, profile) { } if (extra.length) cards.push(...cardsFromAccountSnapshots(extra)) } + const checkedAt = Date.now() + const observedAt = probeObservedAt(probed, checkedAt) // Claude often comes from Hermes /usage when the probe has Codex/etc. but // no Anthropic row. Only skip that fallback once Claude is already present. @@ -1201,7 +1219,7 @@ async function fetchLiveCards(sessionId, opts, connectionId, profile) { seen.add(card.id) unique.push(card) } - return { cards: arrangeProviderCards(unique, { disabled }), errors, hadSession: Boolean(sid), haveAccountRpc, checkedAt: Date.now() } + return { cards: arrangeProviderCards(unique, { disabled }), errors, hadSession: Boolean(sid), haveAccountRpc, checkedAt, observedAt } } function SmallButton({ onClick, children, active, title, disabled }) { @@ -1812,8 +1830,8 @@ function PluginPageContent() { }), jsx('span', { style: { fontSize: '0.6875rem', color: text.tertiary }, - children: Number.isFinite(payload.checkedAt) - ? t('page.gatewayChecked', gateway || 'idle', checkedTime(payload.checkedAt, nowMs)) + children: Number.isFinite(observedTime(payload)) + ? t('page.gatewayChecked', gateway || 'idle', checkedTime(observedTime(payload), nowMs)) : t('page.gateway', gateway || 'idle') }), quickUsageSupported @@ -2032,7 +2050,7 @@ function QuickUsage() { const coolingDown = useCoolingDown(live.cooldownUntil || 0) const lowest = lowestQuotaCard(cards) const lowestPercent = lowest ? quotaPercent(lowest, mode) : null - const checkedAt = live.data?.checkedAt + const checkedAt = observedTime(live.data) const stale = checkedAt && nowMs - checkedAt >= POLL_MS const label = t(mode === 'used' ? 'popup.used' : 'popup.remaining') return jsxs(Popover, { open, onOpenChange: value => $quickOpen.set(value), children: [ diff --git a/probe.py b/probe.py index 51c192f..d02fb56 100644 --- a/probe.py +++ b/probe.py @@ -504,6 +504,10 @@ def _read_probe_result_cache(*, cli_only: bool, max_age: float = PROBE_MIN_INTER age = datetime.now(timezone.utc).timestamp() - float(fetched_at) if age < 0 or age > float(max_age): return None + # Caches written before observed_at existed were observed at fetched_at. + for snap in snapshots: + if isinstance(snap, dict): + snap.setdefault("observed_at", float(fetched_at)) return snapshots @@ -618,6 +622,7 @@ def _cached_anthropic_snapshot(*, scope: Optional[str] = None, max_age: Optional "windows": list(payload.get("windows") or []), } if isinstance(fetched_at, (int, float)) and math.isfinite(fetched_at): + out["observed_at"] = float(fetched_at) try: stamp = datetime.fromtimestamp(float(fetched_at), tz=timezone.utc).astimezone() as_of = stamp.strftime("%b %d, %I:%M %p").lstrip("0") @@ -2392,7 +2397,12 @@ def _pool_entries(provider: str) -> list[dict]: name = str(provider or "").strip() if not name: return [] - for home in _hermes_homes(): + homes = _hermes_homes() + # Hermes falls back per provider: a profile with no rows for a provider + # uses the base home's rows, even when it has an auth.json of its own. + if _profile_home is not None and _profile_home.parent.name == "profiles": + homes = [*homes, _profile_home.parent.parent] + for home in homes: store = _read_auth_store(home / "auth.json") if not store: continue @@ -4151,6 +4161,11 @@ def emit_page(snapshots: list) -> None: cli_snaps, cli_complete = _collect_cli() for snap in cli_snaps: _keep_snapshot(snapshots, have, labelled, snap) + # When the vendor answered, so cached reads report the real check time. + observed_at = datetime.now(timezone.utc).timestamp() + for snap in snapshots: + if isinstance(snap, dict): + snap.setdefault("observed_at", observed_at) if cli_complete and snapshots: _store_probe_result_cache(snapshots, cli_only=cli_only) emit_page(snapshots) diff --git a/test_probe_runtime.py b/test_probe_runtime.py index 3ab1be3..fc5e73a 100644 --- a/test_probe_runtime.py +++ b/test_probe_runtime.py @@ -65,7 +65,7 @@ def test_old_dependency_caches_are_ignored_and_not_written(self): self.assertFalse(cache.exists()) def test_vendor_error_caches_still_work(self): - snapshots = [{"provider": "anthropic", "details": ["HTTP 429"]}] + snapshots = [{"provider": "anthropic", "details": ["HTTP 429"], "observed_at": 1.0}] for source, probe in probes(): with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp: with patch.object(probe, "_probe_result_cache_path", return_value=Path(tmp) / "cache.json"): diff --git a/test_probe_slicing.py b/test_probe_slicing.py index 56884db..3ab53bc 100644 --- a/test_probe_slicing.py +++ b/test_probe_slicing.py @@ -101,7 +101,7 @@ def test_shared_cache_expiry_and_replacement_do_not_mutate_pins(self): for source, probe in probes(): with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp, \ patch.object(probe, '_resetwatch_cache_dir', return_value=Path(tmp)): - first = [{'provider': 'cached-' + str(i)} for i in range(12)] + first = [{'provider': 'cached-' + str(i), 'observed_at': 1.0} for i in range(12)] probe._store_probe_result_cache(first, cli_only=True) cached_rows = probe._read_probe_result_cache(cli_only=True) self.assertEqual(cached_rows, first) @@ -111,9 +111,9 @@ def test_shared_cache_expiry_and_replacement_do_not_mutate_pins(self): cached['fetched_at'] -= 301 cache.write_text(json.dumps(cached)) self.assertIsNone(probe._read_probe_result_cache(cli_only=True)) - probe._store_probe_result_cache([{'provider': 'replacement'}], cli_only=True) + probe._store_probe_result_cache([{'provider': 'replacement', 'observed_at': 2.0}], cli_only=True) self.assertEqual(probe._read_pinned_snapshots(token), first) - self.assertEqual(probe._read_probe_result_cache(cli_only=True), [{'provider': 'replacement'}]) + self.assertEqual(probe._read_probe_result_cache(cli_only=True), [{'provider': 'replacement', 'observed_at': 2.0}]) def test_expired_pin_is_not_replaced_by_shared_cache(self): for source, probe in probes(): diff --git a/test_profile_pool_fallback.py b/test_profile_pool_fallback.py new file mode 100644 index 0000000..1751671 --- /dev/null +++ b/test_profile_pool_fallback.py @@ -0,0 +1,74 @@ +"""A named profile falls back to the base home's credential pool per provider. + +Hermes's read_credential_pool() uses the base home's rows for a provider +whenever the profile has none for that provider, even if the profile has its +own auth.json. The probe must follow the same rule so the Claude card does +not disappear on a profile that runs on the base Claude login (#36). +""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import probe + + +def write_pool(home: Path, pool: dict) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "auth.json").write_text(json.dumps({"credential_pool": pool})) + + +class ProfilePoolFallbackTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) / "hermes" + self.profile = self.base / "profiles" / "curry" + self.profile.mkdir(parents=True) + write_pool(self.base, {"anthropic": [{"id": "base-claude"}]}) + + def tearDown(self): + self.tmp.cleanup() + + def pool(self, provider: str) -> list: + with patch.object(probe, "_profile_home", self.profile): + return [row["id"] for row in probe._pool_entries(provider)] + + def test_profile_without_auth_json_uses_base_rows(self): + self.assertEqual(self.pool("anthropic"), ["base-claude"]) + + def test_profile_with_other_provider_only_still_uses_base_rows(self): + write_pool(self.profile, {"openrouter": [{"id": "profile-openrouter"}]}) + self.assertEqual(self.pool("anthropic"), ["base-claude"]) + self.assertEqual(self.pool("openrouter"), ["profile-openrouter"]) + + def test_profile_with_own_rows_uses_only_those(self): + write_pool(self.profile, {"anthropic": [{"id": "profile-claude"}]}) + self.assertEqual(self.pool("anthropic"), ["profile-claude"]) + + def test_home_outside_profiles_has_no_fallback(self): + other = Path(self.tmp.name) / "elsewhere" / "curry" + other.mkdir(parents=True) + with patch.object(probe, "_profile_home", other): + self.assertEqual(probe._pool_entries("anthropic"), []) + + +class ProbeCacheObservedAtTest(unittest.TestCase): + def test_cache_read_keeps_observed_at_and_backfills_old_entries(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "probe.json" + now = probe.datetime.now(probe.timezone.utc).timestamp() + path.write_text(json.dumps({ + "fetched_at": now - 60, + "disabled_providers": [], + "snapshots": [{"provider": "a", "observed_at": now - 200}, {"provider": "b"}], + })) + with patch.object(probe, "_probe_result_cache_path", return_value=path), \ + patch.object(probe, "_disabled_providers", set()): + snaps = probe._read_probe_result_cache(cli_only=False) + self.assertEqual([snap["observed_at"] for snap in snaps], [now - 200, now - 60]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_quick_usage.cjs b/test_quick_usage.cjs index a819043..96222ce 100644 --- a/test_quick_usage.cjs +++ b/test_quick_usage.cjs @@ -60,7 +60,7 @@ function load({ modern = true, lifecycle = false, withQueryClient = true, quickU if (!(index in states)) states[index] = value return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next }] }, jsx, jsxs: jsx }) - vm.runInContext(source + '\nglobalThis.api = { clampPercent, quotaPercent, formatReset, cardsFromAccountSnapshots, liveQueryKey, readQuotaCache, saveQuotaCache, QuotaRing, fetchLiveCards, useLiveCardsQuery, QuickUsage, QuickUsageSlot, saveQuickUsage, checkedTime, $quickOpen, $displayMode };', context) + vm.runInContext(source + '\nglobalThis.api = { clampPercent, quotaPercent, formatReset, cardsFromAccountSnapshots, liveQueryKey, readQuotaCache, saveQuotaCache, QuotaRing, fetchLiveCards, useLiveCardsQuery, QuickUsage, QuickUsageSlot, saveQuickUsage, checkedTime, probeObservedAt, $quickOpen, $displayMode };', context) context.plugin.register({ storage: { get: (key, fallback) => saved.has(key) ? saved.get(key) : fallback, set: (key, value) => saved.set(key, value) }, onDispose: fn => disposers.push(fn), registerMany: rows => registered.push(...rows) }) @@ -367,3 +367,20 @@ test('older checks show their date, not just a time', () => { assert(sameDay && later.length > sameDay.length, `${later} should carry a date beyond ${sameDay}`) assert.equal(api.checkedTime(undefined, checkedAt), '') }) +test('probe cache hits report the vendor observation time, not the request time', () => { + const { api } = load() + const now = Date.parse('2030-01-01T12:10:00Z') + const observed = Date.parse('2030-01-01T12:04:00Z') + assert.equal(api.probeObservedAt([{ observed_at: observed / 1000 }, { observed_at: now / 1000 }], now), observed) + for (const snaps of [null, [], [{}], [{ observed_at: 'bad' }], [{ observed_at: now / 1000 + 60 }]]) { + assert.equal(api.probeObservedAt(snaps, now), now) + } +}) +test('the saved popup snapshot keeps its observation time', () => { + const { api, key, payload } = load() + const observedAt = payload.checkedAt - 240000 + api.saveQuotaCache(key, { ...payload, observedAt }) + const cached = api.readQuotaCache(key) + assert.equal(cached.observedAt, observedAt) + assert.equal(cached.checkedAt, payload.checkedAt) +})