diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index c3d20ebd878..577d98f1495 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { createContext, useContext, useMemo, useState } from 'react' import { Codicon } from '@/components/ui/codicon' @@ -62,6 +62,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const copy = t.shell.modelMenu const closeMenu = useContext(ModelMenuCloseContext) const [search, setSearch] = useState('') + const [refreshing, setRefreshing] = useState(false) + const queryClient = useQueryClient() // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing // the parent to rebuild the menu content (which would close the dropdown). @@ -110,6 +112,38 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model // next session.create (see selectModel). The default lives in Settings → Model. const switchTo = (model: string, provider: string) => onSelectModel({ model, provider }) + // Explicit "Refresh Models": re-fetch the catalog with refresh:true so the + // backend busts its 1h provider-model disk cache and re-pulls each provider's + // live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing + // when the cache expires and falls back to the curated static list. + const refreshModels = async () => { + if (refreshing) { + return + } + + setRefreshing(true) + + try { + const queryKey = ['model-options', activeSessionId || 'global'] + + const next = + gateway && activeSessionId + ? await gateway.request('model.options', { + session_id: activeSessionId, + refresh: true + }) + : await getGlobalModelOptions({ refresh: true }) + + queryClient.setQueryData(queryKey, next) + } catch { + // Network/backend hiccup — fall back to a plain invalidate so the next + // open re-fetches (still cached, but no worse than before). + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + } finally { + setRefreshing(false) + } + } + // Selecting a model row restores that model's remembered preset onto the // session (effort/fast), gated by capability. Unset → Hermes defaults. const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { @@ -268,6 +302,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model + { + event.preventDefault() + void refreshModels() + }} + > + + {copy.refreshModels} + + setModelVisibilityOpen(true)} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3b200a598f4..197e24611ab 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -660,10 +660,10 @@ export function getUsageAnalytics(days = 30): Promise { }) } -export function getGlobalModelOptions(): Promise { +export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: '/api/model/options' + path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options' }) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c1a7ec3879..d27741c44db 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1532,6 +1532,7 @@ export const en: Translations = { search: 'Search models', noModels: 'No models found', editModels: 'Edit Models…', + refreshModels: 'Refresh Models', fast: 'Fast', medium: 'Med' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 904e4b25c53..194452ed407 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1662,6 +1662,7 @@ export const ja = defineLocale({ search: 'モデルを検索', noModels: 'モデルが見つかりません', editModels: 'モデルを編集…', + refreshModels: 'モデルを更新', fast: '高速', medium: '中' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index dcf1028fb4b..94489e5de9e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1174,6 +1174,7 @@ export interface Translations { search: string noModels: string editModels: string + refreshModels: string fast: string medium: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8f208aff341..de329631098 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({ search: '搜尋模型', noModels: '找不到模型', editModels: '編輯模型…', + refreshModels: '重新整理模型', fast: '快速', medium: '中' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f368d3585ca..ac8c5c0b958 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1712,6 +1712,7 @@ export const zh: Translations = { search: '搜索模型', noModels: '未找到模型', editModels: '编辑模型…', + refreshModels: '刷新模型', fast: '快速', medium: '中' }, diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 7584dd887e0..7f0d3d220e6 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -117,6 +117,7 @@ def build_models_payload( pricing: bool = False, capabilities: bool = False, force_fresh_nous_tier: bool = False, + refresh: bool = False, max_models: int | None = None, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -144,6 +145,10 @@ def build_models_payload( selecting Portal-recommended Nous models and applying tier gating. Keep this false for UI picker opens; explicit auth/model flows can opt in when they need freshly-purchased credits to show up immediately. + - ``refresh``: bust the per-provider model-id disk cache so every row + re-fetches its live catalog. Set only for an explicit user-triggered + "refresh models" action; normal picker opens leave it false to stay + snappy on the 1h cache. """ from hermes_cli.model_switch import list_authenticated_providers @@ -155,6 +160,7 @@ def build_models_payload( custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier, max_models=max_models, + refresh=refresh, ) # --- Deduplicate: remove models from aggregators that overlap with diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index eae987fbbdf..2ed5b14790c 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1207,6 +1207,7 @@ def list_authenticated_providers( force_fresh_nous_tier: bool = False, max_models: int | None = None, current_model: str = "", + refresh: bool = False, ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1227,6 +1228,12 @@ def list_authenticated_providers( ``force_fresh_nous_tier`` bypasses the short Nous tier cache for explicit account-sensitive flows. UI picker opens should leave it false so they do not block on fresh Portal/account checks every time. + + ``refresh`` busts the per-provider model-id disk cache + (``provider_models_cache.json``) up front so every row re-fetches its + live catalog. Use for an explicit user-triggered "refresh models" action + (e.g. the desktop picker's refresh control); leave false for normal picker + opens so they stay snappy on the 1h cache. """ import os from agent.models_dev import ( @@ -1238,9 +1245,21 @@ def list_authenticated_providers( from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids, - get_curated_nous_model_ids, + clear_provider_models_cache, get_curated_nous_model_ids, ) + # Explicit refresh: drop every provider's cached model-id list so the + # cached_provider_model_ids() calls below all re-fetch live. Without this + # a stale 1h cache can fall back to the curated static list when its live + # fetch later fails, silently dropping live-only models (e.g. OpenCode + # Zen's free tier) the user had seen before. + if refresh: + try: + clear_provider_models_cache() + except Exception: + pass + + results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fb96f0f4b49..b2544ce9d77 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3479,7 +3479,7 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(profile: Optional[str] = None): +def get_model_options(profile: Optional[str] = None, refresh: bool = False): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the @@ -3490,6 +3490,10 @@ def get_model_options(profile: Optional[str] = None): ``profile`` scopes the picker context (current model/provider, custom providers from config, per-profile .env auth state) so the Models page reads the SAME profile /api/model/set writes. + + ``refresh`` busts the per-provider model-id disk cache so every row + re-fetches its live catalog — used by the picker's explicit "Refresh + Models" control. Normal opens leave it false to stay on the 1h cache. """ try: from hermes_cli.inventory import build_models_payload, load_picker_context @@ -3510,6 +3514,7 @@ def get_model_options(profile: Optional[str] = None): canonical_order=True, pricing=True, capabilities=True, + refresh=bool(refresh), ) except HTTPException: raise diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index c7d761515b1..2eff7bd460d 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -688,3 +688,40 @@ def test_build_models_payload_no_max_models_returns_full_list(): assert kilo_row["total_models"] == 100 assert len(kilo_row["models"]) == 100 + +# ─── refresh flag (cache-bust) ───────────────────────────────────────── + + +def test_build_models_payload_forwards_refresh_flag(): + """build_models_payload must forward refresh= to list_authenticated_providers. + + The desktop picker's "Refresh Models" control passes refresh=True; the + flag has to reach list_authenticated_providers so the per-provider + model-id cache gets busted. Default opens pass refresh=False. + """ + captured: dict = {} + + def _capture(*args, **kwargs): + captured["refresh"] = kwargs.get("refresh") + return [] + + with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture): + build_models_payload(_empty_ctx()) + assert captured["refresh"] is False + + with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture): + build_models_payload(_empty_ctx(), refresh=True) + assert captured["refresh"] is True + + +def test_list_authenticated_providers_refresh_busts_cache(): + """refresh=True clears the provider-model disk cache exactly once; + refresh=False leaves it untouched (so normal picker opens stay snappy).""" + from hermes_cli import model_switch + + with patch("hermes_cli.models.clear_provider_models_cache") as clear: + model_switch.list_authenticated_providers(refresh=False) + assert clear.call_count == 0 + model_switch.list_authenticated_providers(refresh=True) + assert clear.call_count == 1 + diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 294e543c230..1b92831df3d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9517,6 +9517,7 @@ def _(rid, params: dict) -> dict: canonical_order=True, pricing=True, capabilities=True, + refresh=bool(params.get("refresh")), ) return _ok(rid, payload) except Exception as e: