diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index dbdaafcd34..4d51c5ab03 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -1092,7 +1092,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting - {m.autoUseMain} + {m.inheritMainEffort} {REASONING_EFFORT_VALUES.map(value => ( {value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]} @@ -1122,6 +1122,15 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting {!isAuto && current.base_url && ( · {current.base_url} )} + {current?.reasoning_effort && ( + + {' · '} + {current.reasoning_effort === 'none' + ? `${m.reasoning} ${m.reasoningOff}` + : (t.shell.modelOptions[current.reasoning_effort as keyof typeof t.shell.modelOptions] ?? + current.reasoning_effort)} + + )} } title={ diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 7b3711e9eb..c5aa899742 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -995,6 +995,7 @@ export const ar = defineLocale({ setToMain: 'ضبط على الرئيسي', change: 'تغيير', autoUseMain: 'تلقائي · استخدام النموذج الرئيسي', + inheritMainEffort: 'وراثة · جهد النموذج الرئيسي', providerDefault: '(افتراضي المزوّد)', tasks: { vision: { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2c2d3afb58..71ccec9737 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1307,6 +1307,7 @@ export const en: Translations = { setToMain: 'Set to main', change: 'Change', autoUseMain: 'auto · use main model', + inheritMainEffort: 'inherit · main model effort', providerDefault: '(provider default)', fallbackAdd: 'Add fallback', fallbackEmpty: 'No fallback models — the default model is used unless it fails.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index db22eaa6bb..c084ad85d6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1147,6 +1147,7 @@ export const ja = defineLocale({ setToMain: 'メインに設定', change: '変更', autoUseMain: '自動 · メインモデルを使用', + inheritMainEffort: '継承 · メインモデルの推論強度', providerDefault: '(プロバイダーのデフォルト)', tasks: { vision: { label: 'ビジョン', hint: '画像分析' }, diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 9c4dd27d2d..8a1918268c 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -1357,6 +1357,7 @@ export const ru = defineLocale({ setToMain: 'На основную', change: 'Изменить', autoUseMain: 'авто · использовать основную модель', + inheritMainEffort: 'наследовать · усилие основной модели', providerDefault: '(по умолчанию провайдера)', fallbackAdd: 'Добавить запасную', fallbackEmpty: 'Запасных моделей нет — используется модель по умолчанию, если она не падает.', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5f830e4136..b8be7faeae 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1150,6 +1150,7 @@ export interface Translations { setToMain: string change: string autoUseMain: string + inheritMainEffort: string providerDefault: string fallbackAdd: string fallbackEmpty: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 3490a6d284..c9830427d6 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1169,6 +1169,7 @@ export const zhHant = defineLocale({ setToMain: '設為主要模型', change: '變更', autoUseMain: '自動 · 使用主要模型', + inheritMainEffort: '繼承 · 主要模型推理強度', providerDefault: '(提供方預設)', moaTitle: '混合代理(Mixture of Agents)', moaPreset: '預設', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 50b9c9c9a6..81b9c14c05 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1525,6 +1525,7 @@ export const zh = defineLocale({ setToMain: '设为主模型', change: '更改', autoUseMain: '自动 · 使用主模型', + inheritMainEffort: '继承 · 主模型推理强度', providerDefault: '(提供方默认)', fallbackAdd: '添加备用模型', fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。', diff --git a/hermes_cli/web_routers/models.py b/hermes_cli/web_routers/models.py index 179908509a..262340bbc0 100644 --- a/hermes_cli/web_routers/models.py +++ b/hermes_cli/web_routers/models.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException from hermes_cli.web_deps import LateState, late from hermes_cli.web_server_config import ( - _AUX_TASK_SLOTS, _apply_model_assignment_sync, _dashboard_code_skew_guard, + _AUX_TASK_SLOTS, _UNSET, _apply_model_assignment_sync, _dashboard_code_skew_guard, ) from agent.model_metadata import is_local_endpoint from starlette.concurrency import run_in_threadpool @@ -188,6 +188,7 @@ def get_auxiliary_models(profile: Optional[str] = None): tasks.append({ "task": slot, "provider": str(slot_cfg.get("provider", "auto") or "auto"), "model": str(slot_cfg.get("model", "") or ""), "base_url": base_url, + "reasoning_effort": str(slot_cfg.get("reasoning_effort") or "") or None, # Lets the UI tell a free local/LAN pin from a forgotten paid-provider pin. "local_endpoint": is_local_endpoint(base_url), }) @@ -288,8 +289,11 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N return {"ok": False, "scope": scope, "provider": provider, "model": model, "confirm_required": True, "confirm_message": warning.message} + reasoning_effort = body.reasoning_effort if "reasoning_effort" in body.model_fields_set else _UNSET + def _apply_assignment(): with _profile_scope(body.profile or profile): - return _apply_model_assignment_sync(scope, provider, model, task, base_url, api_key) + return _apply_model_assignment_sync( + scope, provider, model, task, base_url, api_key, reasoning_effort=reasoning_effort) return await asyncio.to_thread(_apply_assignment) diff --git a/hermes_cli/web_server_config.py b/hermes_cli/web_server_config.py index 640d9e2b19..8f2207f236 100644 --- a/hermes_cli/web_server_config.py +++ b/hermes_cli/web_server_config.py @@ -685,7 +685,26 @@ def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url: } -def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str, base_url: str, api_key: str) -> dict: +# "Field omitted" sentinel for optional assignment fields whose None means "clear". +_UNSET: Any = object() + + +def _normalize_aux_reasoning_effort(value: Optional[str]) -> Optional[str]: + """``auxiliary..reasoning_effort`` value for an assignment: None clears (inherit), else the + canonical level (``none`` for a disable), 400 on an unknown level.""" + if value is None: + return None + from hermes_constants import parse_reasoning_effort + parsed = parse_reasoning_effort(value) + if parsed is None: + from hermes_constants import VALID_REASONING_EFFORTS + raise HTTPException(status_code=400, + detail=f"reasoning_effort must be one of: none, {', '.join(VALID_REASONING_EFFORTS)}") + return "none" if parsed.get("enabled") is False else parsed["effort"] + + +def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str, base_url: str, api_key: str, + reasoning_effort: Optional[str] = _UNSET) -> dict: from hermes_cli.config import save_config aux = cfg.get("auxiliary") if not isinstance(aux, dict): @@ -695,12 +714,15 @@ def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str, slot_cfg = aux.get(slot) return slot_cfg if isinstance(slot_cfg, dict) else {} + effort = _normalize_aux_reasoning_effort(reasoning_effort) if reasoning_effort is not _UNSET else _UNSET + if task == "__reset__": - # Reset every slot to provider="auto", model="" — keeps other fields intact. + # Reset every slot to provider="auto", model="", no effort override — keeps other fields intact. for slot in _AUX_TASK_SLOTS: slot_cfg = _slot(slot) slot_cfg["provider"] = "auto" slot_cfg["model"] = "" + slot_cfg.pop("reasoning_effort", None) slot_cfg.pop("base_url", None) clear_model_endpoint_credentials(slot_cfg) aux[slot] = slot_cfg @@ -734,15 +756,23 @@ def _apply_aux_assignment_sync(cfg: dict, provider: str, model: str, task: str, elif new_provider != prev_provider and new_provider != "custom": slot_cfg.pop("base_url", None) clear_model_endpoint_credentials(slot_cfg) + if effort is None: + slot_cfg.pop("reasoning_effort", None) + elif effort is not _UNSET: + slot_cfg["reasoning_effort"] = effort aux[slot] = slot_cfg cfg["auxiliary"] = aux save_config(cfg) - return {"ok": True, "scope": "auxiliary", "tasks": targets, "provider": provider, "model": model} + result = {"ok": True, "scope": "auxiliary", "tasks": targets, "provider": provider, "model": model} + if effort is not _UNSET: + result["reasoning_effort"] = effort + return result def _apply_model_assignment_sync( - scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = "" + scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = "", + reasoning_effort: Optional[str] = _UNSET, ): """Synchronous body of POST /api/model/set. @@ -753,7 +783,7 @@ def _apply_model_assignment_sync( cfg = load_config() if scope == "main": return _apply_main_assignment_sync(cfg, provider, model, base_url, api_key) - return _apply_aux_assignment_sync(cfg, provider, model, task, base_url, api_key) + return _apply_aux_assignment_sync(cfg, provider, model, task, base_url, api_key, reasoning_effort) def _infer_provider_on_model_change(model_val: str, prev_provider: str) -> tuple[str, str]: diff --git a/tests/hermes_cli/test_aux_assignment_reasoning_effort.py b/tests/hermes_cli/test_aux_assignment_reasoning_effort.py new file mode 100644 index 0000000000..7a31621f0c --- /dev/null +++ b/tests/hermes_cli/test_aux_assignment_reasoning_effort.py @@ -0,0 +1,52 @@ +"""Desktop Settings → Model → Auxiliary can set a task's reasoning effort (#89259, salvage #90649). + +``POST /api/model/set`` carries ``reasoning_effort`` for an auxiliary task: omitted → the task's +override is left alone; explicit null → cleared (inherit); a level → set. Runtime reads it from +``auxiliary..reasoning_effort`` (``agent/auxiliary_client.py``). +""" + +import pytest +from fastapi import HTTPException + +from hermes_cli.web_server_config import _UNSET, _apply_aux_assignment_sync + + +@pytest.fixture +def saved(monkeypatch): + store: dict = {} + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: store.update(cfg)) + return store + + +def test_reasoning_effort_field_semantics_omitted_null_and_level(saved): + cfg = {"auxiliary": {"vision": {"provider": "openrouter", "model": "m1", "reasoning_effort": "low"}}} + + # A plain provider/model re-assignment leaves an existing override alone. + _apply_aux_assignment_sync(cfg, "openrouter", "m2", "vision", "", "") + assert cfg["auxiliary"]["vision"] == {"provider": "openrouter", "model": "m2", "reasoning_effort": "low"} + + # A level sets it (canonicalised) and the response echoes it; a disable is stored as the explicit "none". + out = _apply_aux_assignment_sync(cfg, "openrouter", "m2", "vision", "", "", reasoning_effort="HIGH") + assert cfg["auxiliary"]["vision"]["reasoning_effort"] == "high" and out["reasoning_effort"] == "high" + _apply_aux_assignment_sync(cfg, "openrouter", "m2", "vision", "", "", reasoning_effort="disabled") + assert cfg["auxiliary"]["vision"]["reasoning_effort"] == "none" + + # Explicit null clears only this task's key; siblings and the pick survive. + cfg["auxiliary"]["compression"] = {"provider": "openrouter", "model": "m3", "reasoning_effort": "max"} + _apply_aux_assignment_sync(cfg, "openrouter", "m2", "vision", "", "", reasoning_effort=None) + assert "reasoning_effort" not in cfg["auxiliary"]["vision"] + assert cfg["auxiliary"]["vision"]["model"] == "m2" + assert cfg["auxiliary"]["compression"]["reasoning_effort"] == "max" + assert saved["auxiliary"] == cfg["auxiliary"] + + +def test_unknown_level_is_rejected_and_reset_clears_overrides(saved): + cfg = {"auxiliary": {"vision": {"provider": "openrouter", "model": "m1"}}} + with pytest.raises(HTTPException) as exc: + _apply_aux_assignment_sync(cfg, "openrouter", "m1", "vision", "", "", reasoning_effort="turbo") + assert exc.value.status_code == 400 and "reasoning_effort" in exc.value.detail + assert "reasoning_effort" not in cfg["auxiliary"]["vision"] + + cfg["auxiliary"]["vision"]["reasoning_effort"] = "high" + _apply_aux_assignment_sync(cfg, "", "", "__reset__", "", "", reasoning_effort=_UNSET) + assert all("reasoning_effort" not in slot for slot in cfg["auxiliary"].values()) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index bee47e4c6e..920ebed25e 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -184,6 +184,7 @@ Manage providers, models, tools, and credentials from a real UI instead of editi - **Terminal font picker** — choose an installed font in **Settings → Appearance**. Nerd Fonts such as `MesloLGS NF` render Powerlevel10k separators and icons in both interactive and agent terminals; the setting is saved per profile. - **Reopen Last Chat on Launch** — by default the app picks up where you left off on cold start. Turn it off in **Settings → Appearance** (or set `display.resume_last_session: false` in `config.yaml`) to always begin with a fresh chat. Deep links and explicit destinations are never overridden either way. - **Auxiliary-model warning** — if you switch the main model to a new provider while auxiliary tasks (titling, summarization, and similar helpers) are still pinned to another provider, the app warns you so you don't unknowingly split work across two providers. +- **Per-task reasoning effort** — each row under **Settings → Model → Auxiliary models** has a reasoning selector next to its provider/model pick: a level, **Off**, or **inherit · main model effort** (the default, which removes the task's override). It is saved as `auxiliary..reasoning_effort` in `config.yaml`, the same key `hermes model` writes, and shows in the row's summary when set. Use it to run frequent helpers such as compression or titling at low or no reasoning while the main agent stays at high. - **VS Code Marketplace themes** — beyond the built-in theme presets, the appearance settings include a live VS Code Marketplace search: pick any color theme and the app downloads, converts, and installs it as a desktop theme. The same importer is available from the command palette (*Install theme*), and imported themes can be removed again from the appearance settings. - **Keep computer awake** — **Settings → Advanced → Keep computer awake** stops the machine from sleeping so long or overnight agent runs keep going (the display can still dim). This is a per-computer setting.