fix(models): clear deleted endpoint fallback refs (#1207)

This commit is contained in:
red person
2026-06-02 17:41:04 +03:00
committed by GitHub
parent cc6e43da44
commit 42ae905df7
2 changed files with 153 additions and 22 deletions

View File

@@ -33,6 +33,19 @@ _SPEECH_ENDPOINT_SETTINGS = (
("stt_provider", "stt_model", "base", "Speech to Text"),
)
_ENDPOINT_SETTING_FIELDS = {
"default_endpoint_id": ("default_model", "Default Model"),
"utility_endpoint_id": ("utility_model", "Utility Model"),
"research_endpoint_id": ("research_model", "Deep Research"),
"task_endpoint_id": ("task_model", "Background Tasks"),
}
_ENDPOINT_FALLBACK_FIELDS = {
"default_model_fallbacks": "Default Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks",
}
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
"""Return speech settings that reference a model endpoint."""
@@ -56,6 +69,58 @@ def _clear_speech_settings_for_endpoint(settings: dict, ep_id: str) -> list:
return cleared
def _endpoint_settings_using_endpoint(settings: dict, ep_id: str, *, include_speech: bool = False) -> list:
"""Return labels for settings and fallback chains that reference an endpoint."""
affected = []
for ep_key, (_, label) in _ENDPOINT_SETTING_FIELDS.items():
if (settings.get(ep_key) or "") == ep_id:
affected.append(label)
for fallback_key, label in _ENDPOINT_FALLBACK_FIELDS.items():
chain = settings.get(fallback_key) or []
if any(isinstance(entry, dict) and (entry.get("endpoint_id") or "") == ep_id for entry in chain):
affected.append(label)
if include_speech:
affected.extend(_speech_settings_using_endpoint(settings, ep_id))
return affected
def _clear_endpoint_settings_for_endpoint(settings: dict, ep_id: str, *, include_speech: bool = False) -> list:
"""Remove an endpoint from direct settings and model fallback chains."""
cleared = []
for ep_key, (model_key, label) in _ENDPOINT_SETTING_FIELDS.items():
if (settings.get(ep_key) or "") == ep_id:
settings[ep_key] = ""
settings[model_key] = ""
cleared.append(label)
for fallback_key, label in _ENDPOINT_FALLBACK_FIELDS.items():
chain = settings.get(fallback_key)
if not isinstance(chain, list):
continue
kept = [
entry for entry in chain
if not (isinstance(entry, dict) and (entry.get("endpoint_id") or "") == ep_id)
]
if len(kept) != len(chain):
settings[fallback_key] = kept
cleared.append(label)
if include_speech:
cleared.extend(_clear_speech_settings_for_endpoint(settings, ep_id))
return cleared
def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
"""Remove endpoint references from scoped or legacy-flat user preferences."""
if not isinstance(all_prefs, dict):
return 0
users = all_prefs.get("_users")
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
cleared_users = 0
for prefs in pref_sets:
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
cleared_users += 1
return cleared_users
# Loopback hosts a user might type for a local model server (LM Studio,
# llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the
# host the server actually runs on.
@@ -1454,38 +1519,31 @@ def setup_model_routes(model_discovery):
finally:
db.close()
# ── Settings fields that store an endpoint ID ──
_EP_SETTING_FIELDS = {
"default_endpoint_id": ("default_model", "Default Model"),
"utility_endpoint_id": ("utility_model", "Utility Model"),
"research_endpoint_id": ("research_model", "Deep Research"),
"task_endpoint_id": ("task_model", "Background Tasks"),
}
def _settings_using_endpoint(ep_id: str) -> list:
"""Return human-readable labels for settings that reference this endpoint."""
settings = _load_settings()
affected = []
for ep_key, (_, label) in _EP_SETTING_FIELDS.items():
if (settings.get(ep_key) or "") == ep_id:
affected.append(label)
affected.extend(_speech_settings_using_endpoint(settings, ep_id))
return affected
return _endpoint_settings_using_endpoint(_load_settings(), ep_id, include_speech=True)
def _clear_settings_for_endpoint(ep_id: str) -> list:
"""Clear all settings that reference this endpoint. Returns list of cleared labels."""
settings = _load_settings()
cleared = []
for ep_key, (model_key, label) in _EP_SETTING_FIELDS.items():
if (settings.get(ep_key) or "") == ep_id:
settings[ep_key] = ""
settings[model_key] = ""
cleared.append(label)
cleared.extend(_clear_speech_settings_for_endpoint(settings, ep_id))
cleared = _clear_endpoint_settings_for_endpoint(settings, ep_id, include_speech=True)
if cleared:
_save_settings(settings)
return cleared
def _clear_user_prefs_for_endpoint(ep_id: str) -> int:
"""Clear per-user endpoint selections and fallback chains."""
try:
from routes.prefs_routes import _load as _load_prefs, _save as _save_prefs
all_prefs = _load_prefs()
cleared_users = _clear_user_pref_endpoint_refs(all_prefs, ep_id)
if cleared_users:
_save_prefs(all_prefs)
return cleared_users
except Exception as e:
logger.warning("Failed to clear user prefs for endpoint %s: %s", ep_id, e)
return 0
def _session_uses_endpoint_url(session_url: str, base_url: str) -> bool:
if not session_url or not base_url:
return False
@@ -1550,6 +1608,7 @@ def setup_model_routes(model_discovery):
raise HTTPException(404, "Endpoint not found")
# Clean up any settings that reference this endpoint
cleared = _clear_settings_for_endpoint(ep_id)
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
db.delete(ep)
@@ -1559,6 +1618,7 @@ def setup_model_routes(model_discovery):
return {
"deleted": True,
"cleared_settings": cleared,
"cleared_user_preferences": cleared_user_preferences,
"cleared_sessions": cleared_sessions,
"cleared_loaded_sessions": cleared_loaded_sessions,
}

View File

@@ -35,6 +35,9 @@ from routes.model_routes import (
_truthy,
_speech_settings_using_endpoint,
_clear_speech_settings_for_endpoint,
_endpoint_settings_using_endpoint,
_clear_endpoint_settings_for_endpoint,
_clear_user_pref_endpoint_refs,
_PROVIDER_CURATED,
)
from src.llm_core import ANTHROPIC_MODELS
@@ -67,6 +70,74 @@ def test_clear_speech_endpoint_settings_resets_tts_and_stt():
}
def test_endpoint_cleanup_removes_primary_and_fallback_references():
settings = {
"default_endpoint_id": "dead",
"default_model": "primary",
"default_model_fallbacks": [
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
],
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
"stt_provider": "endpoint:dead",
"stt_model": "whisper",
}
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Default Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Default Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert settings["default_endpoint_id"] == ""
assert settings["default_model"] == ""
assert settings["default_model_fallbacks"] == [
{"endpoint_id": "keep", "model": "fallback-b"},
]
assert settings["utility_model_fallbacks"] == []
assert settings["vision_model_fallbacks"] == []
assert settings["stt_provider"] == "disabled"
assert settings["stt_model"] == "base"
def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
scoped = {
"_users": {
"alice": {
"utility_endpoint_id": "dead",
"utility_model": "utility",
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
},
"bob": {
"default_endpoint_id": "keep",
"default_model": "chat",
},
},
}
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
assert scoped["_users"]["alice"] == {
"utility_endpoint_id": "",
"utility_model": "",
"vision_model_fallbacks": [],
}
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
legacy = {
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
}
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 1
assert legacy["default_model_fallbacks"] == []
# ── _match_provider_curated ──
class TestMatchProviderCurated: