Release XY (v0.51.669): persist OpenAI priority service_tier (#4543, fixes #4536)

Release XY (v0.51.669): persist OpenAI priority service_tier in main-model settings (#4543, fixes #4536)
This commit is contained in:
nesquena-hermes 2026-06-25 19:12:18 -07:00 committed by GitHub
commit 76eb548007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 375 additions and 5 deletions

View file

@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.669] — 2026-06-26 — Release XY (set OpenAI priority service tier from the model picker)
### Added
- **The main-model advanced settings can now set the OpenAI priority service tier (`service_tier`), matching the CLI/gateway fast-mode path.** Previously the WebUI model picker had no way to opt a conversation into OpenAI's priority tier even though the agent's `/fast` path supported it. The Settings → main-model advanced panel now exposes a Service tier control (Default/off · Priority) for eligible OpenAI-family models; the choice persists and is forwarded on the request. Codex models are excluded (their transport doesn't accept `service_tier`, so the selector is hidden and the value is never forwarded/persisted for them), and non-OpenAI providers never receive it. Thanks @rodboev. (#4543, fixes #4536)
## [v0.51.668] — 2026-06-26 — Release XX (WebUI stays in sync after the Desktop app continues a session)
### Fixed

View file

@ -3498,14 +3498,85 @@ def _public_advanced_model_options(model_cfg: dict) -> dict:
}
def _main_model_request_overrides(config_data: dict) -> dict:
"""Return supported runtime request overrides for the main chat model."""
def _is_openai_family_provider(provider: str | None) -> bool:
"""Return True when a provider should receive OpenAI-family request overrides."""
if not provider:
return False
resolved = str(_resolve_provider_alias(str(provider).strip().lower()))
return resolved in ("openai", "openai-api", "openai-codex")
def _main_model_supports_service_tier(
model_id: str | None,
provider: str | None,
) -> bool:
"""Return True when the current main-model selection can use OpenAI service tier."""
if not _is_openai_family_provider(provider):
return False
resolved = str(provider or "").strip().lower()
if resolved == "openai-codex":
return False
raw_model = str(model_id or "").strip().lower()
if not raw_model:
return True
if "/" in raw_model:
prefix, bare_model = raw_model.split("/", 1)
if prefix != "openai":
return False
else:
bare_model = raw_model
if "codex" in bare_model:
return False
return (
_is_first_party_model("openai", bare_model)
or bare_model.startswith(("gpt-", "o1", "o3", "o4"))
)
def _public_main_service_tier(model_cfg: dict) -> str:
"""Return the saved main-model service tier only for OpenAI-family providers."""
if not isinstance(model_cfg, dict):
return ""
model_id = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
provider = str(model_cfg.get("provider") or "").strip().lower()
if not provider:
_, provider, _ = resolve_model_provider(model_id)
if not _main_model_supports_service_tier(model_id, provider):
return ""
service_tier = str(model_cfg.get("service_tier") or "").strip().lower()
return "priority" if service_tier == "priority" else ""
def _main_model_request_overrides(
config_data: dict,
effective_model: str | None = None,
effective_provider: str | None = None,
) -> dict:
"""Return supported runtime request overrides for the main chat model.
When *effective_model* / *effective_provider* are supplied, the
service-tier gate checks those instead of the saved default model,
so a per-session model switch to a non-OpenAI provider does not
leak ``service_tier`` onto an unsupported request.
"""
if not isinstance(config_data, dict):
return {}
model_cfg = config_data.get("model", {})
if not isinstance(model_cfg, dict):
return {}
overrides = {}
gate_model = effective_model
gate_provider = effective_provider
if not gate_model:
gate_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
if not gate_provider:
gate_provider = str(model_cfg.get("provider") or "").strip().lower()
if not gate_provider:
_, gate_provider, _ = resolve_model_provider(gate_model)
if _main_model_supports_service_tier(gate_model, gate_provider):
service_tier = str(model_cfg.get("service_tier") or "").strip().lower()
if service_tier == "priority":
overrides["service_tier"] = "priority"
extra_body = model_cfg.get("extra_body")
if isinstance(extra_body, dict) and extra_body:
overrides["extra_body"] = copy.deepcopy(extra_body)
@ -3548,6 +3619,14 @@ def _apply_advanced_model_options(model_cfg: dict, advanced: dict | None) -> Non
model_cfg.pop("extra_body", None)
else:
raise ValueError("extra_body must be a JSON object")
if "service_tier" in advanced:
service_tier = str(advanced.get("service_tier") or "").strip().lower()
if not service_tier or service_tier == "default":
model_cfg.pop("service_tier", None)
elif service_tier == "priority":
model_cfg["service_tier"] = "priority"
else:
raise ValueError("service_tier must be one of: default, priority")
if advanced.get("api_key_clear"):
model_cfg.pop("api_key", None)
api_key = str(advanced.get("api_key") or "").strip()
@ -3614,6 +3693,8 @@ def set_hermes_default_model(model_id: str, provider: str | None = None, advance
model_cfg.pop("base_url", None)
_apply_advanced_model_options(model_cfg, advanced)
if not _main_model_supports_service_tier(persisted_model, persisted_provider):
model_cfg.pop("service_tier", None)
config_data["model"] = model_cfg
_save_yaml_config_file(config_path, config_data)
@ -3656,7 +3737,7 @@ def get_auxiliary_models() -> dict:
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
...
],
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7", "service_tier": ""},
}
"""
reload_config()
@ -3692,6 +3773,7 @@ def get_auxiliary_models() -> dict:
"main": {
"provider": main_provider,
"model": main_model,
"service_tier": _public_main_service_tier(model_cfg),
**_public_advanced_model_options(model_cfg),
},
}

View file

@ -591,6 +591,15 @@ def _run_gateway_chat_streaming(
prefill_messages = []
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
try:
from api.config import _main_model_request_overrides
_gw_overrides = _main_model_request_overrides(
cfg,
effective_model=model,
effective_provider=model_provider,
)
except Exception:
_gw_overrides = {}
# Capability gate: use runs API when gateway advertises approval support.
_use_runs_api = _gateway_use_runs_api_enabled(cfg) and gateway_supports_approval(base_url, api_key)
if _use_runs_api:
@ -599,6 +608,8 @@ def _run_gateway_chat_streaming(
body_extras["provider"] = model_provider
if reasoning_effort is not None:
body_extras["reasoning_effort"] = reasoning_effort
if _gw_overrides.get("service_tier"):
body_extras["service_tier"] = _gw_overrides["service_tier"]
try:
final_text, usage = _run_gateway_runs_api_streaming(
session_id, msg_text, model, workspace, stream_id,
@ -661,6 +672,8 @@ def _run_gateway_chat_streaming(
body["provider"] = model_provider
if reasoning_effort is not None:
body["reasoning_effort"] = reasoning_effort
if _gw_overrides.get("service_tier"):
body["service_tier"] = _gw_overrides["service_tier"]
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),

View file

@ -7026,7 +7026,11 @@ def _run_agent_streaming(
_prefill_context = _load_webui_prefill_context(_cfg)
_prefill_messages = _prefill_messages_with_webui_context(_prefill_context, _cfg)
_prefill_messages = _normalize_prefill_messages_before_user_turn(_prefill_messages)
_main_request_overrides = _main_model_request_overrides(_cfg)
_main_request_overrides = _main_model_request_overrides(
_cfg,
effective_model=resolved_model,
effective_provider=resolved_provider,
)
put('context_status', {
'session_id': session_id,
'prefill': _public_prefill_context_status(_prefill_context),

View file

@ -715,6 +715,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Auxiliary Models',
settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.',
settings_btn_reset_aux_models: 'Reset all to auto',
@ -2332,6 +2336,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Modelli Ausiliari',
settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.',
settings_btn_reset_aux_models: 'Ripristina tutto ad auto',
@ -3940,6 +3948,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'デフォルトのチャットモデルに関する詳細設定です。',
settings_main_advanced_saved: 'メインモデルのオプションを保存しました',
settings_main_advanced_save_failed: 'メインモデルのオプションの保存に失敗しました',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: '補助モデル',
settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。',
settings_btn_reset_aux_models: 'すべて自動にリセット',
@ -5283,6 +5295,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Вспомогательные модели',
settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.',
settings_btn_reset_aux_models: 'Сбросить всё на авто',
@ -6808,6 +6824,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Modelos auxiliares',
settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.',
settings_btn_reset_aux_models: 'Restablecer todo a auto',
@ -8331,6 +8351,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Hilfsmodelle',
settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.',
settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen',
@ -9919,6 +9943,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: '辅助模型',
settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。',
settings_btn_reset_aux_models: '全部重置为自动',
@ -11729,6 +11757,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: '輔助模型',
settings_desc_auxiliary_models: '視覺分析、脈絡壓縮、標題產生等輔助任務的路由。「自動」表示使用主要聊天模型。',
settings_btn_reset_aux_models: '全部重置為自動',
@ -13233,6 +13265,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Modelos auxiliares',
settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.',
settings_btn_reset_aux_models: 'Restaurar tudo para auto',
@ -14743,6 +14779,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: '보조 모델',
settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.',
settings_btn_reset_aux_models: '모두 자동으로 재설정',
@ -16275,6 +16315,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_auxiliary_models: 'Modèles auxiliaires',
settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.',
settings_btn_reset_aux_models: 'Tout réinitialiser à auto',
@ -17964,6 +18008,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_label_send_key: 'Anahtar Gönder',
settings_label_theme: 'Tema',
settings_label_skin: 'Deri',
@ -19563,6 +19611,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_aux_advanced_button_title: 'Advanced options',
settings_aux_advanced_button_aria: 'Advanced options for {task}',
settings_aux_advanced_title: '{task} options',
@ -21956,6 +22008,10 @@ const LOCALES = {
settings_main_advanced_subtitle: 'Cấu hình nâng cao cho model chat mặc định.',
settings_main_advanced_saved: 'Đã lưu tùy chọn model chính',
settings_main_advanced_save_failed: 'Lưu tùy chọn model chính thất bại',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_aux_task_vision: 'Vision',
settings_aux_task_vision_desc: 'phân tích ảnh / ảnh chụp màn hình',
settings_aux_task_compression: 'Compression',

View file

@ -9687,6 +9687,25 @@ function _auxAdvancedInputHtml(id,label,value,desc,type='text',extraAttrs='',ext
return `<label style="display:grid;gap:4px;font-size:12px;color:var(--text)"><span style="font-weight:600">${esc(label)}</span><input ${inputAttrs} style="width:100%;box-sizing:border-box;padding:7px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px${extraStyle}"><span style="font-size:10px;color:var(--muted);line-height:1.35">${esc(desc)}</span></label>`;
}
function _mainModelSupportsServiceTier(cfg){
const selected=$('settingsModel');
const selectedOpt=selected&&selected.selectedIndex>=0?selected.options[selected.selectedIndex]:null;
const optgroup=selectedOpt&&selectedOpt.parentElement&&selectedOpt.parentElement.tagName==='OPTGROUP'?selectedOpt.parentElement:null;
const provider=((selectedOpt&&selectedOpt.dataset&&selectedOpt.dataset.provider)||(optgroup&&optgroup.dataset&&optgroup.dataset.provider)||(cfg&&cfg.provider)||'').trim().toLowerCase();
if(provider!=='openai'&&provider!=='openai-api'&&provider!=='openai-codex') return false;
if(provider==='openai-codex') return false;
const rawModel=String((selectedOpt&&selectedOpt.value)||(selected&&selected.value)||(cfg&&cfg.model)||'').trim().toLowerCase();
if(!rawModel) return true;
let bareModel=rawModel;
if(rawModel.includes('/')){
const slash=rawModel.indexOf('/');
if(rawModel.slice(0,slash)!=='openai') return false;
bareModel=rawModel.slice(slash+1);
}
if(bareModel.includes('codex')) return false;
return bareModel.startsWith('gpt-')||bareModel.startsWith('o1')||bareModel.startsWith('o3')||bareModel.startsWith('o4');
}
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:''});
@ -9699,12 +9718,17 @@ function _openAuxAdvancedOptions(taskKey,cfg){
const extraBody=cfg&&cfg.extra_body&&typeof cfg.extra_body==='object'&&Object.keys(cfg.extra_body).length?JSON.stringify(cfg.extra_body,null,2):'';
const apiKeyHint=cfg&&cfg.api_key_set?(t('settings_aux_advanced_api_key_set_hint')||'API key is set. Leave blank to keep it, or use clear to remove it.'):(t('settings_aux_advanced_api_key_empty_hint')||'Leave blank to use provider/default credentials.');
if(body){
const selectedServiceTier=((cfg&&cfg.service_tier)||'').trim().toLowerCase()==='priority'?'priority':'';
const serviceTierField=isMain&&_mainModelSupportsServiceTier(cfg)
? `<label style="display:grid;gap:4px;font-size:12px;color:var(--text)"><span style="font-weight:600">${esc(t('settings_main_advanced_service_tier')||'Service tier')}</span><select id="auxAdvancedServiceTier" style="width:100%;box-sizing:border-box;padding:7px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px"><option value=""${selectedServiceTier?'':' selected'}>${esc(t('settings_main_advanced_service_tier_default')||'Default / off')}</option><option value="priority"${selectedServiceTier==='priority'?' selected':''}>${esc(t('settings_main_advanced_service_tier_priority')||'Priority (fast)')}</option></select><span style="font-size:10px;color:var(--muted);line-height:1.35">${esc(t('settings_main_advanced_service_tier_desc')||'Optional request setting for OpenAI-family providers.')}</span></label>`
: '';
const timingFields=isMain?'':(
_auxAdvancedInputHtml('auxAdvancedTimeout',t('settings_aux_advanced_timeout')||'Timeout seconds',_auxAdvancedValue(cfg,'timeout'),t('settings_aux_advanced_timeout_desc')||'Request timeout for this auxiliary task. Blank uses Hermes default.','number','inputmode="numeric" min="1" step="1"')+
_auxAdvancedInputHtml('auxAdvancedDownloadTimeout',t('settings_aux_advanced_download_timeout')||'Download timeout seconds',_auxAdvancedValue(cfg,'download_timeout'),t('settings_aux_advanced_download_timeout_desc')||'Only relevant for tasks that download media/content, e.g. vision. Blank uses default.','number','inputmode="numeric" min="1" step="1"')+
_auxAdvancedInputHtml('auxAdvancedMaxConcurrency',t('settings_aux_advanced_max_concurrency')||'Max concurrency',_auxAdvancedValue(cfg,'max_concurrency'),t('settings_aux_advanced_max_concurrency_desc')||'Optional per-task concurrency limit. Blank uses default.','number','inputmode="numeric" min="1" step="1"'));
body.innerHTML=
_auxAdvancedInputHtml('auxAdvancedBaseUrl',t('settings_aux_advanced_base_url')||'Base URL',_auxAdvancedValue(cfg,'base_url'),t('settings_aux_advanced_base_url_desc')||'Optional provider endpoint override.','text','inputmode="url"')+
serviceTierField+
timingFields+
`<label style="display:grid;gap:4px;font-size:12px;color:var(--text)"><span style="font-weight:600">${esc(t('settings_aux_advanced_extra_body')||'Extra body JSON')}</span><textarea id="auxAdvancedExtraBody" rows="6" style="width:100%;box-sizing:border-box;padding:7px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;font-family:var(--mono,monospace)">${esc(extraBody)}</textarea><span style="font-size:10px;color:var(--muted);line-height:1.35">${esc(t('settings_aux_advanced_extra_body_desc')||'Optional JSON object merged into the model request body.')}</span></label>`+
_auxAdvancedInputHtml('auxAdvancedApiKey',t('settings_aux_advanced_api_key')||'API key override','',apiKeyHint,'text','autocomplete="one-time-code" inputmode="text" readonly onfocus="this.removeAttribute(&quot;readonly&quot;)"',';-webkit-text-security:disc')+
@ -9728,6 +9752,9 @@ function _openAuxAdvancedOptions(taskKey,cfg){
api_key:$('auxAdvancedApiKey')?.value||'',
api_key_clear:!!($('auxAdvancedApiKeyClear')&&$('auxAdvancedApiKeyClear').checked),
};
if(isMain&&$('auxAdvancedServiceTier')){
advanced.service_tier=$('auxAdvancedServiceTier')?.value||'';
}
if(!isMain){
advanced.timeout=$('auxAdvancedTimeout')?.value||'';
advanced.download_timeout=$('auxAdvancedDownloadTimeout')?.value||'';

View file

@ -108,11 +108,31 @@ class TestAuxiliaryModelsJS:
"""Main-model modal should not advertise timing knobs that the chat agent cannot apply."""
open_idx = PANELS_JS.find("function _openAuxAdvancedOptions")
assert open_idx >= 0
modal_body = PANELS_JS[open_idx:open_idx + 3200]
modal_body = PANELS_JS[open_idx:open_idx + 4600]
assert "const timingFields=isMain?'':(" in modal_body
assert "auxAdvancedExtraBody" in modal_body
assert "auxAdvancedBaseUrl" in modal_body
def test_main_advanced_modal_exposes_service_tier_selector(self):
"""Main-model advanced modal should expose service-tier control."""
open_idx = PANELS_JS.find("function _openAuxAdvancedOptions")
assert open_idx >= 0
modal_body = PANELS_JS[open_idx:open_idx + 3600]
helper_idx = PANELS_JS.find("function _mainModelSupportsServiceTier")
assert helper_idx >= 0
helper_body = PANELS_JS[helper_idx:helper_idx + 1300]
assert "_mainModelSupportsServiceTier" in PANELS_JS
assert "rawModel.includes('/')" in helper_body
assert "rawModel.slice(0,slash)!=='openai'" in helper_body
assert "bareModel.includes('codex')" in helper_body
assert "bareModel.startsWith('gpt-')" in helper_body
assert "bareModel.startsWith('o4')" in helper_body
assert "auxAdvancedServiceTier" in modal_body
assert "isMain&&_mainModelSupportsServiceTier(cfg)" in modal_body
assert "settings_main_advanced_service_tier" in modal_body
assert "settings_main_advanced_service_tier_default" in modal_body
assert "settings_main_advanced_service_tier_priority" in modal_body
def test_main_advanced_save_omits_unsupported_timing_keys(self):
"""Saving main-model options must not send blank timing keys that backend treats as clears."""
save_idx = PANELS_JS.find("const advanced={")
@ -269,6 +289,10 @@ class TestAuxiliaryModelsI18n:
"settings_main_advanced_subtitle",
"settings_main_advanced_saved",
"settings_main_advanced_save_failed",
"settings_main_advanced_service_tier",
"settings_main_advanced_service_tier_desc",
"settings_main_advanced_service_tier_default",
"settings_main_advanced_service_tier_priority",
"settings_aux_task_vision",
"settings_aux_task_vision_desc",
"settings_aux_task_compression",

View file

@ -0,0 +1,158 @@
"""Tests for issue #4536 — main-model service_tier persistence and guarded forwarding."""
class TestIssue4536ServiceTier:
def test_main_service_tier_roundtrip_via_auxiliary_endpoint(self, monkeypatch, tmp_path):
"""service_tier set on main model should persist in config and return via /api/model/auxiliary payload."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai\n default: gpt-5.5\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "invalidate_models_cache", lambda: None)
result = config.set_hermes_default_model(
"gpt-5.5",
advanced={"service_tier": "priority"},
)
assert result["ok"] is True
assert "service_tier: priority" in config_path.read_text(encoding="utf-8")
payload = config.get_auxiliary_models()
assert payload["main"]["service_tier"] == "priority"
def test_main_service_tier_default_clears_persisted_value(self, monkeypatch, tmp_path):
"""Choosing Default/off should clear service_tier from persisted main-model options."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai\n default: gpt-5.5\n service_tier: priority\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "invalidate_models_cache", lambda: None)
result = config.set_hermes_default_model(
"gpt-5.5",
advanced={"service_tier": "default"},
)
assert result["ok"] is True
payload = config.get_auxiliary_models()
assert payload["main"]["service_tier"] == ""
assert "service_tier" not in config_path.read_text(encoding="utf-8")
def test_main_request_overrides_only_for_openai_family(self, monkeypatch):
"""service_tier forwarding should only happen for OpenAI-family providers."""
from api import config
openai_payload = config._main_model_request_overrides(
{"model": {"provider": "openai", "default": "gpt-5.5", "service_tier": "priority"}},
)
assert openai_payload == {"service_tier": "priority"}
openrouter_payload = config._main_model_request_overrides(
{"model": {"provider": "openrouter", "default": "meta-llama/llama-3.1", "service_tier": "priority"}},
)
assert openrouter_payload == {}
def resolve_alias(model: str, *_args):
return model, "openai", ""
monkeypatch.setattr(config, "resolve_model_provider", resolve_alias)
openai_alias_payload = config._main_model_request_overrides(
{"model": {"default": "gpt-5.5", "service_tier": "priority"}},
)
assert openai_alias_payload == {"service_tier": "priority"}
codex_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-5.3-codex", "service_tier": "priority"}},
)
assert codex_payload == {}
codex_empty_model_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "service_tier": "priority"}},
)
assert codex_empty_model_payload == {}
stale_openai_payload = config._main_model_request_overrides(
{"model": {"provider": "openai", "default": "meta-llama/llama-3.1", "service_tier": "priority"}},
)
assert stale_openai_payload == {}
def test_auxiliary_payload_hides_service_tier_for_non_openai_main_models(self, monkeypatch, tmp_path):
"""Saved service_tier should not be re-exposed once the main model switches away from OpenAI."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openrouter\n default: meta-llama/llama-3.1\n service_tier: priority\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
payload = config.get_auxiliary_models()
assert payload["main"]["service_tier"] == ""
def test_effective_model_gates_service_tier_forwarding(self):
"""service_tier must be gated on the effective send model, not the saved default."""
from api import config
cfg_priority = {"model": {"provider": "openai", "default": "gpt-5.5", "service_tier": "priority"}}
openai_effective = config._main_model_request_overrides(
cfg_priority, effective_model="gpt-5.5", effective_provider="openai",
)
assert openai_effective.get("service_tier") == "priority"
openrouter_effective = config._main_model_request_overrides(
cfg_priority, effective_model="meta-llama/llama-3.1", effective_provider="openrouter",
)
assert openrouter_effective == {}
custom_effective = config._main_model_request_overrides(
cfg_priority, effective_model="my-local-model", effective_provider="custom:local",
)
assert custom_effective == {}
codex_effective = config._main_model_request_overrides(
cfg_priority, effective_model="gpt-5.3-codex", effective_provider="openai-codex",
)
assert codex_effective == {}
def test_no_effective_params_falls_back_to_saved_default(self):
"""Without effective_model/provider, the function still reads the saved config."""
from api import config
result = config._main_model_request_overrides(
{"model": {"provider": "openai", "default": "gpt-5.5", "service_tier": "priority"}},
)
assert result == {"service_tier": "priority"}
def test_switching_main_model_away_from_openai_clears_service_tier(self, monkeypatch, tmp_path):
"""A non-OpenAI default-model save should remove stale OpenAI service-tier state."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai\n default: gpt-5.5\n service_tier: priority\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "invalidate_models_cache", lambda: None)
monkeypatch.setattr(
config,
"cfg",
{"model": {"provider": "openai", "default": "gpt-5.5"}},
)
result = config.set_hermes_default_model("meta-llama/llama-3.1")
assert result["ok"] is True
text = config_path.read_text(encoding="utf-8")
assert "service_tier" not in text