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 <a@b> Co-authored-by: starship-s <starship-s@users.noreply.github.com>
This commit is contained in:
5 files changed
+365
-145
No files matched your search
@@ -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)
|
||||
|
||||
+63
-40
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
+88
-59
@@ -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='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_loading')||'Loading…')+'</div>';
|
||||
|
||||
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)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(t(slot.descKey)||'')+'</div>';
|
||||
label.innerHTML=esc(task.label||task.task)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(task.description||'')+'</div>';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user