mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix: limit desktop model pickers to explicit providers
This commit is contained in:
parent
0e04d14209
commit
37a4cf9000
20 changed files with 287 additions and 65 deletions
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ async function fetchProviderDefaultModel(
|
|||
let options
|
||||
|
||||
try {
|
||||
options = await getGlobalModelOptions()
|
||||
options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue