Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/catalog-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions catalog/desktop/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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.
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [
Expand Down
17 changes: 16 additions & 1 deletion catalog/desktop/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 23 additions & 5 deletions plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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.
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [
Expand Down
17 changes: 16 additions & 1 deletion probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test_probe_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
6 changes: 3 additions & 3 deletions test_probe_slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand Down
74 changes: 74 additions & 0 deletions test_profile_pool_fallback.py
Original file line number Diff line number Diff line change
@@ -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()
19 changes: 18 additions & 1 deletion test_quick_usage.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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)
})
Loading