diff --git a/CHANGELOG.md b/CHANGELOG.md index ea463b0d..799f0ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/config.py b/api/config.py index 8b9270c7..18e0b739 100644 --- a/api/config.py +++ b/api/config.py @@ -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, []): diff --git a/tests/test_issue3928_models_budget_fallback.py b/tests/test_issue3928_models_budget_fallback.py index b2e18d45..8d3bb789 100644 --- a/tests/test_issue3928_models_budget_fallback.py +++ b/tests/test_issue3928_models_budget_fallback.py @@ -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..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)