fix: limit desktop model pickers to explicit providers

This commit is contained in:
Ronald Reis 2026-07-02 10:55:07 +01:00 committed by Teknium
parent 0e04d14209
commit 37a4cf9000
20 changed files with 287 additions and 65 deletions

View file

@ -358,7 +358,10 @@ export function ChatView({
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
},
enabled: gatewayOpen
})

View file

@ -13,8 +13,10 @@ beforeAll(() => {
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const getMoaModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const saveMoaModels = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
getMoaModels: () => getMoaModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
@ -45,15 +49,6 @@ beforeEach(() => {
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{
name: 'DeepSeek',
slug: 'deepseek',
models: [],
authenticated: false,
auth_type: 'api_key',
key_env: 'DEEPSEEK_API_KEY'
}
]
})
@ -61,8 +56,9 @@ beforeEach(() => {
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
getMoaModels.mockResolvedValue(null)
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
@ -80,43 +76,19 @@ async function renderModelSettings() {
}
describe('ModelSettings', () => {
it('loads the current main model and lists the full provider universe', async () => {
it('loads the current main model and lists configured providers only', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select — every provider from the full payload should be
// listed, including the unconfigured one with its "set up" hint.
// Open the provider Select — only configured providers should be listed.
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])
// "Nous" shows in both the trigger and the open list; the unconfigured
// provider + its setup hint are the unique signal of the full universe.
// "Nous" shows in both the trigger and the open list.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
expect(await screen.findByText(/set up/)).toBeTruthy()
})
it('activates an unconfigured api_key provider inline by saving its key', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select and pick the unconfigured provider.
const triggers = screen.getAllByRole('combobox')
fireEvent.click(triggers[0])
const deepseekOption = await screen.findByText(/DeepSeek/)
fireEvent.click(deepseekOption)
// The inline key input appears for an api_key provider that needs setup.
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
const activate = await screen.findByRole('button', { name: /Activate/ })
fireEvent.click(activate)
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
expect(screen.queryByText(/DeepSeek/)).toBeNull()
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {

View file

@ -45,7 +45,10 @@ export function ModelVisibilityDialog({
queryKey: ['model-options', sessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId,
explicit_only: true
})
}
return getGlobalModelOptions()

View file

@ -237,7 +237,7 @@ function ConfirmingModelPanel({
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
const options = useQuery({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
})
const providerRow = options.data?.providers?.find(

View file

@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
// Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
// tests) is funneled into the same .catch instead of escaping.
void Promise.resolve()
.then(() => getGlobalModelOptions())
.then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }))
.then(res => {
if (!cancelled) {
setRows(res.providers ?? [])

View file

@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => {
[getHermesConfig, '/api/config'],
[getHermesConfigDefaults, '/api/config/defaults'],
[getGlobalModelInfo, '/api/model/info'],
[() => getGlobalModelOptions(), '/api/model/options'],
[() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'],
[getCronJobs, '/api/cron/jobs']
]
@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => {
profile: 'xiaoxuxu'
})
})
it('defaults model options to configured providers only', async () => {
await getGlobalModelOptions()
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/model/options?explicit_only=1'
})
)
})
it('can opt into unconfigured providers for onboarding flows', async () => {
await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false })
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/model/options?refresh=1&include_unconfigured=1'
})
)
})
})

View file

@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
})
}
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
export function getGlobalModelOptions(opts?: {
refresh?: boolean
includeUnconfigured?: boolean
explicitOnly?: boolean
}): Promise<ModelOptionsResponse> {
const params = new URLSearchParams()
if (opts?.refresh) {
params.set('refresh', '1')
}
if (opts?.includeUnconfigured) {
params.set('include_unconfigured', '1')
}
if (opts?.explicitOnly !== false) {
params.set('explicit_only', '1')
}
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options',
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}

View file

@ -1,12 +1,17 @@
import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
interface ModelOptionsRequest {
/** When false, include ambient/unconfigured providers (onboarding/setup
* surfaces). Chat pickers default to true so only explicitly configured
* providers are listed (#56974). */
explicitOnly?: boolean
gateway?: HermesGateway
refresh?: boolean
sessionId?: null | string
}
export function requestModelOptions({
explicitOnly = true,
gateway,
refresh = false,
sessionId
@ -22,8 +27,12 @@ export function requestModelOptions({
params.refresh = true
}
if (explicitOnly) {
params.explicit_only = true
}
return gateway.request<ModelOptionsResponse>('model.options', params)
}
return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
}

View file

@ -239,7 +239,7 @@ async function fetchProviderDefaultModel(
let options
try {
options = await getGlobalModelOptions()
options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
} catch {
return null
}

View file

@ -1495,6 +1495,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool:
if has_usable_secret(os.getenv(env_var, "")):
return True
# 4. Check persisted credential-pool entries that came from EXPLICIT flows
# the user initiated inside Hermes (manual add / device-code / PKCE), plus
# env-backed pool entries. This intentionally excludes ambient borrowed
# sources like gh_cli / claude_code / qwen-cli.
try:
for entry in read_credential_pool(normalized):
if not isinstance(entry, dict):
continue
source = str(entry.get("source") or "").strip().lower()
if not source:
continue
if source.startswith("env:"):
# A stale env-seeded pool entry survives in auth.json after
# the user deletes the env var (#55790) — only count it when
# the referenced var still resolves to a usable secret NOW.
env_var = entry.get("source", "").split(":", 1)[1].strip()
if env_var and has_usable_secret(os.getenv(env_var, "")):
return True
continue
if (
source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"}
or source.startswith("manual:")
):
return True
except Exception:
pass
return False

View file

@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext:
def build_models_payload(
ctx: ConfigContext,
*,
explicit_only: bool = False,
include_unconfigured: bool = False,
picker_hints: bool = False,
canonical_order: bool = False,
@ -126,6 +127,10 @@ def build_models_payload(
needs from a single substrate call.
Flags:
- ``explicit_only``: keep only providers the user explicitly configured
(current provider, providers from config, or providers backed by
provider-specific env vars). This hides ambient / auto-seeded
credentials from desktop chat pickers.
- ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
``list_authenticated_providers`` didn't emit (TUI uses this to show
the full provider universe in the picker).
@ -180,6 +185,9 @@ def build_models_payload(
if moa_row is not None:
rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
if explicit_only:
rows = _filter_explicit_provider_rows(rows, ctx)
# --- Deduplicate: remove models from aggregators that overlap with
# user-defined providers. When a local proxy (e.g. litellm-proxy)
# serves a model whose name also appears in an aggregator's curated
@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict
return extras
def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
"""Keep only rows backed by explicit user configuration.
``list_authenticated_providers`` intentionally discovers ambient / auto-
seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model
pickers want the narrower subset the user explicitly configured for Hermes.
"""
from hermes_cli.auth import is_provider_explicitly_configured
current_slug = str(ctx.current_provider or "").strip().lower()
kept: list[dict] = []
for row in rows:
slug = str(row.get("slug", "")).strip().lower()
if not slug:
continue
if row.get("is_user_defined"):
kept.append(row)
continue
if current_slug and slug == current_slug:
kept.append(row)
continue
if slug == "moa":
# MoA is a virtual routing mode, not an independently configured
# provider. Hide it from explicit-only pickers unless it is the
# current provider (handled above).
continue
if is_provider_explicitly_configured(slug):
kept.append(row)
return kept
def _apply_picker_hints(rows: list[dict]) -> None:
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.

View file

@ -4284,7 +4284,12 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = (
@app.get("/api/model/options")
def get_model_options(profile: Optional[str] = None, refresh: bool = False):
def get_model_options(
profile: Optional[str] = None,
refresh: bool = False,
include_unconfigured: bool = False,
explicit_only: bool = False,
):
"""Return authenticated providers + their curated model lists.
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
@ -4303,18 +4308,15 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False):
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
# include_unconfigured + picker_hints + canonical_order mirror the
# tui_gateway `model.options` JSON-RPC handler exactly, so every GUI
# surface fed by this endpoint (Settings → Model, the first-run
# onboarding picker) sees the SAME full provider universe `hermes model`
# exposes — not just the authenticated subset. Unconfigured providers
# come back as skeleton rows carrying `authenticated=False` +
# `auth_type`/`key_env`/`warning` so the GUI can render a setup
# affordance instead of hiding the provider entirely.
# Most desktop surfaces should only list providers the user has already
# configured. Onboarding opts into the full provider universe via
# include_unconfigured=1 so it can still render setup affordances for
# providers that are not yet authenticated.
with _profile_scope(profile):
return build_models_payload(
load_picker_context(),
include_unconfigured=True,
explicit_only=bool(explicit_only),
include_unconfigured=bool(include_unconfigured),
picker_hints=True,
canonical_order=True,
pricing=True,

View file

@ -378,6 +378,44 @@ def test_include_unconfigured_skips_already_present_slugs():
assert or_rows[0]["models"] == ["m1"] # the authenticated row, not skeleton
def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_rows():
rows = [
{"slug": "openai-codex", "name": "OpenAI Codex", "models": ["gpt-5.4"],
"total_models": 1, "is_current": True, "is_user_defined": False,
"source": "hermes"},
{"slug": "gemini", "name": "Gemini", "models": ["gemini-2.5-pro"],
"total_models": 1, "is_current": False, "is_user_defined": False,
"source": "built-in"},
{"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"],
"total_models": 1, "is_current": False, "is_user_defined": False,
"source": "hermes"},
{"slug": "nous", "name": "Nous", "models": ["anthropic/claude-sonnet-5"],
"total_models": 1, "is_current": False, "is_user_defined": False,
"source": "hermes"},
{"slug": "custom:lab", "name": "Lab", "models": ["lab-1"],
"total_models": 1, "is_current": False, "is_user_defined": True,
"source": "user-config"},
{"slug": "moa", "name": "MoA", "models": ["default"],
"total_models": 1, "is_current": False, "is_user_defined": False,
"source": "virtual"},
]
ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4")
with (
_list_auth_returning(rows),
patch(
"hermes_cli.auth.is_provider_explicitly_configured",
side_effect=lambda slug: slug == "gemini",
),
):
payload = build_models_payload(ctx, explicit_only=True)
assert [row["slug"] for row in payload["providers"]] == [
"openai-codex",
"gemini",
"custom:lab",
]
# ─── picker_hints ──────────────────────────────────────────────────────

View file

@ -316,6 +316,36 @@ class TestProfileScopedModel:
resp = client.get("/api/model/options", params={"profile": "ghost"})
assert resp.status_code == 404
def test_model_options_hides_unconfigured_providers_by_default(self, client, monkeypatch):
calls = []
monkeypatch.setattr(
"hermes_cli.inventory.load_picker_context",
lambda: object(),
)
def _fake_build_models_payload(_ctx, **kwargs):
calls.append(kwargs)
return {"providers": [], "model": "", "provider": ""}
monkeypatch.setattr(
"hermes_cli.inventory.build_models_payload",
_fake_build_models_payload,
)
resp = client.get("/api/model/options")
assert resp.status_code == 200
assert calls[-1]["explicit_only"] is False
assert calls[-1]["include_unconfigured"] is False
resp = client.get("/api/model/options", params={"explicit_only": "1"})
assert resp.status_code == 200
assert calls[-1]["explicit_only"] is True
resp = client.get("/api/model/options", params={"include_unconfigured": "1"})
assert resp.status_code == 200
assert calls[-1]["include_unconfigured"] is True
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
"""Regression: the broad except used to convert the 404 into a 200
with empty model info ("no model set" silently wrong)."""

View file

@ -5984,6 +5984,52 @@ def test_model_options_propagates_list_exception(monkeypatch):
assert "catalog blew up" in resp["error"]["message"]
def test_model_options_hides_unconfigured_providers_by_default(monkeypatch):
from hermes_cli.inventory import ConfigContext
calls = []
monkeypatch.setattr(server, "_resolve_model", lambda: "")
monkeypatch.setattr(
"hermes_cli.inventory.load_picker_context",
lambda: ConfigContext(
current_provider="",
current_model="",
current_base_url="",
user_providers={},
custom_providers=[],
),
)
def _fake_build_models_payload(_ctx, **kwargs):
calls.append(kwargs)
return {"providers": [], "model": "", "provider": ""}
monkeypatch.setattr(
"hermes_cli.inventory.build_models_payload",
_fake_build_models_payload,
)
resp = server._methods["model.options"](99, {"session_id": ""})
assert "result" in resp, resp
assert calls[-1]["explicit_only"] is False
assert calls[-1]["include_unconfigured"] is False
resp = server._methods["model.options"](
100,
{"session_id": "", "explicit_only": True},
)
assert "result" in resp, resp
assert calls[-1]["explicit_only"] is True
resp = server._methods["model.options"](
101,
{"session_id": "", "include_unconfigured": True},
)
assert "result" in resp, resp
assert calls[-1]["include_unconfigured"] is True
# ---------------------------------------------------------------------------
# prompt.submit — auto-title
# ---------------------------------------------------------------------------

View file

@ -12398,17 +12398,18 @@ def _(rid, params: dict) -> dict:
),
current_base_url=getattr(agent, "base_url", "") if agent else "",
)
# picker_hints + canonical_order produce the TUI's required shape:
# picker_hints + canonical_order produce the TUI/desktop picker shape:
# `authenticated`/`auth_type`/`key_env`/`warning` per row, in
# CANONICAL_PROVIDERS declaration order. include_unconfigured=True
# so the picker can show the full provider universe (with the
# setup-hint warning attached) instead of only authed rows.
# CANONICAL_PROVIDERS declaration order. Desktop pickers default to the
# configured subset; callers that need setup affordances can pass
# include_unconfigured=true explicitly.
# Curated model lists are preserved — list_authenticated_providers
# populates `models` from the curated catalog, not provider_model_ids
# (which would pull non-agentic models like TTS/embeddings/etc.).
payload = build_models_payload(
ctx,
include_unconfigured=True,
explicit_only=bool(params.get("explicit_only")),
include_unconfigured=bool(params.get("include_unconfigured")),
picker_hints=True,
canonical_order=True,
pricing=True,

View file

@ -60,7 +60,12 @@ export function ModelPicker({
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {
...(sessionId ? { session_id: sessionId } : {}),
...(initialRefresh ? { refresh: true } : {})
...(initialRefresh ? { refresh: true } : {}),
// The TUI picker shows the full provider universe with setup
// affordances ("paste KEY to activate"), so opt into unconfigured
// rows — the backend now defaults to the configured subset for
// desktop chat pickers (#56974).
include_unconfigured: true
})
.then(raw => {
const r = asRpcResult<ModelOptionsResponse>(raw)

View file

@ -138,6 +138,10 @@ export function ModelPickerDialog(props: Props) {
{
...(sessionId ? { session_id: sessionId } : {}),
...(refresh ? { refresh: true } : {}),
// Dashboard picker mirrors the TUI: full provider universe with
// setup warnings. The backend now defaults to the configured
// subset (#56974), so opt into unconfigured rows explicitly.
include_unconfigured: true,
},
);

View file

@ -22,7 +22,7 @@ describe("api.getModelOptions", () => {
await api.getModelOptions({ refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?refresh=1",
"/api/model/options?refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});
@ -41,7 +41,7 @@ describe("api.getModelOptions", () => {
await api.getModelOptions({ profile: "default", refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?profile=default&refresh=1",
"/api/model/options?profile=default&refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});

View file

@ -487,6 +487,11 @@ export const api = {
const qs = new URLSearchParams();
if (profile) qs.set("profile", profile);
if (refresh) qs.set("refresh", "1");
// Dashboard surfaces (Models page, profile builder, cron) are
// management/setup UIs: keep the full provider universe with setup
// affordances. The endpoint now defaults to the configured subset for
// desktop chat pickers (#56974), so opt in explicitly here.
qs.set("include_unconfigured", "1");
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return fetchJSON<ModelOptionsResponse>(`/api/model/options${suffix}`);
},