Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
48 changes: 10 additions & 38 deletions apps/desktop/src/app/settings/model-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -45,24 +49,16 @@ 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'
}
]
})
getAuxiliaryModels.mockResolvedValue({
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 })
Expand All @@ -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 () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/app/shell/model-menu-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gateway && activeSessionId) {
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
}

return getGlobalModelOptions()
Expand Down Expand Up @@ -140,6 +143,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
gateway && activeSessionId
? await gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/components/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export function ModelPickerDialog({
queryFn: () => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId
session_id: sessionId,
explicit_only: true
})
}

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/components/model-visibility-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,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()
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/onboarding/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,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(
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/hermes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
import { getGlobalModelOptions, getSessionMessages, listAllProfileSessions, listSessions } from './hermes'

const emptySessionsResponse = {
limit: 0,
Expand Down Expand Up @@ -57,4 +57,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'
})
)
})
})
22 changes: 20 additions & 2 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,10 +756,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'
})
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ async function fetchProviderDefaultModel(
let options

try {
options = await getGlobalModelOptions()
options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
} catch {
return null
}
Expand Down
20 changes: 20 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,26 @@ 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:")
or source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"}
or source.startswith("manual:")
):
return True
except Exception:
pass

return False


Expand Down
39 changes: 39 additions & 0 deletions hermes_cli/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -124,6 +125,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).
Expand Down Expand Up @@ -167,6 +172,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
Expand Down Expand Up @@ -295,6 +303,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.

Expand Down
22 changes: 12 additions & 10 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4162,7 +4162,12 @@ def get_model_info(profile: Optional[str] = None):


@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
Expand All @@ -4181,18 +4186,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,
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"ronaldrj@gmail.com": "rarf", # PR #56966 (desktop chat model picker: hide implicitly discovered providers unless explicitly configured)
"jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
"jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
"zhangml@tech.icbc.com.cn": "zmlgit", # PR #54872 salvage (multiplex-profile kanban: route task notifications via the owning profile's adapter + wake the creator agent with a synthetic internal MessageEvent on terminal events)
Expand Down
Loading