feat(model-picker): add Refresh Models control to bust stale model cache (#48691)

The desktop model picker had no way to force a fresh model fetch: model.options
went through the 1h-cached provider_models_cache.json, and there was no flag to
bust it. When a provider's cached list expired and its next live fetch failed,
the picker fell back to the curated static list — silently dropping live-only
models (e.g. OpenCode Zen's free tier like deepseek-v4-flash-free) the user had
been using.

- Thread refresh through model.options (RPC + REST /api/model/options) ->
  build_models_payload -> list_authenticated_providers, which calls
  clear_provider_models_cache() up front when set so every row re-fetches live.
- Add a 'Refresh Models' control to the desktop picker (5-locale i18n, spinning
  sync icon). Normal opens leave refresh=false to stay snappy on the cache.

Verified: stale cache hides deepseek-v4-flash-free -> refresh busts it -> live
re-fetch surfaces it. refresh=false never touches the cache.
This commit is contained in:
Teknium 2026-06-18 21:37:41 -07:00 committed by GitHub
parent 28d887ca18
commit 620fd59b8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 124 additions and 5 deletions

View file

@ -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<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
queryClient.setQueryData<ModelOptionsResponse>(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
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}

View file

@ -660,10 +660,10 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
})
}
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: '/api/model/options'
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options'
})
}

View file

@ -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'
},

View file

@ -1662,6 +1662,7 @@ export const ja = defineLocale({
search: 'モデルを検索',
noModels: 'モデルが見つかりません',
editModels: 'モデルを編集…',
refreshModels: 'モデルを更新',
fast: '高速',
medium: '中'
},

View file

@ -1174,6 +1174,7 @@ export interface Translations {
search: string
noModels: string
editModels: string
refreshModels: string
fast: string
medium: string
}

View file

@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({
search: '搜尋模型',
noModels: '找不到模型',
editModels: '編輯模型…',
refreshModels: '重新整理模型',
fast: '快速',
medium: '中'
},

View file

@ -1712,6 +1712,7 @@ export const zh: Translations = {
search: '搜索模型',
noModels: '未找到模型',
editModels: '编辑模型…',
refreshModels: '刷新模型',
fast: '快速',
medium: '中'
},

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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: