From de0e6eb452f13beea6b343d759f1412f17c65498 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 9 Jul 2026 20:31:54 -0700 Subject: [PATCH] Release: centralize auxiliary-model task catalog (#5865) (#5870) Auxiliary-model task definitions were maintained in BOTH Python and JS and could drift (the retired session_search task lingered in one). Centralize to a single backend catalog that panels.js renders+saves from exclusively; purge the retired slot on reset while preserving other custom bindings; old configs still load. Gate-pass: Codex SAFE (only session_search removed, 11 other tasks + defaults unchanged, 22 keys match across 15 locales, legacy migrate preserves custom bindings, executable DOM harness rendered all rows). 53 own + 887 regression green. Co-authored-by: t Co-authored-by: starship-s --- CHANGELOG.md | 2 + api/config.py | 103 ++++++----- static/i18n.js | 30 ---- static/panels.js | 147 +++++++++------ tests/test_auxiliary_models_settings.py | 228 ++++++++++++++++++++++-- 5 files changed, 365 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 723ae5d3..37cddfbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixed +- **The auxiliary-model settings now come from a single catalog, so the task list can't drift.** The auxiliary/helper-model task definitions were maintained separately in Python and JavaScript, which let the two lists fall out of sync (and left the retired `session_search` task lingering in one). The settings panel now renders and saves exclusively from a single backend catalog; the retired task is purged on reset while every other custom binding is preserved, and existing configs referencing the old task still load cleanly. Thanks @starship-s. (#5865) + - **The Experimental update channel now shows the real installed version instead of "unknown" / a bare SHA on a stable-tagged install.** When an install pinned to a stable `v*` tag opts into the Experimental channel, no `exp-v*` tag sits behind HEAD, so the channel-scoped version lookup returned nothing and the UI rendered "unknown" (and a bogus ahead-count of 1). The check now recovers the real installed version and the true number of Experimental releases ahead. The installed-version fallback is scoped to the WebUI repo (never injected into the Agent repo's payload), and the compare-link ref is always a git-verified tag (or omitted) rather than a possibly-dirty version string. Thanks @nesquena-hermes. (#5864, #5862) - **The activity worklog no longer freezes the browser on reasoning-heavy history (#4633 frontend half).** A settled turn's activity worklog (the reasoning + tool steps behind the "Processed Xm Ys" chip) can carry 80+ rows; in compact-worklog mode (the default) every collapsed group still eagerly built all its row DOM, so a long history ballooned the DOM and a later synchronous layout could tip the tab into a multi-GB freeze. Collapsed settled worklogs now defer their row DOM until first expand. Expanded rows still get full post-processing (syntax highlighting, copy buttons, Mermaid, KaTeX, structured trees) and their open/scroll state is preserved across transcript rebuilds — matching (and, for nested-card scroll, improving on) the eager path. Thanks @nesquena-hermes. (#5860, #5839, #4633) diff --git a/api/config.py b/api/config.py index 878a983c..84f28626 100644 --- a/api/config.py +++ b/api/config.py @@ -4144,23 +4144,66 @@ def set_hermes_default_model(model_id: str, provider: str | None = None, advance # ── Auxiliary model configuration ────────────────────────────────────────── -# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py -# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS. -AUX_TASK_SLOTS: tuple[str, ...] = ( - "vision", - "web_extract", - "compression", - "session_search", - "skills_hub", - "approval", - "mcp", - "title_generation", - "curator", - "kanban_decomposer", - "profile_describer", - "triage_specifier", +# Canonical auxiliary task catalog. +# Keep in sync with hermes_cli/config.py DEFAULT_CONFIG["auxiliary"] and +# hermes_cli/web_server.py _AUX_TASK_SLOTS. +AUXILIARY_TASK_CATALOG: tuple[dict[str, str], ...] = ( + {"key": "vision", "label": "Vision", "description": "image/screenshot analysis"}, + {"key": "web_extract", "label": "Web extract", "description": "web page summarization"}, + {"key": "compression", "label": "Compression", "description": "context summarization"}, + {"key": "approval", "label": "Approval", "description": "smart command approval"}, + {"key": "mcp", "label": "MCP", "description": "MCP tool reasoning"}, + {"key": "title_generation", "label": "Title generation", "description": "session titles"}, + {"key": "skills_hub", "label": "Skills hub", "description": "skills search/install"}, + {"key": "curator", "label": "Curator", "description": "skill-usage review pass"}, + {"key": "kanban_decomposer", "label": "Kanban decomposer", "description": "task decomposition"}, + {"key": "profile_describer", "label": "Profile describer", "description": "profile summaries"}, + {"key": "triage_specifier", "label": "Triage specifier", "description": "issue/task triage specs"}, ) +AUX_TASK_SLOTS: tuple[str, ...] = tuple(item["key"] for item in AUXILIARY_TASK_CATALOG) + +# Slots removed from the WebUI catalog whose persisted assignments should be +# discarded when the user explicitly resets all auxiliary-model routing. +RETIRED_AUX_TASK_SLOTS: tuple[str, ...] = ("session_search",) + + +def _aux_task_payload(task_key: str, entry: dict, fallback_label: str = "", fallback_description: str = "") -> dict: + """Build the API payload row for a single auxiliary task.""" + if not isinstance(entry, dict): + entry = {} + return { + "task": task_key, + "provider": str(entry.get("provider") or "auto").strip() or "auto", + "model": str(entry.get("model") or "").strip(), + "base_url": str(entry.get("base_url") or "").strip(), + "timeout": entry.get("timeout", ""), + "download_timeout": entry.get("download_timeout", ""), + "max_concurrency": entry.get("max_concurrency", ""), + "extra_body": entry.get("extra_body") if isinstance(entry.get("extra_body"), dict) else {}, + "api_key_set": bool(str(entry.get("api_key") or "").strip()), + "label": fallback_label, + "description": fallback_description, + } + + +def _iter_auxiliary_task_rows() -> list[dict]: + """Return canonical auxiliary task payload rows.""" + aux_cfg = cfg.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + + rows: list[dict] = [] + + # Canonical, first-class tasks from WebUI's catalog. + for slot in AUXILIARY_TASK_CATALOG: + key = str(slot["key"]).strip() + if not key: + continue + rows.append(_aux_task_payload(key, aux_cfg.get(key, {}), slot["label"], slot["description"])) + + return rows + def get_auxiliary_models() -> dict: """Return current auxiliary task assignments from config.yaml. @@ -4181,26 +4224,7 @@ def get_auxiliary_models() -> dict: main_provider = str(model_cfg.get("provider") or "").strip() main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() - aux_cfg = cfg.get("auxiliary", {}) - if not isinstance(aux_cfg, dict): - aux_cfg = {} - - tasks = [] - for slot in AUX_TASK_SLOTS: - entry = aux_cfg.get(slot, {}) - if not isinstance(entry, dict): - entry = {} - tasks.append({ - "task": slot, - "provider": str(entry.get("provider") or "auto").strip(), - "model": str(entry.get("model") or "").strip(), - "base_url": str(entry.get("base_url") or "").strip(), - "timeout": entry.get("timeout", ""), - "download_timeout": entry.get("download_timeout", ""), - "max_concurrency": entry.get("max_concurrency", ""), - "extra_body": entry.get("extra_body") if isinstance(entry.get("extra_body"), dict) else {}, - "api_key_set": bool(str(entry.get("api_key") or "").strip()), - }) + tasks = _iter_auxiliary_task_rows() return { "tasks": tasks, @@ -4238,20 +4262,19 @@ def set_auxiliary_model(task: str, provider: str, model: str, advanced: dict | N Sensitive api_key values are write-only: get_auxiliary_models() only reports whether one is set. """ - if task != "__reset__" and task not in AUX_TASK_SLOTS: - raise ValueError( - f"Unknown auxiliary task slot: {task!r}. Valid: {list(AUX_TASK_SLOTS)}" - ) config_path = _get_config_path() with _cfg_lock: config_data = _load_yaml_config_file(config_path) - + if task != "__reset__" and task not in AUX_TASK_SLOTS: + raise ValueError(f"Unknown auxiliary task slot: {task!r}. Valid: {list(AUX_TASK_SLOTS)}") if task == "__reset__": # Per-slot reset: set each slot to auto, preserving extra fields # (timeout, extra_body, api_key, base_url, download_timeout, etc.) aux_cfg = config_data.get("auxiliary", {}) if not isinstance(aux_cfg, dict): aux_cfg = {} + for retired_slot in RETIRED_AUX_TASK_SLOTS: + aux_cfg.pop(retired_slot, None) for slot in AUX_TASK_SLOTS: slot_cfg = aux_cfg.get(slot, {}) if not isinstance(slot_cfg, dict): diff --git a/static/i18n.js b/static/i18n.js index 5550b4a5..073f9e57 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -772,8 +772,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -2475,8 +2473,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -4169,8 +4165,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'コンテキストの要約・圧縮', settings_aux_task_web_extract: 'ウェブページ抽出', settings_aux_task_web_extract_desc: 'ウェブページの要約', - settings_aux_task_session_search: 'セッション検索', - settings_aux_task_session_search_desc: '過去の会話の呼び出し', settings_aux_task_approval: '承認', settings_aux_task_approval_desc: 'コマンド実行のスマート承認', settings_aux_task_mcp: 'MCP', @@ -5581,8 +5575,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -7192,8 +7184,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -8798,8 +8788,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'Kontext-Zusammenfassung', settings_aux_task_web_extract: 'Web-Extraktion', settings_aux_task_web_extract_desc: 'Webseiten-Zusammenfassung', - settings_aux_task_session_search: 'Sitzungssuche', - settings_aux_task_session_search_desc: 'Erinnerung an frühere Gespräche', settings_aux_task_approval: 'Freigabe', settings_aux_task_approval_desc: 'intelligente Befehlsfreigabe', settings_aux_task_mcp: 'MCP', @@ -10475,8 +10463,6 @@ const LOCALES = { settings_aux_task_compression_desc: '上下文摘要', settings_aux_task_web_extract: '网页提取', settings_aux_task_web_extract_desc: '网页内容摘要', - settings_aux_task_session_search: '会话搜索', - settings_aux_task_session_search_desc: '历史对话召回', settings_aux_task_approval: '批准', settings_aux_task_approval_desc: '智能指令批准', settings_aux_task_mcp: 'MCP', @@ -12389,8 +12375,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -13979,8 +13963,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -15572,8 +15554,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -17278,8 +17258,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'résumé du contexte', settings_aux_task_web_extract: 'Extraction web', settings_aux_task_web_extract_desc: 'résumé de page web', - settings_aux_task_session_search: 'Recherche de session', - settings_aux_task_session_search_desc: 'rappel de conversations passées', settings_aux_task_approval: 'Approbation', settings_aux_task_approval_desc: 'approbation intelligente des commandes', settings_aux_task_mcp: 'MCP', @@ -19043,8 +19021,6 @@ const LOCALES = { settings_aux_task_mcp_desc: 'uvažování o nástrojích MCP', settings_aux_task_profile_describer: 'Profil popisovač', settings_aux_task_profile_describer_desc: 'souhrny profilů', - settings_aux_task_session_search: 'Hledání relací', - settings_aux_task_session_search_desc: 'připomenutí minulých konverzací', settings_aux_task_skills_hub: 'Centrum dovedností', settings_aux_task_skills_hub_desc: 'hledání/instalace dovedností', settings_aux_task_title_generation: 'Generování názvů', @@ -20576,8 +20552,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'context summarization', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'web page summarization', - settings_aux_task_session_search: 'Session search', - settings_aux_task_session_search_desc: 'past-conversation recall', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'smart command approval', settings_aux_task_mcp: 'MCP', @@ -22292,8 +22266,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'streszczanie kontekstu', settings_aux_task_web_extract: 'Ekstrakcja treści z sieci', settings_aux_task_web_extract_desc: 'streszczanie stron internetowych', - settings_aux_task_session_search: 'Wyszukiwanie sesji', - settings_aux_task_session_search_desc: 'przywoływanie minionych konwersacji', settings_aux_task_approval: 'Zatwierdzanie', settings_aux_task_approval_desc: 'inteligentne zatwierdzanie poleceń', settings_aux_task_mcp: 'MCP', @@ -24855,8 +24827,6 @@ const LOCALES = { settings_aux_task_compression_desc: 'tóm tắt ngữ cảnh', settings_aux_task_web_extract: 'Web extract', settings_aux_task_web_extract_desc: 'trích xuất và tóm tắt trang web', - settings_aux_task_session_search: 'Tìm phiên', - settings_aux_task_session_search_desc: 'truy hồi hội thoại cũ', settings_aux_task_approval: 'Approval', settings_aux_task_approval_desc: 'phê duyệt lệnh thông minh', settings_aux_task_mcp: 'MCP', diff --git a/static/panels.js b/static/panels.js index 43b062f0..bdd74ac9 100644 --- a/static/panels.js +++ b/static/panels.js @@ -11897,24 +11897,8 @@ async function checkUpdatesNow(channelOverride){ // ── Auxiliary Models ────────────────────────────────────────────────────────── -// Canonical auxiliary task slots with display names. -// Keep in sync with hermes_cli/main.py _AUX_TASKS and hermes_cli/web_server.py _AUX_TASK_SLOTS. -const _AUX_TASK_SLOTS=[ - {key:'vision',nameKey:'settings_aux_task_vision',descKey:'settings_aux_task_vision_desc'}, - {key:'compression',nameKey:'settings_aux_task_compression',descKey:'settings_aux_task_compression_desc'}, - {key:'web_extract',nameKey:'settings_aux_task_web_extract',descKey:'settings_aux_task_web_extract_desc'}, - {key:'session_search',nameKey:'settings_aux_task_session_search',descKey:'settings_aux_task_session_search_desc'}, - {key:'approval',nameKey:'settings_aux_task_approval',descKey:'settings_aux_task_approval_desc'}, - {key:'mcp',nameKey:'settings_aux_task_mcp',descKey:'settings_aux_task_mcp_desc'}, - {key:'title_generation',nameKey:'settings_aux_task_title_generation',descKey:'settings_aux_task_title_generation_desc'}, - {key:'skills_hub',nameKey:'settings_aux_task_skills_hub',descKey:'settings_aux_task_skills_hub_desc'}, - {key:'curator',nameKey:'settings_aux_task_curator',descKey:'settings_aux_task_curator_desc'}, - {key:'kanban_decomposer',nameKey:'settings_aux_task_kanban_decomposer',descKey:'settings_aux_task_kanban_decomposer_desc'}, - {key:'profile_describer',nameKey:'settings_aux_task_profile_describer',descKey:'settings_aux_task_profile_describer_desc'}, - {key:'triage_specifier',nameKey:'settings_aux_task_triage_specifier',descKey:'settings_aux_task_triage_specifier_desc'}, -]; - let _auxProviders=[]; // cached provider list from /api/models +let _auxTasks=[]; // sanitized auxiliary task configs from /api/model/auxiliary let _auxOriginalConfig=null; // snapshot of initial config for dirty detection let _mainAdvancedConfig=null; // current advanced config for the default chat model @@ -11922,6 +11906,50 @@ function _auxSelectStyle(){ return 'width:100%;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;box-sizing:border-box'; } +function _auxTaskLabelFromMeta(taskKey, taskCfg){ + const nameKey='settings_aux_task_'+taskKey; + const descKey=nameKey+'_desc'; + const tName=t(nameKey); + const tDesc=t(descKey); + const name=(tName&&tName!==nameKey)?String(tName).trim():''; + const description=(tDesc&&tDesc!==descKey)?String(tDesc).trim():''; + const fallbackName=(taskCfg&&typeof taskCfg.label==='string'&&taskCfg.label.trim())?String(taskCfg.label).trim():taskKey; + const fallbackDesc=(taskCfg&&typeof taskCfg.description==='string'&&taskCfg.description.trim())?String(taskCfg.description).trim():''; + return { + task: taskKey, + label: name||fallbackName, + description: description||fallbackDesc, + }; +} + +function _normalizeAuxiliaryTasks(rawTasks){ + const tasks=Array.isArray(rawTasks)?rawTasks:[]; + const out=[]; + const seen=new Set(); + for(const rawTask of tasks){ + if(!rawTask||typeof rawTask!=='object') continue; + const task=(typeof rawTask.task==='string'?String(rawTask.task).trim():''); + if(!task||seen.has(task)) continue; + seen.add(task); + const meta=_auxTaskLabelFromMeta(task,rawTask); + const entry={ + task, + provider:String(rawTask.provider||'auto').trim()||'auto', + model:String(rawTask.model||'').trim(), + base_url:String(rawTask.base_url||'').trim(), + timeout:rawTask.timeout, + download_timeout:rawTask.download_timeout, + max_concurrency:rawTask.max_concurrency, + extra_body:rawTask.extra_body&&typeof rawTask.extra_body==='object'?rawTask.extra_body:{}, + api_key_set:!!rawTask.api_key_set, + label:meta.label, + description:meta.description, + }; + out.push(entry); + } + return out; +} + function _buildAuxProviderOptions(sel,providers,currentProvider){ sel.innerHTML=''; // "auto" = use main model @@ -12055,13 +12083,14 @@ function _mainModelSupportsServiceTier(cfg){ return cfg&&cfg.supports_fast_tier===true; } -function _openAuxAdvancedOptions(taskKey,cfg){ - const isMain=taskKey==='__main__'; - const slot=isMain?{key:taskKey,nameKey:'settings_label_model',descKey:'settings_desc_model'}:(_AUX_TASK_SLOTS.find(s=>s.key===taskKey)||{key:taskKey,nameKey:'',descKey:''}); +function _openAuxAdvancedOptions(taskCfg,cfg){ + const isMain=taskCfg==='__main__'; + const taskKey=isMain?'__main__':(taskCfg&&typeof taskCfg==='object'&&typeof taskCfg.task==='string'?taskCfg.task:typeof taskCfg==='string'?taskCfg:''); + const slot=isMain?{task:taskKey,label:(t('settings_label_model')||'Default model')}:_auxTaskLabelFromMeta(taskKey,taskCfg); const overlay=_ensureAuxAdvancedModal(); overlay.dataset.task=taskKey; const title=$('auxAdvancedTitle'),sub=$('auxAdvancedSubtitle'),body=$('auxAdvancedBody'); - const slotName=t(slot.nameKey)||slot.key; + const slotName=isMain?(t('settings_label_model')||'Default model'):(slot&&slot.label)||taskKey; if(title) title.textContent=isMain?(t('settings_main_advanced_title')||'Main model options'):((t('settings_aux_advanced_title')||'{task} options').replace('{task}',slotName)); if(sub) sub.textContent=isMain?(t('settings_main_advanced_subtitle')||'Advanced config for the default chat model.'):(t('settings_aux_advanced_subtitle')||'Advanced config for auxiliary.'); const extraBody=cfg&&cfg.extra_body&&typeof cfg.extra_body==='object'&&Object.keys(cfg.extra_body).length?JSON.stringify(cfg.extra_body,null,2):''; @@ -12185,68 +12214,68 @@ async function _loadAuxiliaryModels(){ container.innerHTML='
'+(t('settings_aux_loading')||'Loading…')+'
'; try{ - // Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists - const [auxData,modelsData]=await Promise.all([ - api('/api/model/auxiliary').catch(()=>null), - api('/api/models').catch(()=>null), - ]); - // Build provider list from /api/models groups - // /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] } - const groups=(modelsData&&modelsData.groups)||[]; - _auxProviders=groups.filter(g=>g.provider&&((g.models&&g.models.length>0)||(g.extra_models&&g.extra_models.length>0))).map(g=>({ - slug:g.provider_id||g.provider, - name:g.provider, - models:[...(g.models||[]),...(g.extra_models||[])].map(m=>m.id), - })); - if(auxData&&Object.prototype.hasOwnProperty.call(auxData,'main')){ - _mainAdvancedConfig=auxData.main||{}; - }else{ - _mainAdvancedConfig=null; - } - _bindMainAdvancedOptionsButton(); - const tasks=(auxData&&auxData.tasks)||[]; - // Build a quick lookup: taskKey → {provider, model} + // Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists + const [auxData,modelsData]=await Promise.all([ + api('/api/model/auxiliary').catch(()=>null), + api('/api/models').catch(()=>null), + ]); + // Build provider list from /api/models groups + // /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] } + const groups=(modelsData&&modelsData.groups)||[]; + _auxProviders=groups.filter(g=>g.provider&&((g.models&&g.models.length>0)||(g.extra_models&&g.extra_models.length>0))).map(g=>({ + slug:g.provider_id||g.provider, + name:g.provider, + models:[...(g.models||[]),...(g.extra_models||[])].map(m=>m.id), + })); + if(auxData&&Object.prototype.hasOwnProperty.call(auxData,'main')){ + _mainAdvancedConfig=auxData.main||{}; + }else{ + _mainAdvancedConfig=null; + } + _bindMainAdvancedOptionsButton(); + _auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[]); + // Build a quick lookup: taskKey → config const taskMap={}; - for(const t of tasks) taskMap[t.task]=t; + for(const task of _auxTasks) taskMap[task.task]=task; _auxOriginalConfig=JSON.parse(JSON.stringify(taskMap)); container.innerHTML=''; - for(const slot of _AUX_TASK_SLOTS){ - const cfg=taskMap[slot.key]||{provider:'auto',model:''}; + for(const task of _auxTasks){ + const cfg=taskMap[task.task]||{provider:'auto',model:''}; const row=document.createElement('div'); row.style.cssText='display:grid;grid-template-columns:120px 1fr 1fr 34px;gap:8px;align-items:center;margin-bottom:8px'; // Task name + description const label=document.createElement('div'); label.style.cssText='font-size:12px;font-weight:500;color:var(--text);line-height:1.3'; - label.innerHTML=esc(t(slot.nameKey)||slot.key)+'
'+esc(t(slot.descKey)||'')+'
'; + label.innerHTML=esc(task.label||task.task)+'
'+esc(task.description||'')+'
'; row.appendChild(label); // Provider select const provSel=document.createElement('select'); - provSel.id='aux-prov-'+slot.key; + provSel.id='aux-prov-'+task.task; provSel.style.cssText=_auxSelectStyle(); _buildAuxProviderOptions(provSel,_auxProviders,cfg.provider); - provSel.addEventListener('change',()=>_onAuxProviderChange(slot.key,_auxProviders)); + provSel.addEventListener('change',()=>_onAuxProviderChange(task.task,_auxProviders)); row.appendChild(provSel); // Model select const modelSel=document.createElement('select'); - modelSel.id='aux-model-'+slot.key; + modelSel.id='aux-model-'+task.task; modelSel.style.cssText=_auxSelectStyle(); _buildAuxModelOptions(modelSel,cfg.provider,_auxProviders,cfg.model); - modelSel.addEventListener('change',()=>_onAuxModelChange(slot.key)); + modelSel.addEventListener('change',()=>_onAuxModelChange(task.task)); row.appendChild(modelSel); const advancedBtn=document.createElement('button'); advancedBtn.type='button'; advancedBtn.className='aux-advanced-btn model-advanced-btn'; const advTitle=t('settings_aux_advanced_button_title')||'Advanced options'; - const slotName=t(slot.nameKey)||slot.key; + const taskName=task.label||task.task; advancedBtn.title=advTitle; - advancedBtn.setAttribute('aria-label',(t('settings_aux_advanced_button_aria')||'Advanced options for {task}').replace('{task}',slotName)); + advancedBtn.setAttribute('aria-label',(t('settings_aux_advanced_button_aria')||'Advanced options for {task}').replace('{task}',taskName)); advancedBtn.innerHTML=typeof li==='function'?li('settings',15):'⚙'; - advancedBtn.addEventListener('click',()=>_openAuxAdvancedOptions(slot.key,cfg)); + advancedBtn.addEventListener('click',()=>_openAuxAdvancedOptions(task,cfg)); row.appendChild(advancedBtn); container.appendChild(row); @@ -12284,20 +12313,20 @@ async function _loadAuxiliaryModels(){ async function _applyAuxModels(){ let saved=0; - for(const slot of _AUX_TASK_SLOTS){ - const provSel=$('aux-prov-'+slot.key); - const modelSel=$('aux-model-'+slot.key); + for(const task of _auxTasks){ + const provSel=$('aux-prov-'+task.task); + const modelSel=$('aux-model-'+task.task); if(!provSel) continue; const provider=provSel.value; const model=(modelSel&&modelSel.value!=='__custom__')?(modelSel.value||''):''; - const orig=_auxOriginalConfig?.[slot.key]||{provider:'auto',model:''}; + const orig=_auxOriginalConfig?.[task.task]||{provider:'auto',model:''}; // Only save if changed if(provider!==orig.provider||model!==orig.model){ try{ - await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:slot.key,provider,model})}); + await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:task.task,provider,model})}); saved++; }catch(e){ - console.warn('[settings] failed to save aux task',slot.key,e); + console.warn('[settings] failed to save aux task',task.task,e); if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to save auxiliary model'); return; } diff --git a/tests/test_auxiliary_models_settings.py b/tests/test_auxiliary_models_settings.py index 0e3fff0e..0456ac23 100644 --- a/tests/test_auxiliary_models_settings.py +++ b/tests/test_auxiliary_models_settings.py @@ -4,14 +4,22 @@ Verifies that the auxiliary models card is present in the settings HTML, that the JS loading/saving logic is wired up, and that all locales have the required i18n keys. """ +import json +import shutil +import subprocess + from pathlib import Path from types import SimpleNamespace +import pytest + ROOT = Path(__file__).parent.parent -PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +PANELS_JS_PATH = ROOT / "static" / "panels.js" +PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8") INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") STREAMING_PY = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8") +NODE = shutil.which("node") class TestAuxiliaryModelsHTML: @@ -63,18 +71,102 @@ class TestAuxiliaryModelsJS: "Missing _applyAuxModels() in panels.js" ) - def test_aux_task_slots_defined(self): - """_AUX_TASK_SLOTS must list the 12 canonical task slots.""" - assert "_AUX_TASK_SLOTS" in PANELS_JS, ( - "Missing _AUX_TASK_SLOTS constant in panels.js" + def test_auxiliary_task_metadata_is_normalized(self): + """Frontend should keep auxiliary task rows backed by normalized metadata.""" + assert "let _auxTasks=[]" in PANELS_JS, "Missing _auxTasks cache in panels.js" + assert "function _normalizeAuxiliaryTasks" in PANELS_JS, ( + "Missing _normalizeAuxiliaryTasks() in panels.js" ) - # Verify all 12 tasks are present - for key in ("vision", "compression", "web_extract", "session_search", - "approval", "mcp", "title_generation", "skills_hub", "curator", - "kanban_decomposer", "profile_describer", "triage_specifier"): - assert f"key:'{key}'" in PANELS_JS, ( - f"Missing auxiliary task slot '{key}' in _AUX_TASK_SLOTS" - ) + assert "function _auxTaskLabelFromMeta" in PANELS_JS, ( + "Missing _auxTaskLabelFromMeta() in panels.js" + ) + assert "_auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[])" in PANELS_JS, ( + "Auxiliary load flow must normalize API task payload" + ) + assert "for(const task of _auxTasks)" in PANELS_JS, ( + "Auxiliary rows should be rendered from normalized API payload" + ) + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") + def test_normalize_auxiliary_tasks_keeps_first_wins_and_unknown_metadata(self): + """Normalization should keep first occurrence and preserve unknown metadata.""" + script = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[1], 'utf8'); + +function extract(name){ + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if(start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 0; + while(i < src.length){ + const ch = src[i]; + if(ch === '{') depth += 1; + else if(ch === '}') { + depth -= 1; + if(depth === 0){ + break; + } + } + i += 1; + } + if(depth !== 0) throw new Error(name + ' parse failed'); + return src.slice(start, i + 1); +} + +global.t = (key) => { + const dict = { + settings_aux_task_vision: 'Vision', + settings_aux_task_vision_desc: 'image/screenshot analysis', + }; + return Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : key; +}; + +eval(extract('_auxTaskLabelFromMeta')); +eval(extract('_normalizeAuxiliaryTasks')); + +const normalized = _normalizeAuxiliaryTasks([ + {task: 'vision', provider: 'openai', model: 'gpt-5.5', label: 'Backend Vision', description: 'backend desc'}, + {task: 'future_task', provider: 'openai', model: 'future-1', label: 'Future Task', description: 'future tool'}, + {task: 'vision', provider: 'openai', model: 'gpt-5.6'}, + null, + {}, +]); + +const vision = normalized.find((entry) => entry.task === 'vision'); +const futureTask = normalized.find((entry) => entry.task === 'future_task'); + +console.log(JSON.stringify({ + tasks: normalized.map((entry) => entry.task), + visionLabel: vision ? vision.label : null, + visionDescription: vision ? vision.description : null, + futureLabel: futureTask ? futureTask.label : null, + futureDescription: futureTask ? futureTask.description : null, +})); +""" + + proc = subprocess.run( + [NODE, "-e", script, str(PANELS_JS_PATH)], + capture_output=True, + text=True, + timeout=20, + ) + assert proc.returncode == 0, f"node probe failed:\n{proc.stderr}" + result = json.loads(proc.stdout.strip().splitlines()[-1]) + assert result["tasks"] == ["vision", "future_task"], ( + "normalize should keep duplicate removal and payload order" + ) + assert result["visionLabel"] == "Vision" + assert result["visionDescription"] == "image/screenshot analysis" + assert result["futureLabel"] == "Future Task" + assert result["futureDescription"] == "future tool" + + def test_no_hardcoded_aux_task_slot_array(self): + """Auxiliary rows must not come from a hardcoded task-slot list.""" + assert "_AUX_TASK_SLOTS" not in PANELS_JS + assert "const _AUX_TASK_SLOTS" not in PANELS_JS + assert "session_search" not in PANELS_JS def test_advanced_options_button_and_modal_wiring(self): """Each auxiliary row and the main model should expose gear-driven advanced config editing.""" @@ -104,6 +196,17 @@ class TestAuxiliaryModelsJS: ): assert marker in PANELS_JS + def test_normalize_auxiliary_tasks_deduplicates_and_rejects_malformed(self): + """Frontend normalization should keep only first occurrence and valid payload entries.""" + assert "if(!rawTask||typeof rawTask!=='object') continue;" in PANELS_JS + assert "if(!task||seen.has(task)) continue;" in PANELS_JS + + def test_auxiliary_load_uses_api_payload_order(self): + """Row rendering should iterate exactly the normalized API payload order.""" + idx = PANELS_JS.find("_auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[])") + assert idx >= 0 + assert "for(const task of _auxTasks)" in PANELS_JS[idx:idx + 380] + def test_main_advanced_modal_hides_unsupported_timing_fields_but_keeps_request_body(self): """Main-model modal should not advertise timing knobs that the chat agent cannot apply.""" open_idx = PANELS_JS.find("function _openAuxAdvancedOptions") @@ -302,8 +405,6 @@ class TestAuxiliaryModelsI18n: "settings_aux_task_compression_desc", "settings_aux_task_web_extract", "settings_aux_task_web_extract_desc", - "settings_aux_task_session_search", - "settings_aux_task_session_search_desc", "settings_aux_task_approval", "settings_aux_task_approval_desc", "settings_aux_task_mcp", @@ -330,13 +431,18 @@ class TestAuxiliaryModelsI18n: ) def test_all_locales_have_auxiliary_keys(self): - """Count of each key should equal the number of locales (12 with Turkish).""" + """Count of each key should equal the number of supported locales.""" for key in self.REQUIRED_KEYS: count = I18N_JS.count(f"{key}:") assert count == 15, ( - f"i18n key '{key}' found {count} times — expected 14 (one per locale)" + f"i18n key '{key}' found {count} times — expected 15 (one per locale)" ) + def test_session_search_aux_task_i18n_keys_removed(self): + """session_search auxiliary labels were retired from the canonical set.""" + assert "settings_aux_task_session_search" not in I18N_JS + assert "settings_aux_task_session_search_desc" not in I18N_JS + class TestAuxiliaryModelsBackend: """WebUI backend must expose /api/model/auxiliary and /api/model/set.""" @@ -447,6 +553,96 @@ class TestAuxiliaryModelsBackend: assert vision["api_key_set"] is True assert "api_key" not in vision + def test_get_auxiliary_models_omits_session_search_and_includes_metadata(self, monkeypatch): + """Backend payload should omit unknown keys and keep canonical metadata.""" + from api import config + + monkeypatch.setattr(config, "reload_config", lambda: None) + monkeypatch.setattr(config, "cfg", { + "model": {"provider": "openai", "default": "gpt-5.5"}, + "auxiliary": { + "vision": {"provider": "openai", "model": "gpt-5.5"}, + "future_task": { + "provider": "openai", + "model": "gpt-5.5", + "label": "Future Task", + "description": "future tool", + }, + "monitor": {"provider": "openai", "model": "gpt-5.5"}, + "tts_audio_tags": {"provider": "openai", "model": "gpt-5.5"}, + }, + }) + + data = config.get_auxiliary_models() + task_keys = [t["task"] for t in data["tasks"]] + assert task_keys == [ + "vision", + "web_extract", + "compression", + "approval", + "mcp", + "title_generation", + "skills_hub", + "curator", + "kanban_decomposer", + "profile_describer", + "triage_specifier", + ] + assert "session_search" not in task_keys + assert "future_task" not in task_keys + assert "monitor" not in task_keys + assert "tts_audio_tags" not in task_keys + vision = next(t for t in data["tasks"] if t["task"] == "vision") + assert vision["label"] == "Vision" + assert vision["description"] == "image/screenshot analysis" + + def test_set_auxiliary_model_rejects_existing_unknown_task(self, monkeypatch, tmp_path): + """Existing unknown keys must be rejected by the auxiliary setter.""" + from api import config + + config_path = tmp_path / "config.yaml" + config_path.write_text( + "auxiliary:\n future_task:\n provider: openai\n model: gpt-5.5\n", + encoding="utf-8", + ) + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) + monkeypatch.setattr(config, "reload_config", lambda: None) + + with pytest.raises(ValueError, match="Unknown auxiliary task slot") as excinfo: + config.set_auxiliary_model("future_task", "openai", "gpt-5.6") + assert "future_task" in str(excinfo.value) + + def test_reset_removes_retired_slot_but_preserves_other_unknown_mappings( + self, monkeypatch, tmp_path + ): + """Reset cleans known retired slots without treating config as task schema.""" + from api import config + + config_path = tmp_path / "config.yaml" + config_path.write_text( + "auxiliary:\n" + " vision:\n" + " provider: openai\n" + " model: gpt-5.5\n" + " session_search:\n" + " provider: openai\n" + " model: gpt-5.5\n" + " monitor:\n" + " provider: openai\n" + " model: gpt-5.5\n", + encoding="utf-8", + ) + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) + monkeypatch.setattr(config, "reload_config", lambda: None) + + config.set_auxiliary_model("__reset__", "auto", "") + + saved = config._load_yaml_config_file(config_path)["auxiliary"] + assert "session_search" not in saved + assert saved["monitor"] == {"provider": "openai", "model": "gpt-5.5"} + assert saved["vision"]["provider"] == "auto" + assert saved["vision"]["model"] == "" + def test_backend_surfaces_main_advanced_fields_without_api_key_value(self, monkeypatch): """Main model advanced fields should be visible, but API keys remain write-only.""" from api import config