harden(#4052): tolerate providers.*.models list-of-dicts keyed by model/name (Opus SHOULD-FIX)

The richer static-catalog builder's group-building loop used a strict item["id"]
extraction that would KeyError (caught → degrade to the minimal one-model catalog)
for legal config shapes where providers.<id>.models is a list of dicts keyed by
"model"/"name" rather than "id". Mirror the tolerant id-or-model-or-name resolution
the detection loop already uses, skip entries with nothing usable, and add a
regression test. No behavior change for the common list-of-strings / list-of-id-dicts
shapes.

docs(changelog): stamp #4052 model-picker budget fallback as v0.51.370 (Release MI)
This commit is contained in:
nesquena-hermes
2026-06-12 20:05:12 +00:00
parent 1c82dab798
commit 82fcf665eb
3 changed files with 68 additions and 11 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.370] — 2026-06-12 — Release MI (model picker shows real providers when /api/models rebuild times out)
### Fixed
- **The model picker no longer collapses to a single "Default" entry when the live model-catalog rebuild exceeds its time budget (#3928).** On the first cold open after a restart, `/api/models` rebuilds the provider catalog by probing each configured provider; on a slow network (or behind a corporate proxy) that probe can blow the bounded rebuild budget. The over-budget fallback previously served an emergency one-model catalog, so the picker showed only "Default" until a later request rebuilt successfully. It now serves a richer **network-free** catalog assembled from local config and the auth store — the active provider and its default model, configured `providers.*` entries, credential-pool providers (excluding ambient `gh` CLI tokens), known providers that have a key, declared fallback providers, and custom providers — with the active provider listed first. The 4-second guardrail is unchanged, the real catalog still refreshes out-of-band for the next caller, nothing is written to the 24-hour disk cache from this path, and the build safely degrades to the minimal one-model catalog if anything goes wrong. (#3928)
## [v0.51.369] — 2026-06-12 — Release MH (WebUI streaming honors runtime target model/base_url)
### Fixed

View File

@@ -3449,17 +3449,24 @@ def _static_models_catalog_without_live_probes() -> dict:
if isinstance(cfg_models, dict):
raw_models = [{"id": key, "label": key} for key in cfg_models.keys()]
elif isinstance(cfg_models, list):
raw_models = [
{
"id": item["id"] if isinstance(item, dict) else item,
"label": (
item.get("label", item["id"])
if isinstance(item, dict)
else item
),
}
for item in cfg_models
]
raw_models = []
for item in cfg_models:
if isinstance(item, dict):
model_id = (
item.get("id")
or item.get("model")
or item.get("name")
)
if not model_id:
continue
raw_models.append(
{
"id": model_id,
"label": item.get("label", model_id),
}
)
elif item:
raw_models.append({"id": item, "label": item})
if not raw_models:
raw_models = copy.deepcopy(_PROVIDER_MODELS.get(pid, []))
for model_id in configured_model_ids.get(pid, []):

View File

@@ -217,3 +217,47 @@ def test_default_group_survives_only_as_emergency_last_resort(
assert catalog["groups"][0]["provider"] == "Default"
assert catalog["groups"][0]["provider_id"] == "anthropic"
assert catalog["groups"][0]["models"][0]["id"] == "claude-sonnet-4.6"
def test_provider_models_list_of_dicts_without_id_does_not_collapse_catalog(
monkeypatch,
isolate_models_catalog_state,
):
"""A legal ``providers.<id>.models`` list of dicts keyed by ``model``/``name``
(not ``id``) must still build the rich catalog instead of KeyError-ing into
the minimal one-model fallback."""
cfg.cfg = {
"model": {
"provider": "openai-api",
"default": "gpt-5.5",
},
"providers": {
"openai_api": {
"api_key": "***",
# list-of-dicts keyed by "model"/"name", and a bare string —
# all legal config shapes that the strict item["id"] path broke.
"models": [
{"model": "gpt-5.5", "label": "GPT-5.5"},
{"name": "gpt-4.1"},
"gpt-4o",
{"label": "no-id-no-model"}, # nothing usable → skipped, no crash
],
},
},
}
catalog = cfg._static_models_catalog_without_live_probes()
provider_ids = [group["provider_id"] for group in catalog["groups"]]
# Must NOT have collapsed to the emergency single "Default" group.
assert not (
len(catalog["groups"]) == 1
and catalog["groups"][0]["provider"] == "Default"
)
assert "openai-api" in provider_ids
openai_group = next(g for g in catalog["groups"] if g["provider_id"] == "openai-api")
group_model_ids = {str(m.get("id") or "") for m in openai_group["models"]}
# All three identifiable models survive; the unusable entry is dropped.
assert any(mid.endswith("gpt-5.5") for mid in group_model_ids)
assert any(mid.endswith("gpt-4.1") for mid in group_model_ids)
assert any(mid.endswith("gpt-4o") for mid in group_model_ids)