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
27 changes: 24 additions & 3 deletions apps/desktop/src/app/shell/model-catalog-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import {
DEFAULT_REASONING_EFFORT,
reasoningEffortLabel,
resolveSupportedReasoningEffort
} from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import {
Expand Down Expand Up @@ -207,7 +211,14 @@ export function ModelCatalogMenu({

controller.applyPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
effort: (caps?.reasoning ?? true)
? resolveSupportedReasoningEffort(
preset.effort ?? '',
defaultEffort,
caps?.supported_efforts,
caps?.can_disable_reasoning !== false
)
: undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ model: family.id, provider: provider.slug }
Expand Down Expand Up @@ -412,6 +423,13 @@ export function ModelCatalogMenu({
const effEffort = isCurrent ? current.effort : (preset.effort ?? '')
const effFast = isCurrent ? current.fast : (preset.fast ?? false)

const resolvedEffort = resolveSupportedReasoningEffort(
effEffort,
defaultEffort,
caps?.supported_efforts,
caps?.can_disable_reasoning !== false
)

const fastControl: FastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
Expand All @@ -421,7 +439,9 @@ export function ModelCatalogMenu({

const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
(caps?.reasoning ?? true) && resolvedEffort !== 'none'
? reasoningEffortLabel(resolvedEffort)
: null
]
.filter(Boolean)
.join(' ')
Expand Down Expand Up @@ -474,6 +494,7 @@ export function ModelCatalogMenu({
}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
supportedEfforts={caps?.supported_efforts}
/>
</DropdownMenuSub>
)
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function renderSubmenu(opts: {
onSelectModel?: (model: string) => void
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
reasoning: boolean
supportedEfforts?: readonly string[]
}) {
return render(
<DropdownMenu open>
Expand All @@ -47,6 +48,7 @@ function renderSubmenu(opts: {
onSetOptions={opts.onSetOptions}
provider="p1"
reasoning={opts.reasoning}
supportedEfforts={opts.supportedEfforts}
/>
</DropdownMenuSub>
</DropdownMenuContent>
Expand Down Expand Up @@ -94,6 +96,43 @@ describe('ModelEditSubmenu reports edits without performing them', () => {
expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' })
})

it('shows only exact supported efforts and selects a supported fallback', () => {
const onSetOptions = vi.fn()
renderSubmenu({
defaultEffort: 'ultra',
effort: 'ultra',
fastControl: { kind: 'none' },
onSetOptions,
reasoning: true,
supportedEfforts: ['low', 'high']
})

expect(screen.getByRole('menuitemradio', { name: 'Low' })).toBeTruthy()
expect(screen.getByRole('menuitemradio', { name: 'High' })).toBeTruthy()
expect(screen.queryByRole('menuitemradio', { name: 'Medium' })).toBeNull()
expect(screen.queryByRole('menuitemradio', { name: 'Ultra' })).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'Low' }).getAttribute('aria-checked')).toBe('true')

fireEvent.click(screen.getByRole('switch'))
expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' })
})

it('restores an enabled level when the model default is thinking off', () => {
const onSetOptions = vi.fn()
renderSubmenu({
defaultEffort: 'none',
effort: 'none',
fastControl: { kind: 'none' },
onSetOptions,
reasoning: true,
supportedEfforts: ['low', 'high']
})

fireEvent.click(screen.getByRole('switch'))

expect(onSetOptions).toHaveBeenCalledWith({ effort: 'low' })
})

it('variant fast: swaps the model only when the row is active', () => {
const onSelectModel = vi.fn()
const onSetOptions = vi.fn()
Expand Down
47 changes: 41 additions & 6 deletions apps/desktop/src/app/shell/model-edit-submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort'
import {
reasoningEffortsForModel,
resolveReasoningEffort,
resolveSupportedReasoningEffort
} from '@/lib/reasoning-effort'

// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned
// by the Thinking toggle, not the radio.
Expand Down Expand Up @@ -86,6 +90,8 @@ interface ModelEditSubmenuProps {
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
/** Exact catalog vocabulary, absent when the backend cannot determine it. */
supportedEfforts?: readonly string[]
}

export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
Expand All @@ -109,15 +115,31 @@ function ModelEditSubmenuBody({
isActive,
onSelectModel,
onSetOptions,
reasoning
reasoning,
supportedEfforts
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions

const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
const showThinkingToggle = reasoning && canDisableReasoning !== false

const selectedEffort = resolveSupportedReasoningEffort(
effort,
defaultEffort,
supportedEfforts,
canDisableReasoning !== false
)

const effortValue = resolveReasoningEffort(
effort,
defaultEffort,
supportedEfforts,
canDisableReasoning !== false
)

const thinkingOn = selectedEffort !== 'none'
const effortLevels = reasoningEffortsForModel(supportedEfforts)

const setFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id. Report the choice so the controller can
Expand Down Expand Up @@ -151,7 +173,20 @@ function ModelEditSubmenuBody({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })}
onCheckedChange={checked =>
onSetOptions({
effort: checked
? selectedEffort === 'none'
? resolveSupportedReasoningEffort(
defaultEffort === 'none' ? 'medium' : '',
defaultEffort,
supportedEfforts,
false
)
: selectedEffort
: 'none'
})
}
size="xs"
/>
</DropdownMenuItem>
Expand All @@ -167,7 +202,7 @@ function ModelEditSubmenuBody({
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => onSetOptions({ effort: value })} value={effortValue}>
{REASONING_EFFORTS.map(value => (
{effortLevels.map(value => (
<DropdownMenuRadioItem
className={dropdownMenuRow}
key={value}
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/lib/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
REASONING_EFFORT_VALUES,
REASONING_EFFORTS,
reasoningEffortLabel,
resolveReasoningEffort
reasoningEffortsForModel,
resolveReasoningEffort,
resolveSupportedReasoningEffort
} from './reasoning-effort'

describe('reasoning-effort', () => {
Expand Down Expand Up @@ -50,4 +52,18 @@ describe('reasoning-effort', () => {
expect(resolveReasoningEffort('none')).toBe('')
expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT)
})

it('filters exact model efforts in canonical order and falls back when unknown', () => {
expect(reasoningEffortsForModel(['xhigh', 'high'])).toEqual(['high', 'xhigh'])
expect(reasoningEffortsForModel(undefined)).toEqual([...REASONING_EFFORTS])
expect(reasoningEffortsForModel(['high', 'vendor-specific'])).toEqual([...REASONING_EFFORTS])
})

it('keeps thinking-off separate and resolves stale defaults deterministically', () => {
expect(resolveSupportedReasoningEffort('none', 'high', ['low', 'high'])).toBe('none')
expect(resolveSupportedReasoningEffort('ultra', 'medium', ['low', 'high'])).toBe('low')
expect(resolveSupportedReasoningEffort('', 'ultra', ['low', 'high'])).toBe('low')
expect(resolveReasoningEffort('ultra', 'medium', ['low', 'high'])).toBe('low')
expect(resolveReasoningEffort('none', 'high', ['low', 'high'], false)).toBe('high')
})
})
59 changes: 55 additions & 4 deletions apps/desktop/src/lib/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const
* specifies one (mirrors the backend's own fallback). */
export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium'

/** Return exact model-supported levels in Hermes' canonical order.
* Missing, empty, or malformed metadata deliberately falls back to the full
* ladder so an older backend or an unknown catalog shape never blanks the UI. */
export function reasoningEffortsForModel(supportedEfforts?: readonly string[]): ReasoningEffort[] {
if (!supportedEfforts?.length) {
return [...REASONING_EFFORTS]
}

const normalized = supportedEfforts.map(value => normalize(value))

if (normalized.some(value => !isReasoningEffort(value))) {
return [...REASONING_EFFORTS]
}

const supported = new Set(normalized)
const filtered = REASONING_EFFORTS.filter(value => supported.has(value))

return filtered.length > 0 ? filtered : [...REASONING_EFFORTS]
}

/** Compact labels for chrome where space is tight (pill, picker rows). Menus
* and settings use the translated `shell.modelOptions` strings instead. */
const SHORT_LABELS: Record<string, string> = {
Expand Down Expand Up @@ -43,12 +63,43 @@ export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REA

/** The level a scale control should show. Empty inherits `fallback`; `none`
* (thinking off) selects nothing; anything unrecognized clamps to the default. */
export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string {
export function resolveSupportedReasoningEffort(
effort: string,
fallback: string = DEFAULT_REASONING_EFFORT,
supportedEfforts?: readonly string[],
canDisableReasoning = true
): string {
const levels = reasoningEffortsForModel(supportedEfforts)
const value = normalize(effort || fallback)

if (value === 'none') {
return ''
if (value === 'none' && canDisableReasoning) {
return 'none'
}

if (isReasoningEffort(value) && levels.includes(value)) {
return value
}

return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT
const fallbackValue = normalize(fallback)

if (fallbackValue === 'none' && canDisableReasoning) {
return 'none'
}

if (isReasoningEffort(fallbackValue) && levels.includes(fallbackValue)) {
return fallbackValue
}

return levels[0] ?? DEFAULT_REASONING_EFFORT
}

export function resolveReasoningEffort(
effort: string,
fallback: string = DEFAULT_REASONING_EFFORT,
supportedEfforts?: readonly string[],
canDisableReasoning = true
): string {
const resolved = resolveSupportedReasoningEffort(effort, fallback, supportedEfforts, canDisableReasoning)

return resolved === 'none' ? '' : resolved
}
3 changes: 3 additions & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ export interface ModelCapabilities {
can_disable_reasoning?: boolean
fast: boolean
reasoning: boolean
/** Exact reasoning levels from the serving provider catalog. Absent when
* the catalog is unavailable or does not publish a valid vocabulary. */
supported_efforts?: string[]
}

export interface ModelOptionsResponse {
Expand Down
39 changes: 31 additions & 8 deletions hermes_cli/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@
from typing import Any, Optional


_REASONING_EFFORTS = (
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
)
_REASONING_EFFORT_SET = frozenset(_REASONING_EFFORTS)


# ─── Public types ───────────────────────────────────────────────────────


Expand Down Expand Up @@ -150,9 +162,11 @@ def build_models_payload(
mirroring the ``hermes model`` CLI picker. Adds network calls
(pricing fetch + Nous tier check); only set for interactive pickers.
- ``capabilities``: add a per-row ``capabilities`` map
``{model: {fast, reasoning}}`` so pickers can gate the model-options
controls (fast toggle / reasoning) to what each model actually
supports, instead of offering knobs the backend would reject.
``{model: {fast, reasoning, supported_efforts}}`` so pickers can gate
model-options controls to what each model actually supports, instead of
offering knobs the backend would reject. Exact effort lists are omitted
when the catalog is absent or malformed, preserving the full-ladder
compatibility fallback.
- ``featured``: add a per-row ``featured_models`` list β€” the newest few
models per lab (by models.dev release_date, ranked within the row's own
models; see ``_FEATURED_PER_LAB``) for aggregator providers that serve
Expand Down Expand Up @@ -445,11 +459,9 @@ def _apply_capabilities(rows: list[dict]) -> None:
parameter β€” a definitive negative from the provider actually serving the
model outranks the models.dev inference.

The catalog's `supported_efforts` list is deliberately NOT forwarded: it
under-reports. The Portal accepts and honors levels a route doesn't
advertise (``z-ai/glm-5.3`` publishes ``max, high, low`` yet serves
``minimal`` at its lowest thinking), so filtering the picker by that list
would hide levels that demonstrably work.
A non-empty, fully recognized `supported_efforts` list is forwarded in
canonical Hermes order. Missing or malformed metadata is omitted so older
backends and unknown catalog shapes retain the full-ladder fallback.
"""
from hermes_cli.models import model_supports_fast_mode

Expand Down Expand Up @@ -490,6 +502,17 @@ def _apply_capabilities(rows: list[dict]) -> None:
entry["reasoning"] = False
elif detail:
entry["can_disable_reasoning"] = not detail.get("mandatory")
raw_efforts = detail.get("supported_efforts")
if isinstance(raw_efforts, list) and raw_efforts:
normalized = [str(value).strip().lower() for value in raw_efforts]
if all(value in _REASONING_EFFORT_SET for value in normalized):
supported = [
value
for value in _REASONING_EFFORTS
if value in normalized
]
if supported:
entry["supported_efforts"] = supported

caps[model] = entry

Expand Down
Loading