fix(model-routing): keep selected models strict

This commit is contained in:
RaresKeY 2026-07-20 14:28:17 +00:00
parent d96c7af3df
commit 85894d9a4b
14 changed files with 417 additions and 103 deletions

View file

@ -21,6 +21,7 @@ from src import agent_runs
from src.model_context import estimate_tokens
from src.chat_helpers import coerce_message_and_session
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
from src.foreground_model_routing import build_foreground_model_candidates
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
@ -1399,14 +1400,14 @@ def setup_chat_routes(
thinking_response = ""
last_metrics = None
# Configured fallback chain for the default chat model. Tried in
# order if the session's primary model fails before producing
# output. Resolved once per request.
try:
from src.endpoint_resolver import resolve_chat_fallback_candidates
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
except Exception:
_fallback_candidates = []
# Foreground Chat and Agent requests use one owner-aware policy
# boundary. Legacy `default_model_fallbacks` data is not eligible.
_foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
)
# Send model name early so the frontend can show it during streaming
_model_suffix = "Research" if effective_do_research else None
@ -1522,9 +1523,8 @@ def setup_chat_routes(
_actual_model = None
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
async for chunk in stream_llm_with_fallback(
_chat_candidates,
_foreground_candidates,
messages,
temperature=ctx.preset.temperature,
# Respect the preset; 0/unset = let the server decide (no
@ -1710,7 +1710,7 @@ def setup_chat_routes(
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
owner=_user,
fallbacks=_fallback_candidates,
fallbacks=_foreground_candidates[1:],
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,

View file

@ -5209,9 +5209,9 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints AND their configured
# fallback chains. Dedupe by url+model so we don't retry
# the same broken endpoint.
# user's Utility / Default endpoints and the active Utility
# fallback chain. The retired default-fallback hook stays empty.
# Dedupe by url+model so we don't retry the same broken endpoint.
from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import (
resolve_utility_fallback_candidates,
@ -5240,7 +5240,7 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers)
except Exception:
pass
# Configured fallback chains last.
# Active Utility fallbacks, then the retired default hook.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:

View file

@ -46,10 +46,11 @@ _ENDPOINT_SETTING_FIELDS = {
}
_ENDPOINT_FALLBACK_FIELDS = {
"default_model_fallbacks": "Default Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks",
}
# `default_model_fallbacks` is intentionally absent. The legacy data remains
# stored as-is even when an endpoint is removed, but no longer affects routing.
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
@ -2437,7 +2438,6 @@ def setup_model_routes(model_discovery):
_user_prefs = _load_for_user(_user) or {}
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip()
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
# If user has no personal default, fall back to global default
# But only based on the "share_defaults_with_users" flag
# (only if share_defaults_with_users is enabled)
@ -2446,12 +2446,9 @@ def setup_model_routes(model_discovery):
ep_id = settings.get("default_endpoint_id", "")
if not model:
model = settings.get("default_model", "")
if not _fallbacks:
_fallbacks = settings.get("default_model_fallbacks") or []
else:
ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "")
_fallbacks = settings.get("default_model_fallbacks") or []
db = SessionLocal()
try:
ep = None
@ -2466,33 +2463,6 @@ def setup_model_routes(model_discovery):
if _user and not _is_admin:
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
ep = ep_q.first()
# Configured fallback chain — when the chosen default endpoint is
# gone/disabled, honor the user's configured `default_model_fallbacks`
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
# (Previously this jumped straight to "first enabled", which is why
# deleting/changing the main endpoint silently reassigned the default
# chat to some unrelated endpoint instead of the fallback.)
if not ep:
for entry in _fallbacks:
if not isinstance(entry, dict):
continue
fid = (entry.get("endpoint_id") or "").strip()
if not fid:
continue
cand_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
)
if _user and not _is_admin:
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
cand = cand_q.first()
if cand:
ep = cand
# Use the fallback entry's model. Reset even when empty
# so we don't carry the prior endpoint's stale model onto
# this fallback — the cached-models lookup below then
# fills it from the fallback endpoint.
model = (entry.get("model") or "").strip()
break
# Last resort: first enabled endpoint owned by THIS user. Do not
# include null-owner/shared endpoints here: a brand-new user with
# no explicit default should not auto-open a pending chat using an

View file

@ -443,28 +443,14 @@ def resolve_endpoint_by_id(
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
"""Build the configured default-chat fallback chain as a list of
(chat_url, model, headers) tuples, skipping any that can't resolve.
"""Compatibility shim for the retired default-chat fallback chain."""
The primary model is NOT included callers prepend their session's
current (url, model, headers) so per-session model overrides are honored.
"""
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
del owner
return []
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
try:
from src.settings import get_user_setting, load_settings
settings = load_settings()
utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip()
if not utility_ep:
utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or []
if utility_chain:
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
except Exception:
pass
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)

View file

@ -0,0 +1,31 @@
"""Foreground Chat and Agent model-routing policy.
The selected session model is strict by default. Historical
``default_model_fallbacks`` values remain stored for compatibility, but this
policy intentionally does not read or migrate them.
"""
from typing import Any, Dict, Optional
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
"""Return fallback candidates for a foreground Chat or Agent request.
Foreground routing is strict, so no alternate endpoint/model is eligible.
``owner`` is accepted to keep this policy boundary owner-aware.
"""
del owner
return []
def build_foreground_model_candidates(
endpoint_url: str,
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
) -> list:
"""Build the ordered candidate list for a foreground request."""
primary = (endpoint_url, model, headers or {})
return [primary] + resolve_foreground_fallback_candidates(owner=owner)

View file

@ -1885,11 +1885,10 @@ def _dedupe_candidates(candidates):
"""Filter malformed entries and drop a later repeat of an already-seen
``(url, model)`` route, preserving order (first occurrence wins).
The chain is the primary target followed by the configured fallbacks, so a
fallback that repeats the session's current model — a common misconfiguration,
since callers prepend the live ``(url, model)`` to ``default_model_fallbacks``
would otherwise make the chain re-attempt the very route that just failed:
a wasted round-trip plus a spurious ``fallback`` notice for a switch that did
The chain is the primary target followed by any caller-authorized
fallbacks. A fallback that repeats the session's current model would
otherwise make the chain re-attempt the very route that just failed: a
wasted round-trip plus a spurious ``fallback`` notice for a switch that did
not happen. Headers are not part of the key; the first tuple (with its
headers) is the one kept.
"""

View file

@ -138,14 +138,13 @@ DEFAULT_SETTINGS = {
# Email replies use email_writing_style instead because greetings,
# signatures, and mailbox identity rules are medium-specific.
"document_writing_style": "",
# Ordered fallback chain for the default chat model. Each entry is
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
# before producing output (endpoint offline / errors), the chat
# dispatch retries the next entry in order.
# Legacy ordered fallback chain for the default chat model. Values remain
# stored for compatibility and rollback reference, but model routing no
# longer reads this key.
"default_model_fallbacks": [],
# When True, non-admin users inherit global default model/endpoint/fallbacks
# when they have no personal defaults. When False, users only use their
# personal defaults (no global fallback). Default is False.
# When True, non-admin users inherit the global default model/endpoint when
# they have no personal defaults. When False, users only use their personal
# defaults. Default is False.
"share_defaults_with_users": False,
"utility_endpoint_id": "",
"utility_model": "",

View file

@ -32,7 +32,7 @@ def resolve_task_candidates(
2. Utility endpoint/model
3. Default endpoint/model
4. Utility fallback chain
5. Default fallback chain
5. Retired default-fallback compatibility hook (currently empty)
"""
candidates = []

View file

@ -1482,7 +1482,7 @@
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
<select id="set-defaultModelSelect" class="settings-select"></select>
</div>
<div class="settings-row" style="align-items:flex-start;">
<div class="settings-row" style="align-items:flex-start;" hidden>
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>

View file

@ -448,7 +448,7 @@ async function initDefaultChat() {
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
@ -534,11 +534,6 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
_fallbacks = Array.isArray(settings.default_model_fallbacks)
? settings.default_model_fallbacks.map(function(f) {
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
})
: [];
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
@ -547,13 +542,11 @@ async function initDefaultChat() {
async function saveDefault() {
try {
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_endpoint_id: epSel.value,
default_model: modelSel.value,
default_model_fallbacks: clean
default_model: modelSel.value
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';

View file

@ -0,0 +1,277 @@
"""Regression coverage for strict foreground model selection."""
import asyncio
import json
from types import SimpleNamespace
import pytest
import src.agent_loop as agent_loop
import src.endpoint_resolver as endpoint_resolver
import src.foreground_model_routing as foreground_model_routing
import routes.chat_routes as chat_routes
from src.foreground_model_routing import (
build_foreground_model_candidates,
resolve_foreground_fallback_candidates,
)
def _collect(gen):
async def _run():
return [chunk async for chunk in gen]
return asyncio.run(_run())
class _EmptyQuery:
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def first(self):
return None
class _EmptyDb:
def query(self, *args, **kwargs):
return _EmptyQuery()
def close(self):
return None
class _RouteRequest:
def __init__(self, mode):
self.headers = {}
self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=None))
self._form = {
"message": "hello",
"session": "session-1",
"mode": mode,
"compare_mode": "true",
}
async def form(self):
return self._form
def _chat_stream_endpoint(monkeypatch, mode, captured):
session = SimpleNamespace(
endpoint_url="https://selected.example/v1",
model="selected-model",
headers={"Authorization": "Bearer selected"},
name="test",
history=[],
add_message=lambda message: None,
)
session_manager = SimpleNamespace(
get_session=lambda session_id: session,
save_sessions=lambda: None,
)
context = SimpleNamespace(
user="alice",
messages=[{"role": "user", "content": "hello"}],
preprocessed=SimpleNamespace(attachment_meta=[]),
auto_opened_docs=[],
rag_sources=[],
web_sources=[],
used_memories=[],
uploaded_files=[],
uprefs={},
was_compacted=False,
context_trimmed=False,
context_length=4096,
context_messages_before_trim=1,
context_messages_after_trim=1,
context_tokens_before_trim=10,
context_tokens_after_trim=10,
preset=SimpleNamespace(temperature=0.2, max_tokens=128, character_name=None),
)
async def fake_build_context(*args, **kwargs):
return context
async def fake_chat_stream(candidates, messages, **kwargs):
captured["chat"] = candidates
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
async def fake_agent_stream(endpoint_url, model, messages, **kwargs):
captured["agent"] = {
"primary": (endpoint_url, model, kwargs.get("headers")),
"fallbacks": kwargs.get("fallbacks"),
}
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(chat_routes, "coerce_message_and_session", lambda *args, **kwargs: ("hello", "session-1"))
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "effective_user", lambda request: "alice")
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "get_session_mode", lambda session_id: "chat")
monkeypatch.setattr(chat_routes, "set_session_mode", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "build_chat_context", fake_build_context)
monkeypatch.setattr(chat_routes, "SessionLocal", _EmptyDb)
monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "stream_llm_with_fallback", fake_chat_stream)
monkeypatch.setattr(chat_routes, "stream_agent_loop", fake_agent_stream)
monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "estimate_tokens", lambda messages: 10)
monkeypatch.setattr(
endpoint_resolver,
"resolve_chat_fallback_candidates",
lambda owner=None: [("https://legacy.example/v1", "legacy-model", {})],
)
import src.settings as settings
monkeypatch.setattr(
settings,
"get_setting",
lambda key, default=None: default,
)
monkeypatch.setattr(
settings,
"get_user_setting",
lambda key, owner="", default=None: (
[{"endpoint_id": "legacy", "model": "legacy-model"}]
if key == "default_model_fallbacks"
else default
),
)
router = chat_routes.setup_chat_routes(
session_manager,
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
)
return next(route.endpoint for route in router.routes if route.path == "/api/chat_stream")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["chat", "agent"])
async def test_chat_stream_route_keeps_selected_model_strict_with_legacy_data(monkeypatch, mode):
captured = {}
endpoint = _chat_stream_endpoint(monkeypatch, mode, captured)
response = await endpoint(_RouteRequest(mode))
async for _ in response.body_iterator:
pass
selected = (
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
)
if mode == "chat":
assert captured == {"chat": [selected]}
else:
assert captured == {"agent": {"primary": selected, "fallbacks": []}}
def test_candidate_builder_appends_only_policy_authorized_fallbacks(monkeypatch):
"""Chat and Agent share the same candidate-building policy boundary."""
authorized = [("https://opt-in.example/v1", "opt-in-model", {})]
monkeypatch.setattr(
foreground_model_routing,
"resolve_foreground_fallback_candidates",
lambda owner=None: authorized,
)
assert build_foreground_model_candidates(
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
owner="alice",
) == [
("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"}),
*authorized,
]
def test_strict_policy_builds_only_the_selected_chat_candidate():
candidates = build_foreground_model_candidates(
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
owner="alice",
)
assert candidates == [
("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"})
]
def test_legacy_chat_resolver_is_disconnected():
assert endpoint_resolver.resolve_chat_fallback_candidates(owner="alice") == []
def test_utility_resolver_does_not_inherit_legacy_chat_fallbacks(monkeypatch):
seen_keys = []
def fake_resolve(setting_key, owner=None):
seen_keys.append((setting_key, owner))
return [("https://utility.example/v1", "utility-model", {})]
monkeypatch.setattr(endpoint_resolver, "_resolve_fallback_candidates", fake_resolve)
assert endpoint_resolver.resolve_utility_fallback_candidates(owner="alice") == [
("https://utility.example/v1", "utility-model", {})
]
assert seen_keys == [("utility_model_fallbacks", "alice")]
def test_multi_round_agent_uses_only_selected_model(monkeypatch):
"""Every Agent round receives only the selected foreground candidate."""
seen_candidates = []
round_number = 0
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
async def fake_stream(candidates, messages, **kwargs):
nonlocal round_number
round_number += 1
seen_candidates.append([(url, model) for url, model, _headers in candidates])
if round_number == 1:
call = {"name": "bash", "arguments": json.dumps({"command": "printf ok"})}
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
else:
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
async def fake_execute(block, *args, **kwargs):
return "bash", {"output": "ok", "exit_code": 0}
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
fallbacks = resolve_foreground_fallback_candidates(owner="alice")
chunks = _collect(
agent_loop.stream_agent_loop(
"https://selected.example/v1",
"selected-model",
[{"role": "user", "content": "Run one tool and report back."}],
max_rounds=3,
relevant_tools={"bash"},
fallbacks=fallbacks,
_is_teacher_run=True,
)
)
assert seen_candidates == [
[("https://selected.example/v1", "selected-model")],
[("https://selected.example/v1", "selected-model")],
]
assert any('"delta": "done"' in chunk for chunk in chunks)

View file

@ -0,0 +1,29 @@
"""The retired default fallback editor must not imply active routing."""
from pathlib import Path
from bs4 import BeautifulSoup
_REPO = Path(__file__).resolve().parents[1]
def test_legacy_default_fallback_editor_is_hidden():
soup = BeautifulSoup(
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
"html.parser",
)
editor = soup.find(id="set-defaultFallbacks")
assert editor is not None
assert editor.find_parent(class_="settings-row").has_attr("hidden")
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
start = source.index("async function initDefaultChat()")
end = source.index("/* ── Utility Model ── */", start)
default_chat_source = source[start:end]
assert "settings.default_model_fallbacks" not in default_chat_source
assert "default_model_fallbacks:" not in default_chat_source

View file

@ -144,10 +144,10 @@ def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypa
assert test_data["model"] == "", "Should get empty model"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
defaults for ep_id and model when share_defaults_with_users is enabled.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
@ -158,16 +158,45 @@ def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_f
assert test_data["endpoint_id"] == "global-ep-123", \
"Should get global endpoint_id"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
def test_get_default_chat_does_not_read_legacy_fallbacks(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
The preserved legacy list must not influence default model resolution.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
class LegacyReadGuard(dict):
def get(self, key, default=None):
if key == "default_model_fallbacks":
raise AssertionError("legacy fallback list was read")
return super().get(key, default)
assert test_data["model"] == "qwen-3.6", \
"model should be resolved from global default_model"
guarded_settings = LegacyReadGuard({
"default_endpoint_id": "global-ep-123",
"default_model": "qwen-3.6",
"default_model_fallbacks": [
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
],
"share_defaults_with_users": True,
})
monkeypatch.setattr(model_routes, "_load_settings", lambda: guarded_settings)
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: LegacyReadGuard({}))
assert test_data["endpoint_id"] == "fallback-ep", \
"Should get global endpoint_id"
fake_auth_manager = MagicMock()
fake_auth_manager.is_admin = lambda user: False
endpoint = _FakeEndpoint(
id="global-ep-123",
base_url="http://global-endpoint:8000/v1",
is_enabled=True,
)
fake_db = _make_db_session([endpoint], user="regular_user")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
router = model_routes.setup_model_routes(model_discovery=None)
get_default_chat = _get_default_chat_route(router)
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
test_data = get_default_chat(fake_request)
assert test_data["endpoint_id"] == "global-ep-123"
assert test_data["model"] == "qwen-3.6"

View file

@ -90,7 +90,7 @@ def test_clear_speech_endpoint_settings_resets_tts_and_stt():
}
def test_endpoint_cleanup_removes_primary_and_fallback_references():
def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
settings = {
"default_endpoint_id": "dead",
"default_model": "primary",
@ -106,14 +106,12 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
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",
@ -121,6 +119,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
assert settings["default_endpoint_id"] == ""
assert settings["default_model"] == ""
assert settings["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
]
assert settings["utility_model_fallbacks"] == []
@ -129,7 +128,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
assert settings["stt_model"] == "base"
def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
scoped = {
"_users": {
"alice": {
@ -154,8 +153,10 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
legacy = {
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
}
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 1
assert legacy["default_model_fallbacks"] == []
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 0
assert legacy["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "chat"}
]
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──