mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
fix(model-routing): keep selected models strict
This commit is contained in:
parent
d96c7af3df
commit
85894d9a4b
14 changed files with 417 additions and 103 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 []:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
31
src/foreground_model_routing.py
Normal file
31
src/foreground_model_routing.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
|
|||
277
tests/test_foreground_model_routing.py
Normal file
277
tests/test_foreground_model_routing.py
Normal 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)
|
||||
29
tests/test_legacy_default_fallback_ui.py
Normal file
29
tests/test_legacy_default_fallback_ui.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue