mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-20 10:11:58 +00:00
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:
parent
28d887ca18
commit
620fd59b8e
12 changed files with 124 additions and 5 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,7 @@ export const ja = defineLocale({
|
|||
search: 'モデルを検索',
|
||||
noModels: 'モデルが見つかりません',
|
||||
editModels: 'モデルを編集…',
|
||||
refreshModels: 'モデルを更新',
|
||||
fast: '高速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,7 @@ export interface Translations {
|
|||
search: string
|
||||
noModels: string
|
||||
editModels: string
|
||||
refreshModels: string
|
||||
fast: string
|
||||
medium: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({
|
|||
search: '搜尋模型',
|
||||
noModels: '找不到模型',
|
||||
editModels: '編輯模型…',
|
||||
refreshModels: '重新整理模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1712,6 +1712,7 @@ export const zh: Translations = {
|
|||
search: '搜索模型',
|
||||
noModels: '未找到模型',
|
||||
editModels: '编辑模型…',
|
||||
refreshModels: '刷新模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue