fix: derive service tier support from agent fast-mode metadata

This commit is contained in:
Hermes Agent authored and t committed 2026-07-09 03:16:53 +00:00
1 parent 4111e097c8
commit 69d0fc73c9
10 files changed
+250 -54

No files matched your search

+128 -27
View File
@@ -2226,10 +2226,12 @@ def _apply_provider_prefix(
result = [] result = []
for m in raw_models: for m in raw_models:
mid = m["id"] mid = m["id"]
entry = dict(m)
if mid.startswith("@") or "/" in mid: if mid.startswith("@") or "/" in mid:
result.append({"id": mid, "label": m["label"]}) result.append(entry)
else: else:
result.append({"id": f"@{provider_id}:{mid}", "label": m["label"]}) entry["id"] = f"@{provider_id}:{mid}"
result.append(entry)
return result return result
@@ -3853,6 +3855,74 @@ def _is_openai_family_provider(provider: str | None) -> bool:
return resolved in ("openai", "openai-api", "openai-codex") return resolved in ("openai", "openai-api", "openai-codex")
def _normalize_openai_family_model_id(model_id: str | None) -> str:
"""Return a model id in the form expected by hermes_cli fast-mode resolution."""
model = str(model_id or "").strip()
if not model:
return ""
if model.startswith("@") and ":" in model:
model = model.split(":", 1)[1].strip()
if "://" in model:
return model
if "/" in model:
provider_hint, candidate = model.split("/", 1)
if provider_hint.strip().lower() in {"openai", "openai-api", "openai-codex"}:
model = candidate.strip()
else:
return ""
return model
def _legacy_openai_service_tier_overrides(model_id: str | None, provider: str | None) -> dict:
"""Compatibility fallback for standalone WebUI installs without hermes_cli.
Normal operation delegates to Hermes Agent model metadata. This fallback
preserves the old WebUI behavior when the agent package is unavailable,
while still failing closed for codex model slugs and foreign provider IDs.
"""
if not _is_openai_family_provider(provider):
return {}
resolved_provider = str(_resolve_provider_alias(str(provider or "").strip().lower()))
raw_model = str(model_id or "").strip()
if "://" not in raw_model and "/" in raw_model:
provider_hint = raw_model.split("/", 1)[0].strip().lower()
if provider_hint not in {"openai", "openai-api", "openai-codex"}:
return {}
normalized_model = _normalize_openai_family_model_id(model_id)
if not normalized_model:
if resolved_provider == "openai-codex":
return {}
return {"service_tier": "priority"}
lowered = normalized_model.lower()
if "codex" in lowered:
return {}
if lowered.startswith(("gpt-", "o1", "o3", "o4")):
return {"service_tier": "priority"}
return {}
def _resolve_main_model_fast_mode_overrides(model_id: str | None, provider: str | None = None) -> dict:
"""Return provider request overrides for the main model fast-mode setting."""
normalized_model = _normalize_openai_family_model_id(model_id)
if not normalized_model:
return _legacy_openai_service_tier_overrides(model_id, provider)
try:
from hermes_cli.models import resolve_fast_mode_overrides
except Exception:
logger.debug("Failed to import hermes_cli.models.resolve_fast_mode_overrides; using WebUI compatibility fallback.")
return _legacy_openai_service_tier_overrides(model_id, provider)
try:
resolved = resolve_fast_mode_overrides(normalized_model)
except Exception:
logger.debug("Failed to resolve fast-mode overrides for %r; using WebUI compatibility fallback.", normalized_model)
return _legacy_openai_service_tier_overrides(model_id, provider)
return resolved if isinstance(resolved, dict) else {}
def _main_model_supports_service_tier( def _main_model_supports_service_tier(
model_id: str | None, model_id: str | None,
provider: str | None, provider: str | None,
@@ -3860,26 +3930,43 @@ def _main_model_supports_service_tier(
"""Return True when the current main-model selection can use OpenAI service tier.""" """Return True when the current main-model selection can use OpenAI service tier."""
if not _is_openai_family_provider(provider): if not _is_openai_family_provider(provider):
return False 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 ( return (
_is_first_party_model("openai", bare_model) str(_resolve_main_model_fast_mode_overrides(model_id, provider).get("service_tier", "")).strip().lower()
or bare_model.startswith(("gpt-", "o1", "o3", "o4")) == "priority"
) )
def _model_supports_fast_tier_for_provider(model_id: str | None, provider: str | None) -> bool:
"""Return whether a provider/model entry supports WebUI's service-tier toggle."""
return _main_model_supports_service_tier(model_id, provider)
def _annotate_fast_tier_model_groups(payload: dict | None) -> dict | None:
"""Add service-tier capability metadata to OpenAI-family model groups."""
if not isinstance(payload, dict):
return payload
groups = payload.get("groups")
if not isinstance(groups, list):
return payload
for group in groups:
if not isinstance(group, dict):
continue
provider_id = str(group.get("provider_id") or "").strip()
if not _is_openai_family_provider(provider_id):
continue
for bucket in ("models", "extra_models"):
models = group.get(bucket)
if not isinstance(models, list):
continue
for model in models:
if not isinstance(model, dict):
continue
model_id = str(model.get("id") or "").strip()
if model_id:
model["supports_fast_tier"] = _model_supports_fast_tier_for_provider(model_id, provider_id)
return payload
def _public_main_service_tier(model_cfg: dict) -> str: def _public_main_service_tier(model_cfg: dict) -> str:
"""Return the saved main-model service tier only for OpenAI-family providers.""" """Return the saved main-model service tier only for OpenAI-family providers."""
if not isinstance(model_cfg, dict): if not isinstance(model_cfg, dict):
@@ -4120,6 +4207,7 @@ def get_auxiliary_models() -> dict:
"main": { "main": {
"provider": main_provider, "provider": main_provider,
"model": main_model, "model": main_model,
"supports_fast_tier": _main_model_supports_service_tier(main_model, main_provider),
"service_tier": _public_main_service_tier(model_cfg), "service_tier": _public_main_service_tier(model_cfg),
**_public_advanced_model_options(model_cfg), **_public_advanced_model_options(model_cfg),
}, },
@@ -4447,13 +4535,13 @@ def _minimal_static_models_catalog() -> dict:
"models": [{"id": default_model, "label": label}], "models": [{"id": default_model, "label": label}],
} }
) )
return { return _annotate_fast_tier_model_groups({
"active_provider": active_provider, "active_provider": active_provider,
"default_model": default_model, "default_model": default_model,
"configured_model_badges": {}, "configured_model_badges": {},
"groups": groups, "groups": groups,
"aliases": {}, "aliases": {},
} })
except Exception: except Exception:
logger.debug("minimal static models catalog build failed", exc_info=True) logger.debug("minimal static models catalog build failed", exc_info=True)
return { return {
@@ -4812,7 +4900,7 @@ def _static_models_catalog_without_live_probes() -> dict:
if not groups and default_model: if not groups and default_model:
return copy.deepcopy(_minimal_static_models_catalog()) return copy.deepcopy(_minimal_static_models_catalog())
return { return _annotate_fast_tier_model_groups({
"active_provider": active_provider, "active_provider": active_provider,
"default_model": default_model, "default_model": default_model,
"configured_model_badges": _configured_model_badges_from_static_catalog( "configured_model_badges": _configured_model_badges_from_static_catalog(
@@ -4822,7 +4910,7 @@ def _static_models_catalog_without_live_probes() -> dict:
), ),
"groups": groups, "groups": groups,
"aliases": model_aliases, "aliases": model_aliases,
} })
except Exception: except Exception:
logger.debug("static models catalog build failed", exc_info=True) logger.debug("static models catalog build failed", exc_info=True)
return copy.deepcopy(_minimal_static_models_catalog()) return copy.deepcopy(_minimal_static_models_catalog())
@@ -5339,7 +5427,7 @@ def _load_models_cache_from_disk() -> dict | None:
# disk save path does not persist `aliases`, so reconstruct them from # disk save path does not persist `aliases`, so reconstruct them from
# current config to keep the /api/models.aliases contract intact (a # current config to keep the /api/models.aliases contract intact (a
# disk-cache hit must not silently drop `/model <alias>` resolution). # disk-cache hit must not silently drop `/model <alias>` resolution).
return { return _annotate_fast_tier_model_groups({
"active_provider": cache["active_provider"], "active_provider": cache["active_provider"],
"default_model": cache["default_model"], "default_model": cache["default_model"],
"configured_model_badges": cache["configured_model_badges"], "configured_model_badges": cache["configured_model_badges"],
@@ -5349,7 +5437,7 @@ def _load_models_cache_from_disk() -> dict | None:
if isinstance(cache.get("aliases"), dict) if isinstance(cache.get("aliases"), dict)
else _model_aliases_from_config() else _model_aliases_from_config()
), ),
} })
except Exception: except Exception:
return None return None
@@ -5407,13 +5495,13 @@ def _load_stale_models_cache_from_disk() -> dict | None:
# duration of the over-budget stale fallback. Reconstruct from # duration of the over-budget stale fallback. Reconstruct from
# current config, mirroring the live/static catalog alias build. # current config, mirroring the live/static catalog alias build.
aliases = _model_aliases_from_config() aliases = _model_aliases_from_config()
return { return _annotate_fast_tier_model_groups({
"active_provider": cache["active_provider"], "active_provider": cache["active_provider"],
"default_model": cache["default_model"], "default_model": cache["default_model"],
"configured_model_badges": cache["configured_model_badges"], "configured_model_badges": cache["configured_model_badges"],
"groups": cache["groups"], "groups": cache["groups"],
"aliases": aliases, "aliases": aliases,
} })
except Exception: except Exception:
return None return None
@@ -5478,7 +5566,7 @@ def _get_fresh_memory_models_cache(now: float) -> dict | None:
_available_models_cache_source_fingerprint = None _available_models_cache_source_fingerprint = None
return None return None
if _is_valid_models_cache(_available_models_cache): if _is_valid_models_cache(_available_models_cache):
return copy.deepcopy(_available_models_cache) return _annotate_fast_tier_model_groups(copy.deepcopy(_available_models_cache))
_available_models_cache = None _available_models_cache = None
_available_models_cache_ts = 0.0 _available_models_cache_ts = 0.0
_available_models_live_rebuild_ts = 0.0 _available_models_live_rebuild_ts = 0.0
@@ -6661,6 +6749,19 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
allow_empty: bool = False, allow_empty: bool = False,
) -> None: ) -> None:
picker_models = copy.deepcopy(raw_models or []) picker_models = copy.deepcopy(raw_models or [])
if _is_openai_family_provider(provider_id):
for _model in picker_models:
if not isinstance(_model, dict):
continue
_model_id = str(_model.get("id") or "").strip()
if not _model_id:
continue
_model["supports_fast_tier"] = (
str(
_resolve_main_model_fast_mode_overrides(_model_id, provider_id).get("service_tier", "")
).strip().lower()
== "priority"
)
if apply_prefix: if apply_prefix:
picker_models = _apply_provider_prefix(picker_models, provider_id, active_provider) picker_models = _apply_provider_prefix(picker_models, provider_id, active_provider)
visible_models, extra_models = _split_picker_overflow_models( visible_models, extra_models = _split_picker_overflow_models(
+14 -2
View File
@@ -18552,7 +18552,11 @@ def _handle_live_models(handler, parsed):
# Normalise to {id, label} — provider_model_ids() returns plain string IDs. # Normalise to {id, label} — provider_model_ids() returns plain string IDs.
# For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix). # For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix).
# For all other providers use a simpler hyphen-split capitaliser. # For all other providers use a simpler hyphen-split capitaliser.
from api.config import _format_ollama_label as _fmt_ollama from api.config import (
_format_ollama_label as _fmt_ollama,
_is_openai_family_provider as _is_fast_tier_provider,
_model_supports_fast_tier_for_provider,
)
def _make_label(mid): def _make_label(mid):
"""Best-effort human label from a model ID string.""" """Best-effort human label from a model ID string."""
@@ -18579,7 +18583,15 @@ def _handle_live_models(handler, parsed):
label = label.replace(orig.title(), orig) label = label.replace(orig.title(), orig)
return label return label
models_out = [{"id": mid, "label": _make_label(mid)} for mid in ids if mid] annotate_fast_tier = _is_fast_tier_provider(provider)
models_out = []
for mid in ids:
if not mid:
continue
entry = {"id": mid, "label": _make_label(mid)}
if annotate_fast_tier:
entry["supports_fast_tier"] = _model_supports_fast_tier_for_provider(mid, provider)
models_out.append(entry)
return _finish({"provider": provider, "models": models_out, return _finish({"provider": provider, "models": models_out,
"count": len(models_out)}) "count": len(models_out)})
+8 -11
View File
@@ -9020,6 +9020,11 @@ async function loadSettingsPanel(){
for(const m of [...(g.models||[]),...(g.extra_models||[])]){ for(const m of [...(g.models||[]),...(g.extra_models||[])]){
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m.id;opt.textContent=m.label; opt.value=m.id;opt.textContent=m.label;
if(m && (m.supports_fast_tier === true || String(m.supports_fast_tier).toLowerCase()==='true')){
opt.dataset.fast='1';
}else if(m && (m.supports_fast_tier === false || String(m.supports_fast_tier).toLowerCase()==='false')){
opt.dataset.fast='0';
}
og.appendChild(opt); og.appendChild(opt);
} }
modelSel.appendChild(og); modelSel.appendChild(og);
@@ -12045,17 +12050,9 @@ function _mainModelSupportsServiceTier(cfg){
const optgroup=selectedOpt&&selectedOpt.parentElement&&selectedOpt.parentElement.tagName==='OPTGROUP'?selectedOpt.parentElement: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(); 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'&&provider!=='openai-api'&&provider!=='openai-codex') return false;
if(provider==='openai-codex') return false; const fastSupport = selectedOpt&&selectedOpt.dataset?selectedOpt.dataset.fast:'';
const rawModel=String((selectedOpt&&selectedOpt.value)||(selected&&selected.value)||(cfg&&cfg.model)||'').trim().toLowerCase(); if(fastSupport) return fastSupport==='1'||fastSupport==='true';
if(!rawModel) return true; return cfg&&cfg.supports_fast_tier===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){ function _openAuxAdvancedOptions(taskKey,cfg){
+10
View File
@@ -3123,6 +3123,11 @@ async function populateModelDropdown(opts={}){
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m.id; opt.value=m.id;
opt.textContent=m.label; opt.textContent=m.label;
if(m && (m.supports_fast_tier === true || String(m.supports_fast_tier).toLowerCase()==='true')){
opt.dataset.fast='1';
}else if(m && (m.supports_fast_tier === false || String(m.supports_fast_tier).toLowerCase()==='false')){
opt.dataset.fast='0';
}
og.appendChild(opt); og.appendChild(opt);
_dynamicModelLabels[m.id]=m.label||m.id; _dynamicModelLabels[m.id]=m.label||m.id;
} }
@@ -3221,6 +3226,11 @@ function _addLiveModelsToSelect(provider, models, sel){
opt.textContent=m.label||m.id; opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider'; opt.title='Live model — fetched from provider';
opt.dataset.provider=provider; opt.dataset.provider=provider;
if(m && (m.supports_fast_tier === true || String(m.supports_fast_tier).toLowerCase()==='true')){
opt.dataset.fast='1';
}else if(m && (m.supports_fast_tier === false || String(m.supports_fast_tier).toLowerCase()==='false')){
opt.dataset.fast='0';
}
providerGroup.appendChild(opt); providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id; _dynamicModelLabels[mid]=m.label||m.id;
added++; added++;
+8 -5
View File
@@ -122,16 +122,19 @@ class TestAuxiliaryModelsJS:
assert helper_idx >= 0 assert helper_idx >= 0
helper_body = PANELS_JS[helper_idx:helper_idx + 1300] helper_body = PANELS_JS[helper_idx:helper_idx + 1300]
assert "_mainModelSupportsServiceTier" in PANELS_JS assert "_mainModelSupportsServiceTier" in PANELS_JS
assert "rawModel.includes('/')" in helper_body assert "selectedOpt.dataset.fast" in helper_body
assert "rawModel.slice(0,slash)!=='openai'" in helper_body assert "return cfg&&cfg.supports_fast_tier===true" in helper_body
assert "bareModel.includes('codex')" in helper_body assert "return fastSupport==='1'||fastSupport==='true'" in helper_body
assert "bareModel.startsWith('gpt-')" in helper_body assert "provider!=='openai'&&provider!=='openai-api'&&provider!=='openai-codex')" in helper_body
assert "bareModel.startsWith('o4')" in helper_body assert "provider==='openai-codex') return false" not in helper_body
assert "auxAdvancedServiceTier" in modal_body assert "auxAdvancedServiceTier" in modal_body
assert "isMain&&_mainModelSupportsServiceTier(cfg)" 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" in modal_body
assert "settings_main_advanced_service_tier_default" in modal_body assert "settings_main_advanced_service_tier_default" in modal_body
assert "settings_main_advanced_service_tier_priority" in modal_body assert "settings_main_advanced_service_tier_priority" in modal_body
assert "m.supports_fast_tier" in PANELS_JS
assert "opt.dataset.fast='1'" in PANELS_JS
assert "opt.dataset.fast='0'" in PANELS_JS
def test_main_advanced_save_omits_unsupported_timing_keys(self): 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.""" """Saving main-model options must not send blank timing keys that backend treats as clears."""
+4 -3
View File
@@ -380,13 +380,14 @@ def test_apply_provider_prefix_no_double_prefix():
from api.config import _apply_provider_prefix from api.config import _apply_provider_prefix
raw = [ raw = [
{"id": "@copilot:gpt-5.4", "label": "already prefixed"}, {"id": "@copilot:gpt-5.4", "label": "already prefixed", "supports_fast_tier": True},
{"id": "openai/gpt-5.4", "label": "slash form"}, {"id": "openai/gpt-5.4", "label": "slash form", "supports_fast_tier": True},
{"id": "bare-model", "label": "bare"}, {"id": "bare-model", "label": "bare", "supports_fast_tier": False},
] ]
result = _apply_provider_prefix(raw, "copilot", "openai-codex") result = _apply_provider_prefix(raw, "copilot", "openai-codex")
ids = [m["id"] for m in result] ids = [m["id"] for m in result]
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
assert [m["supports_fast_tier"] for m in result] == [True, True, False]
def test_apply_provider_prefix_active_provider_no_prefix(): def test_apply_provider_prefix_active_provider_no_prefix():
+74 -2
View File
@@ -1,5 +1,7 @@
"""Tests for issue #4536 — main-model service_tier persistence and guarded forwarding.""" """Tests for issue #4536 — main-model service_tier persistence and guarded forwarding."""
import sys
import pytest import pytest
@@ -61,6 +63,7 @@ class TestIssue4536ServiceTier:
payload = config.get_auxiliary_models() payload = config.get_auxiliary_models()
assert payload["main"]["service_tier"] == "priority" assert payload["main"]["service_tier"] == "priority"
assert payload["main"]["supports_fast_tier"] is True
def test_main_service_tier_default_clears_persisted_value(self, monkeypatch, tmp_path): 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.""" """Choosing Default/off should clear service_tier from persisted main-model options."""
@@ -92,6 +95,11 @@ class TestIssue4536ServiceTier:
) )
assert openai_payload == {"service_tier": "priority"} assert openai_payload == {"service_tier": "priority"}
codex_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-5.5", "service_tier": "priority"}},
)
assert codex_payload == {"service_tier": "priority"}
openrouter_payload = config._main_model_request_overrides( openrouter_payload = config._main_model_request_overrides(
{"model": {"provider": "openrouter", "default": "meta-llama/llama-3.1", "service_tier": "priority"}}, {"model": {"provider": "openrouter", "default": "meta-llama/llama-3.1", "service_tier": "priority"}},
) )
@@ -107,10 +115,10 @@ class TestIssue4536ServiceTier:
) )
assert openai_alias_payload == {"service_tier": "priority"} assert openai_alias_payload == {"service_tier": "priority"}
codex_payload = config._main_model_request_overrides( codex_nonfast_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-5.3-codex", "service_tier": "priority"}}, {"model": {"provider": "openai-codex", "default": "gpt-5.3-codex", "service_tier": "priority"}},
) )
assert codex_payload == {} assert codex_nonfast_payload == {}
codex_empty_model_payload = config._main_model_request_overrides( codex_empty_model_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "service_tier": "priority"}}, {"model": {"provider": "openai-codex", "service_tier": "priority"}},
@@ -122,6 +130,16 @@ class TestIssue4536ServiceTier:
) )
assert stale_openai_payload == {} assert stale_openai_payload == {}
foreign_prefixed_openai_payload = config._main_model_request_overrides(
{"model": {"provider": "openai", "default": "openrouter/gpt-5.5", "service_tier": "priority"}},
)
assert foreign_prefixed_openai_payload == {}
codex_unknown_model_payload = config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-7.0-codex", "service_tier": "priority"}},
)
assert codex_unknown_model_payload == {}
def test_auxiliary_payload_hides_service_tier_for_non_openai_main_models(self, monkeypatch, tmp_path): 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.""" """Saved service_tier should not be re-exposed once the main model switches away from OpenAI."""
from api import config from api import config
@@ -194,3 +212,57 @@ class TestIssue4536ServiceTier:
assert result["ok"] is True assert result["ok"] is True
text = config_path.read_text(encoding="utf-8") text = config_path.read_text(encoding="utf-8")
assert "service_tier" not in text assert "service_tier" not in text
def test_standalone_agent_import_failure_preserves_supported_service_tier(self, monkeypatch, tmp_path):
"""If hermes_cli is unavailable, Settings saves should not delete supported service_tier state."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai-codex\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-codex", "default": "gpt-5.5", "service_tier": "priority"}},
)
monkeypatch.setitem(sys.modules, "hermes_cli.models", None)
assert config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-5.5", "service_tier": "priority"}},
) == {"service_tier": "priority"}
result = config.set_hermes_default_model("gpt-5.5", provider="openai-codex")
assert result["ok"] is True
text = config_path.read_text(encoding="utf-8")
assert "service_tier: priority" in text
def test_standalone_agent_import_failure_still_blocks_codex_slug(self, monkeypatch):
"""The compatibility fallback must not broadly enable Codex-specific model slugs."""
from api import config
monkeypatch.setitem(sys.modules, "hermes_cli.models", None)
assert config._main_model_request_overrides(
{"model": {"provider": "openai-codex", "default": "gpt-5.3-codex", "service_tier": "priority"}},
) == {}
def test_auxiliary_payload_marks_false_fast_tier_for_unsupported_main_models(self, monkeypatch, tmp_path):
"""The frontend needs explicit false metadata, not an absent field that falls back stale."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai-codex\n default: gpt-5.3-codex\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"] == ""
assert payload["main"]["supports_fast_tier"] is False
@@ -21,7 +21,7 @@ def _catalog(label: str) -> dict:
{ {
"provider": "OpenAI", "provider": "OpenAI",
"provider_id": "openai", "provider_id": "openai",
"models": [{"id": label, "label": label}], "models": [{"id": label, "label": label, "supports_fast_tier": False}],
} }
], ],
"aliases": {}, "aliases": {},
+2 -2
View File
@@ -43,7 +43,7 @@ def test_live_models_cache_hits_within_ttl(monkeypatch):
assert calls == ["openai"] assert calls == ["openai"]
assert first == second assert first == second
assert first["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}] assert first["models"] == [{"id": "openai/gpt-test", "label": "GPT Test", "supports_fast_tier": True}]
def test_live_models_cache_expires(monkeypatch): def test_live_models_cache_expires(monkeypatch):
@@ -111,7 +111,7 @@ def test_live_models_cache_returns_deep_copies(monkeypatch):
second = routes._handle_live_models(object(), parsed) second = routes._handle_live_models(object(), parsed)
assert second["provider"] == "openai" assert second["provider"] == "openai"
assert second["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}] assert second["models"] == [{"id": "openai/gpt-test", "label": "GPT Test", "supports_fast_tier": True}]
def test_live_models_endpoint_respects_picker_visibility_budget(monkeypatch): def test_live_models_endpoint_respects_picker_visibility_budget(monkeypatch):
+1 -1
View File
@@ -26,7 +26,7 @@ def test_save_models_cache_to_disk_preserves_response_metadata(tmp_path, monkeyp
{ {
"provider": "OpenAI", "provider": "OpenAI",
"provider_id": "openai", "provider_id": "openai",
"models": [{"id": "gpt-5.4-mini", "label": "GPT 5.4 Mini"}], "models": [{"id": "gpt-5.4-mini", "label": "GPT 5.4 Mini", "supports_fast_tier": True}],
} }
], ],
} }