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:
@@ -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
|
||||
|
||||
@@ -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, []):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user