From 4f4bca8b306342ff19b715891aca8150dbb599bf Mon Sep 17 00:00:00 2001 From: higgs216 Date: Thu, 20 Aug 2026 21:10:32 +0900 Subject: [PATCH] fix(desktop,web): filter reasoning efforts by model capabilities --- .../src/app/shell/model-catalog-menu.tsx | 27 ++++++++- .../src/app/shell/model-edit-submenu.test.tsx | 39 ++++++++++++ .../src/app/shell/model-edit-submenu.tsx | 47 +++++++++++++-- apps/desktop/src/lib/reasoning-effort.test.ts | 18 +++++- apps/desktop/src/lib/reasoning-effort.ts | 59 +++++++++++++++++-- apps/desktop/src/types/hermes.ts | 3 + hermes_cli/inventory.py | 39 +++++++++--- hermes_cli/web_server.py | 20 +++++++ .../test_inventory_reasoning_caps.py | 32 +++++++--- tests/hermes_cli/test_web_server.py | 44 ++++++++++++++ web/src/components/ChatSidebar.tsx | 6 ++ web/src/components/ReasoningPicker.tsx | 17 +++++- web/src/lib/api.ts | 2 + web/src/lib/reasoning-effort.test.ts | 14 +++++ web/src/lib/reasoning-effort.ts | 22 +++++++ 15 files changed, 355 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 1dfd70f15b333..262c6a7452c3d 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -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 { @@ -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 } @@ -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 ?? [], @@ -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(' ') @@ -474,6 +494,7 @@ export function ModelCatalogMenu({ } provider={group.provider.slug} reasoning={caps?.reasoning ?? true} + supportedEfforts={caps?.supported_efforts} /> ) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 3685f8e44aaa1..cd67849c1c4bf 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -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( @@ -47,6 +48,7 @@ function renderSubmenu(opts: { onSetOptions={opts.onSetOptions} provider="p1" reasoning={opts.reasoning} + supportedEfforts={opts.supportedEfforts} /> @@ -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() diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 68b7281345cd4..e1ab05555838b 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -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. @@ -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) { @@ -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 @@ -151,7 +173,20 @@ function ModelEditSubmenuBody({ onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })} + onCheckedChange={checked => + onSetOptions({ + effort: checked + ? selectedEffort === 'none' + ? resolveSupportedReasoningEffort( + defaultEffort === 'none' ? 'medium' : '', + defaultEffort, + supportedEfforts, + false + ) + : selectedEffort + : 'none' + }) + } size="xs" /> @@ -167,7 +202,7 @@ function ModelEditSubmenuBody({ {copy.effort} onSetOptions({ effort: value })} value={effortValue}> - {REASONING_EFFORTS.map(value => ( + {effortLevels.map(value => ( { @@ -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') + }) }) diff --git a/apps/desktop/src/lib/reasoning-effort.ts b/apps/desktop/src/lib/reasoning-effort.ts index 5d3e17afd0508..e6b33fb9d0461 100644 --- a/apps/desktop/src/lib/reasoning-effort.ts +++ b/apps/desktop/src/lib/reasoning-effort.ts @@ -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 = { @@ -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 } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 783ee945e5ddd..2a642a4c0d7da 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 { diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index d122213be65a7..a1b4c26fca4b6 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -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 ─────────────────────────────────────────────────────── @@ -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 @@ -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 @@ -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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cc24499880861..41bb23031ea0e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6952,6 +6952,26 @@ def get_model_info(profile: Optional[str] = None): "max_output_tokens": mc.max_output_tokens, "model_family": mc.model_family, } + + # Aggregator catalogs can publish an exact per-route effort + # vocabulary that models.dev does not carry. Reuse the same + # cache-only reader as the Desktop model-options payload. + from hermes_cli.inventory import _REASONING_EFFORTS, _reasoning_catalog_reader + + read_reasoning_catalog = _reasoning_catalog_reader(str(provider).lower()) + if read_reasoning_catalog is not None: + detail = read_reasoning_catalog(model_name) + if detail and not detail.get("supports_reasoning"): + caps["supports_reasoning"] = False + elif detail: + caps["can_disable_reasoning"] = not bool(detail.get("mandatory")) + supported_efforts = detail.get("supported_efforts") + if isinstance(supported_efforts, list) and supported_efforts: + normalized = [str(value).strip().lower() for value in supported_efforts] + if all(value in _REASONING_EFFORTS for value in normalized): + caps["supported_efforts"] = [ + value for value in _REASONING_EFFORTS if value in normalized + ] except Exception: pass diff --git a/tests/hermes_cli/test_inventory_reasoning_caps.py b/tests/hermes_cli/test_inventory_reasoning_caps.py index 7fc40cc9c2ffe..cfe152b56f4d0 100644 --- a/tests/hermes_cli/test_inventory_reasoning_caps.py +++ b/tests/hermes_cli/test_inventory_reasoning_caps.py @@ -4,9 +4,9 @@ so a route that can't disable reasoning must be describable here — otherwise the UI offers an off switch whose setting the upstream rejects. -The catalog's `supported_efforts` is intentionally absent from the payload: -the Portal honors levels a route doesn't advertise, so publishing it would -invite a picker filter that hides working levels. +The catalog's `supported_efforts` is forwarded only when it is a non-empty, +fully recognized list. The picker can then show the exact route vocabulary; +missing or malformed metadata keeps the existing full-ladder fallback. """ import hermes_cli.inventory as inv @@ -40,12 +40,8 @@ def test_optional_reasoning_route_can_disable(monkeypatch): assert rows[0]["capabilities"]["deepseek/deepseek-v4-pro"]["can_disable_reasoning"] is True -def test_advertised_efforts_never_reach_the_picker(monkeypatch): - """The catalog's level list stays off the wire even when it is published. - - It under-reports what the Portal serves, so forwarding it would let the - picker hide levels that work. Only the disable verdict crosses. - """ +def test_advertised_efforts_reach_the_picker_in_canonical_order(monkeypatch): + """A valid catalog list is forwarded in Hermes' canonical order.""" _patch_catalog(monkeypatch, { "deepseek/deepseek-v4-pro": { "supports_reasoning": True, @@ -56,6 +52,24 @@ def test_advertised_efforts_never_reach_the_picker(monkeypatch): rows = [{"slug": "nous", "models": ["deepseek/deepseek-v4-pro"]}] inv._apply_capabilities(rows) + assert rows[0]["capabilities"]["deepseek/deepseek-v4-pro"]["supported_efforts"] == [ + "high", + "xhigh", + ] + + +def test_invalid_efforts_preserve_full_ladder_fallback(monkeypatch): + """Malformed or empty metadata is omitted so the UI keeps all levels.""" + _patch_catalog(monkeypatch, { + "deepseek/deepseek-v4-pro": { + "supports_reasoning": True, + "supported_efforts": ["high", "not-a-hermes-level"], + "mandatory": False, + }, + }) + rows = [{"slug": "nous", "models": ["deepseek/deepseek-v4-pro"]}] + inv._apply_capabilities(rows) + assert "supported_efforts" not in rows[0]["capabilities"]["deepseek/deepseek-v4-pro"] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 5c1fb1e9858ce..7ba6d7f1b930d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -259,6 +259,50 @@ def _setup_test_client(self, monkeypatch, _isolate_hermes_home): self.client = TestClient(app) self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + def test_model_info_exposes_route_reasoning_capabilities(self, monkeypatch): + """Dashboard model info carries the same exact effort metadata as Desktop.""" + from hermes_cli import web_server + + monkeypatch.setattr( + web_server, + "load_config", + lambda: { + "model": { + "default": "deepseek/deepseek-v4-pro", + "provider": "openrouter", + } + }, + ) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda **_kwargs: 0, + ) + monkeypatch.setattr( + "agent.models_dev.get_model_capabilities", + lambda **_kwargs: SimpleNamespace( + supports_tools=True, + supports_vision=False, + supports_reasoning=True, + context_window=128000, + max_output_tokens=8192, + model_family="deepseek", + ), + ) + monkeypatch.setattr( + "hermes_cli.inventory._reasoning_catalog_reader", + lambda _provider: lambda _model: { + "supports_reasoning": True, + "supported_efforts": ["high", "low"], + "mandatory": True, + }, + ) + + response = self.client.get("/api/model/info") + + assert response.status_code == 200 + assert response.json()["capabilities"]["supported_efforts"] == ["low", "high"] + assert response.json()["capabilities"]["can_disable_reasoning"] is False + @pytest.mark.requires_wal def test_get_sessions_poll_preserves_pending_wal(self): """Repeated GET-only polls must not checkpoint another writer's WAL.""" diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index e7669921129c2..96bf9d0d0d2b6 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -142,6 +142,8 @@ export function ChatSidebar({ // (currently unused) ModelInfoCard surfaces, so the dashboard exposes a // control to *set* the level, not just a read-only "Reasoning" badge. const [supportsReasoning, setSupportsReasoning] = useState(false); + const [reasoningEfforts, setReasoningEfforts] = useState(null); + const [canDisableReasoning, setCanDisableReasoning] = useState(null); // Bumped on model change/save so ReasoningPicker re-reads the saved effort // (config is profile-scoped the same way the model badge is). const [modelRefreshKey, setModelRefreshKey] = useState(0); @@ -160,6 +162,8 @@ export function ChatSidebar({ .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); setSupportsReasoning(!!r?.capabilities?.supports_reasoning); + setReasoningEfforts(r?.capabilities?.supported_efforts ?? null); + setCanDisableReasoning(r?.capabilities?.can_disable_reasoning ?? null); // Bump so ReasoningPicker re-reads the saved effort for the new model. setModelRefreshKey((k) => k + 1); }) @@ -493,6 +497,8 @@ export function ChatSidebar({ currentModel={modelName} profile={profile} refreshKey={modelRefreshKey} + reasoningEfforts={reasoningEfforts} + canDisableReasoning={canDisableReasoning} onChanged={(effort) => setModelNotice( `Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`, diff --git a/web/src/components/ReasoningPicker.tsx b/web/src/components/ReasoningPicker.tsx index cd45986a766c2..32fef9bb3df29 100644 --- a/web/src/components/ReasoningPicker.tsx +++ b/web/src/components/ReasoningPicker.tsx @@ -25,7 +25,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { - EFFORT_OPTIONS, + effortOptionsForModel, normalizeEffort, VALID_EFFORTS, } from "@/lib/reasoning-effort"; @@ -41,6 +41,8 @@ interface ReasoningPickerProps { /** Called after a successful change so the sidebar can show an "apply on * /new or reload" notice, matching the model-switch UX. */ onChanged?: (effort: string) => void; + reasoningEfforts?: string[] | null; + canDisableReasoning?: boolean | null; } export function ReasoningPicker({ @@ -48,11 +50,20 @@ export function ReasoningPicker({ profile, refreshKey = 0, onChanged, + reasoningEfforts, + canDisableReasoning, }: ReasoningPickerProps) { const [effort, setEffort] = useState("medium"); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); const lastFetchKeyRef = useRef(""); + const options = effortOptionsForModel( + reasoningEfforts ?? undefined, + canDisableReasoning ?? undefined, + ); + const selectedEffort = options.some((option) => option.value === effort) + ? effort + : options.find((option) => option.value !== "none")?.value ?? options[0]?.value ?? effort; useEffect(() => { const fetchKey = `${profile ?? ""}:${currentModel}:${refreshKey}`; @@ -112,9 +123,9 @@ export function ReasoningPicker({ className="ml-auto min-w-0" disabled={!loaded || saving} onValueChange={onSelect} - value={effort} + value={selectedEffort} > - {EFFORT_OPTIONS.map((opt) => ( + {options.map((opt) => ( {opt.label} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0d277ef589b2b..eecebefe15d1f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2397,6 +2397,8 @@ export interface ModelInfoResponse { supports_tools?: boolean; supports_vision?: boolean; supports_reasoning?: boolean; + can_disable_reasoning?: boolean; + supported_efforts?: string[]; context_window?: number; max_output_tokens?: number; model_family?: string; diff --git a/web/src/lib/reasoning-effort.test.ts b/web/src/lib/reasoning-effort.test.ts index 9c2d0b139aef1..371124ff990e1 100644 --- a/web/src/lib/reasoning-effort.test.ts +++ b/web/src/lib/reasoning-effort.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { EFFORT_OPTIONS, + effortOptionsForModel, VALID_EFFORTS, normalizeEffort, } from "./reasoning-effort"; @@ -45,3 +46,16 @@ describe("EFFORT_OPTIONS", () => { } }); }); + +describe("effortOptionsForModel", () => { + it("filters exact efforts and keeps Off separate", () => { + expect(effortOptionsForModel(["max", "low", "high"], true).map((option) => option.value)) + .toEqual(["none", "low", "high", "max"]); + expect(effortOptionsForModel(["low", "high"], false).map((option) => option.value)) + .toEqual(["low", "high"]); + }); + + it("preserves the full ladder when capability metadata is unknown", () => { + expect(effortOptionsForModel(undefined, undefined)).toBe(EFFORT_OPTIONS); + }); +}); diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts index 2d5fecd31207a..3d908662f0a20 100644 --- a/web/src/lib/reasoning-effort.ts +++ b/web/src/lib/reasoning-effort.ts @@ -29,6 +29,28 @@ export const VALID_EFFORTS: ReadonlySet = new Set( EFFORT_OPTIONS.map((o) => o.value), ); +/** Filter the picker to an exact model vocabulary when one is known. */ +export function effortOptionsForModel( + efforts?: readonly string[], + canDisableReasoning?: boolean, +): ReadonlyArray { + if (efforts === undefined && canDisableReasoning === undefined) { + return EFFORT_OPTIONS; + } + + const allowed = efforts === undefined + ? new Set(EFFORT_OPTIONS.filter((option) => option.value !== "none").map((option) => option.value)) + : new Set(efforts.map((value) => value.trim().toLowerCase())); + const enabled = EFFORT_OPTIONS.filter( + (option) => option.value !== "none" && allowed.has(option.value), + ); + const levels = enabled.length > 0 + ? enabled + : [EFFORT_OPTIONS.find((option) => option.value === "medium")!]; + + return canDisableReasoning === true ? [EFFORT_OPTIONS[0], ...levels] : levels; +} + /** Normalize a raw `agent.reasoning_effort` config value to a selectable * option. Empty/unknown → `medium` (Hermes' default when unset). */ export function normalizeEffort(raw: unknown): string {