From 24cf7069a6c4d5ae9272e44739bf3c9515dcbb0a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:22:56 -0400 Subject: [PATCH 1/9] fix(#4536): persist OpenAI priority tier in main model settings --- api/config.py | 39 +++++++++++- static/i18n.js | 52 +++++++++++++++ static/panels.js | 16 +++++ tests/test_auxiliary_models_settings.py | 18 +++++- tests/test_issue4536_service_tier.py | 85 +++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue4536_service_tier.py diff --git a/api/config.py b/api/config.py index 2b48872b..3089f30e 100644 --- a/api/config.py +++ b/api/config.py @@ -3498,6 +3498,27 @@ def _public_advanced_model_options(model_cfg: dict) -> dict: } +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 _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 "" + provider = str(model_cfg.get("provider") or "").strip().lower() + if not provider: + _, provider, _ = resolve_model_provider(str(model_cfg.get("default") or "")) + if not _is_openai_family_provider(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) -> dict: """Return supported runtime request overrides for the main chat model.""" if not isinstance(config_data, dict): @@ -3506,6 +3527,13 @@ def _main_model_request_overrides(config_data: dict) -> dict: if not isinstance(model_cfg, dict): return {} overrides = {} + resolved_provider = str(model_cfg.get("provider") or "").strip().lower() + if not resolved_provider: + _, resolved_provider, _ = resolve_model_provider(str(model_cfg.get("default") or "")) + if _is_openai_family_provider(resolved_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 +3576,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() @@ -3656,7 +3692,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 +3728,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), }, } diff --git a/static/i18n.js b/static/i18n.js index 18ae9103..b1900dce 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/panels.js b/static/panels.js index cb42c709..8bbed330 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9687,6 +9687,14 @@ function _auxAdvancedInputHtml(id,label,value,desc,type='text',extraAttrs='',ext return ``; } +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=((optgroup&&optgroup.dataset&&optgroup.dataset.provider)||(cfg&&cfg.provider)||'').trim().toLowerCase(); + return provider==='openai'||provider==='openai-api'||provider==='openai-codex'; +} + 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 +9707,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) + ? `` + : ''; 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+ ``+ _auxAdvancedInputHtml('auxAdvancedApiKey',t('settings_aux_advanced_api_key')||'API key override','',apiKeyHint,'text','autocomplete="one-time-code" inputmode="text" readonly onfocus="this.removeAttribute("readonly")"',';-webkit-text-security:disc')+ @@ -9728,6 +9741,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||''; diff --git a/tests/test_auxiliary_models_settings.py b/tests/test_auxiliary_models_settings.py index e113f7e2..bc46660c 100644 --- a/tests/test_auxiliary_models_settings.py +++ b/tests/test_auxiliary_models_settings.py @@ -108,11 +108,23 @@ 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] + assert "_mainModelSupportsServiceTier" in PANELS_JS + 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 +281,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", diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py new file mode 100644 index 00000000..54a904d5 --- /dev/null +++ b/tests/test_issue4536_service_tier.py @@ -0,0 +1,85 @@ +"""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"} + + 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"] == "" From a340c69b08d1c82f1e94212dd6df9195f2775153 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:36:53 -0400 Subject: [PATCH 2/9] fix(#4536): clear stale service tier on model switch --- api/config.py | 2 ++ tests/test_issue4536_service_tier.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/api/config.py b/api/config.py index 3089f30e..dadef1b6 100644 --- a/api/config.py +++ b/api/config.py @@ -3650,6 +3650,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 _is_openai_family_provider(persisted_provider): + model_cfg.pop("service_tier", None) config_data["model"] = model_cfg _save_yaml_config_file(config_path, config_data) diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py index 54a904d5..2f4dc02b 100644 --- a/tests/test_issue4536_service_tier.py +++ b/tests/test_issue4536_service_tier.py @@ -83,3 +83,26 @@ class TestIssue4536ServiceTier: payload = config.get_auxiliary_models() assert payload["main"]["service_tier"] == "" + + 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, + "resolve_model_provider", + lambda model: (model, "openrouter", None), + ) + + 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 From 6f9d23eb48aff32e1e1375147aa3cb026f0871d0 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:46:05 -0400 Subject: [PATCH 3/9] fix(#4536): gate service tier to real OpenAI model ids --- api/config.py | 35 ++++++++++++++++++++++++---- tests/test_issue4536_service_tier.py | 9 +++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/api/config.py b/api/config.py index dadef1b6..1b879bd7 100644 --- a/api/config.py +++ b/api/config.py @@ -3506,14 +3506,38 @@ def _is_openai_family_provider(provider: str | None) -> bool: 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 + 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 + return ( + _is_first_party_model("openai", bare_model) + or _is_first_party_model("openai-codex", 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(str(model_cfg.get("default") or "")) - if not _is_openai_family_provider(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 "" @@ -3527,10 +3551,11 @@ def _main_model_request_overrides(config_data: dict) -> dict: if not isinstance(model_cfg, dict): return {} overrides = {} + model_id = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() resolved_provider = str(model_cfg.get("provider") or "").strip().lower() if not resolved_provider: - _, resolved_provider, _ = resolve_model_provider(str(model_cfg.get("default") or "")) - if _is_openai_family_provider(resolved_provider): + _, resolved_provider, _ = resolve_model_provider(model_id) + if _main_model_supports_service_tier(model_id, resolved_provider): service_tier = str(model_cfg.get("service_tier") or "").strip().lower() if service_tier == "priority": overrides["service_tier"] = "priority" @@ -3650,7 +3675,7 @@ 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 _is_openai_family_provider(persisted_provider): + if not _main_model_supports_service_tier(persisted_model, persisted_provider): model_cfg.pop("service_tier", None) config_data["model"] = model_cfg diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py index 2f4dc02b..a0d588b5 100644 --- a/tests/test_issue4536_service_tier.py +++ b/tests/test_issue4536_service_tier.py @@ -69,6 +69,11 @@ class TestIssue4536ServiceTier: ) assert openai_alias_payload == {"service_tier": "priority"} + 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 @@ -97,8 +102,8 @@ class TestIssue4536ServiceTier: monkeypatch.setattr(config, "invalidate_models_cache", lambda: None) monkeypatch.setattr( config, - "resolve_model_provider", - lambda model: (model, "openrouter", None), + "cfg", + {"model": {"provider": "openai", "default": "gpt-5.5"}}, ) result = config.set_hermes_default_model("meta-llama/llama-3.1") From cae551f4a6bf713373c7eb4dd2504bd7ca6182e7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 03:04:06 -0400 Subject: [PATCH 4/9] fix(#4536): match service-tier UI gate to backend --- static/panels.js | 13 +++++++++++-- tests/test_auxiliary_models_settings.py | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 8bbed330..309d33dd 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9691,8 +9691,17 @@ 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=((optgroup&&optgroup.dataset&&optgroup.dataset.provider)||(cfg&&cfg.provider)||'').trim().toLowerCase(); - return provider==='openai'||provider==='openai-api'||provider==='openai-codex'; + 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; + 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); + } + return bareModel.startsWith('gpt-')||bareModel.startsWith('o1')||bareModel.startsWith('o3')||bareModel.startsWith('o4'); } function _openAuxAdvancedOptions(taskKey,cfg){ diff --git a/tests/test_auxiliary_models_settings.py b/tests/test_auxiliary_models_settings.py index bc46660c..308a5fb7 100644 --- a/tests/test_auxiliary_models_settings.py +++ b/tests/test_auxiliary_models_settings.py @@ -118,7 +118,14 @@ class TestAuxiliaryModelsJS: 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.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 From 4d64c18832617c967f887db07670885d20e4dd24 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 03:39:17 -0400 Subject: [PATCH 5/9] fix(#4536): block Codex models from priority service tier --- api/config.py | 3 ++- static/panels.js | 1 + tests/test_auxiliary_models_settings.py | 1 + tests/test_issue4536_service_tier.py | 5 +++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index 1b879bd7..49c8ec9e 100644 --- a/api/config.py +++ b/api/config.py @@ -3522,9 +3522,10 @@ def _main_model_supports_service_tier( return False else: bare_model = raw_model + if "codex" in bare_model: + return False return ( _is_first_party_model("openai", bare_model) - or _is_first_party_model("openai-codex", bare_model) or bare_model.startswith(("gpt-", "o1", "o3", "o4")) ) diff --git a/static/panels.js b/static/panels.js index 309d33dd..6b63abf4 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9701,6 +9701,7 @@ function _mainModelSupportsServiceTier(cfg){ 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'); } diff --git a/tests/test_auxiliary_models_settings.py b/tests/test_auxiliary_models_settings.py index 308a5fb7..2af243de 100644 --- a/tests/test_auxiliary_models_settings.py +++ b/tests/test_auxiliary_models_settings.py @@ -124,6 +124,7 @@ class TestAuxiliaryModelsJS: 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 diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py index a0d588b5..3ebaad93 100644 --- a/tests/test_issue4536_service_tier.py +++ b/tests/test_issue4536_service_tier.py @@ -69,6 +69,11 @@ class TestIssue4536ServiceTier: ) 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 == {} + stale_openai_payload = config._main_model_request_overrides( {"model": {"provider": "openai", "default": "meta-llama/llama-3.1", "service_tier": "priority"}}, ) From 2f2d36d4eb4434c8ed34750e12f2c330bfa5969c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 23:41:31 -0400 Subject: [PATCH 6/9] fix(#4536): gate service tier on effective send model, not saved default --- api/config.py | 28 ++++++++++++++++------ api/gateway_chat.py | 13 +++++++++++ api/streaming.py | 6 ++++- tests/test_issue4536_service_tier.py | 35 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/api/config.py b/api/config.py index 49c8ec9e..7ca3f395 100644 --- a/api/config.py +++ b/api/config.py @@ -3544,19 +3544,33 @@ def _public_main_service_tier(model_cfg: dict) -> str: return "priority" if service_tier == "priority" else "" -def _main_model_request_overrides(config_data: dict) -> dict: - """Return supported runtime request overrides for the main chat model.""" +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 = {} - model_id = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() - resolved_provider = str(model_cfg.get("provider") or "").strip().lower() - if not resolved_provider: - _, resolved_provider, _ = resolve_model_provider(model_id) - if _main_model_supports_service_tier(model_id, resolved_provider): + 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" diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 3cc42166..f5d21da1 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -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"), diff --git a/api/streaming.py b/api/streaming.py index af28636a..69a90410 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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), diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py index 3ebaad93..e0de22f1 100644 --- a/tests/test_issue4536_service_tier.py +++ b/tests/test_issue4536_service_tier.py @@ -94,6 +94,41 @@ class TestIssue4536ServiceTier: 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 From 61ea7360a0e29d6137fbf062c2e10c3a795ece94 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 23:56:27 -0400 Subject: [PATCH 7/9] fix(#4536): reject openai-codex provider before empty-model early return --- api/config.py | 3 +++ static/panels.js | 1 + tests/test_issue4536_service_tier.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/api/config.py b/api/config.py index 7ca3f395..c9732669 100644 --- a/api/config.py +++ b/api/config.py @@ -3513,6 +3513,9 @@ def _main_model_supports_service_tier( """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 diff --git a/static/panels.js b/static/panels.js index 6b63abf4..7bfdd568 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9693,6 +9693,7 @@ function _mainModelSupportsServiceTier(cfg){ 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; diff --git a/tests/test_issue4536_service_tier.py b/tests/test_issue4536_service_tier.py index e0de22f1..97b63023 100644 --- a/tests/test_issue4536_service_tier.py +++ b/tests/test_issue4536_service_tier.py @@ -74,6 +74,11 @@ class TestIssue4536ServiceTier: ) 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"}}, ) From b2510efcb9b5bb85295503c23976b5ffbd0f89ba Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 26 Jun 2026 01:51:59 +0000 Subject: [PATCH 8/9] i18n(#4543): add service_tier keys to the vi locale (added to master after this PR; strict locale parity) --- static/i18n.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index b1900dce..dee931a4 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -22008,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', From b22bb85a6a80c19fa7efb1bcbea42c08b4de3622 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 26 Jun 2026 02:09:20 +0000 Subject: [PATCH 9/9] Release XY (v0.51.669): persist OpenAI priority service_tier in main-model settings (#4543, fixes #4536) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425a3476..ac34f52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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