mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 03:32:25 +01:00
fix(model-routing): keep selected models strict (#5801)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
co-authored by
Alexandre Teixeira
parent
53869d194d
commit
b52296471b
+11
-11
@@ -21,6 +21,7 @@ from src import agent_runs
|
|||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.chat_helpers import coerce_message_and_session
|
from src.chat_helpers import coerce_message_and_session
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
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.session_search import search_session_messages
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.exceptions import SessionNotFoundError
|
from core.exceptions import SessionNotFoundError
|
||||||
@@ -1399,14 +1400,14 @@ def setup_chat_routes(
|
|||||||
thinking_response = ""
|
thinking_response = ""
|
||||||
last_metrics = None
|
last_metrics = None
|
||||||
|
|
||||||
# Configured fallback chain for the default chat model. Tried in
|
# Foreground Chat and Agent requests use one owner-aware policy
|
||||||
# order if the session's primary model fails before producing
|
# boundary. Legacy `default_model_fallbacks` data is not eligible.
|
||||||
# output. Resolved once per request.
|
_foreground_candidates = build_foreground_model_candidates(
|
||||||
try:
|
sess.endpoint_url,
|
||||||
from src.endpoint_resolver import resolve_chat_fallback_candidates
|
sess.model,
|
||||||
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
|
sess.headers,
|
||||||
except Exception:
|
owner=_user,
|
||||||
_fallback_candidates = []
|
)
|
||||||
|
|
||||||
# Send model name early so the frontend can show it during streaming
|
# Send model name early so the frontend can show it during streaming
|
||||||
_model_suffix = "Research" if effective_do_research else None
|
_model_suffix = "Research" if effective_do_research else None
|
||||||
@@ -1522,9 +1523,8 @@ def setup_chat_routes(
|
|||||||
_actual_model = None
|
_actual_model = None
|
||||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||||
try:
|
try:
|
||||||
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
|
|
||||||
async for chunk in stream_llm_with_fallback(
|
async for chunk in stream_llm_with_fallback(
|
||||||
_chat_candidates,
|
_foreground_candidates,
|
||||||
messages,
|
messages,
|
||||||
temperature=ctx.preset.temperature,
|
temperature=ctx.preset.temperature,
|
||||||
# Respect the preset; 0/unset = let the server decide (no
|
# 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,
|
disabled_tools=disabled_tools if disabled_tools else None,
|
||||||
tool_policy=tool_policy,
|
tool_policy=tool_policy,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
fallbacks=_fallback_candidates,
|
fallbacks=_foreground_candidates[1:],
|
||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace or None,
|
workspace=workspace or None,
|
||||||
|
|||||||
@@ -5327,9 +5327,9 @@ def setup_email_routes():
|
|||||||
# Build a candidate chain so a stale session-stored API key
|
# Build a candidate chain so a stale session-stored API key
|
||||||
# (the most common cause of "authentication failed" here)
|
# (the most common cause of "authentication failed" here)
|
||||||
# doesn't kill AI Reply outright — fall through to the
|
# doesn't kill AI Reply outright — fall through to the
|
||||||
# user's Utility / Default endpoints AND their configured
|
# user's Utility / Default endpoints and the active Utility
|
||||||
# fallback chains. Dedupe by url+model so we don't retry
|
# fallback chain. The retired default-fallback hook stays empty.
|
||||||
# the same broken endpoint.
|
# 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.llm_core import llm_call_async_with_fallback
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
@@ -5358,7 +5358,7 @@ def setup_email_routes():
|
|||||||
_add(_d_url, _d_model, _d_headers)
|
_add(_d_url, _d_model, _d_headers)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Configured fallback chains last.
|
# Active Utility fallbacks, then the retired default hook.
|
||||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||||
|
|||||||
+2
-32
@@ -46,10 +46,11 @@ _ENDPOINT_SETTING_FIELDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ENDPOINT_FALLBACK_FIELDS = {
|
_ENDPOINT_FALLBACK_FIELDS = {
|
||||||
"default_model_fallbacks": "Default Model Fallbacks",
|
|
||||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||||
"vision_model_fallbacks": "Vision 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:
|
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 {}
|
_user_prefs = _load_for_user(_user) or {}
|
||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") 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
|
# If user has no personal default, fall back to global default
|
||||||
# But only based on the "share_defaults_with_users" flag
|
# But only based on the "share_defaults_with_users" flag
|
||||||
# (only if share_defaults_with_users is enabled)
|
# (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", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
if not model:
|
if not model:
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
if not _fallbacks:
|
|
||||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = None
|
ep = None
|
||||||
@@ -2466,33 +2463,6 @@ def setup_model_routes(model_discovery):
|
|||||||
if _user and not _is_admin:
|
if _user and not _is_admin:
|
||||||
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
||||||
ep = ep_q.first()
|
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
|
# Last resort: first enabled endpoint owned by THIS user. Do not
|
||||||
# include null-owner/shared endpoints here: a brand-new user with
|
# include null-owner/shared endpoints here: a brand-new user with
|
||||||
# no explicit default should not auto-open a pending chat using an
|
# 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:
|
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||||
"""Build the configured default-chat fallback chain as a list of
|
"""Compatibility shim for the retired default-chat fallback chain."""
|
||||||
(chat_url, model, headers) tuples, skipping any that can't resolve.
|
|
||||||
|
|
||||||
The primary model is NOT included — callers prepend their session's
|
del owner
|
||||||
current (url, model, headers) so per-session model overrides are honored.
|
return []
|
||||||
"""
|
|
||||||
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||||
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
|
"""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)
|
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+4
-5
@@ -1949,11 +1949,10 @@ def _dedupe_candidates(candidates):
|
|||||||
"""Filter malformed entries and drop a later repeat of an already-seen
|
"""Filter malformed entries and drop a later repeat of an already-seen
|
||||||
``(url, model)`` route, preserving order (first occurrence wins).
|
``(url, model)`` route, preserving order (first occurrence wins).
|
||||||
|
|
||||||
The chain is the primary target followed by the configured fallbacks, so a
|
The chain is the primary target followed by any caller-authorized
|
||||||
fallback that repeats the session's current model — a common misconfiguration,
|
fallbacks. A fallback that repeats the session's current model would
|
||||||
since callers prepend the live ``(url, model)`` to ``default_model_fallbacks``
|
otherwise make the chain re-attempt the very route that just failed: a
|
||||||
— would otherwise make the chain re-attempt the very route that just failed:
|
wasted round-trip plus a spurious ``fallback`` notice for a switch that did
|
||||||
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
|
not happen. Headers are not part of the key; the first tuple (with its
|
||||||
headers) is the one kept.
|
headers) is the one kept.
|
||||||
"""
|
"""
|
||||||
|
|||||||
+6
-7
@@ -138,14 +138,13 @@ DEFAULT_SETTINGS = {
|
|||||||
# Email replies use email_writing_style instead because greetings,
|
# Email replies use email_writing_style instead because greetings,
|
||||||
# signatures, and mailbox identity rules are medium-specific.
|
# signatures, and mailbox identity rules are medium-specific.
|
||||||
"document_writing_style": "",
|
"document_writing_style": "",
|
||||||
# Ordered fallback chain for the default chat model. Each entry is
|
# Legacy ordered fallback chain for the default chat model. Values remain
|
||||||
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
# stored for compatibility and rollback reference, but model routing no
|
||||||
# before producing output (endpoint offline / errors), the chat
|
# longer reads this key.
|
||||||
# dispatch retries the next entry in order.
|
|
||||||
"default_model_fallbacks": [],
|
"default_model_fallbacks": [],
|
||||||
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
# When True, non-admin users inherit the global default model/endpoint when
|
||||||
# when they have no personal defaults. When False, users only use their
|
# they have no personal defaults. When False, users only use their personal
|
||||||
# personal defaults (no global fallback). Default is False.
|
# defaults. Default is False.
|
||||||
"share_defaults_with_users": False,
|
"share_defaults_with_users": False,
|
||||||
"utility_endpoint_id": "",
|
"utility_endpoint_id": "",
|
||||||
"utility_model": "",
|
"utility_model": "",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def resolve_task_candidates(
|
|||||||
2. Utility endpoint/model
|
2. Utility endpoint/model
|
||||||
3. Default endpoint/model
|
3. Default endpoint/model
|
||||||
4. Utility fallback chain
|
4. Utility fallback chain
|
||||||
5. Default fallback chain
|
5. Retired default-fallback compatibility hook (currently empty)
|
||||||
"""
|
"""
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1504,7 +1504,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>
|
<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>
|
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||||
</div>
|
</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>
|
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ async function initDefaultChat() {
|
|||||||
var fbContainer = el('set-defaultFallbacks');
|
var fbContainer = el('set-defaultFallbacks');
|
||||||
var addFbBtn = el('set-defaultAddFallback');
|
var addFbBtn = el('set-defaultAddFallback');
|
||||||
var _endpoints = [];
|
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() {
|
function enabledEndpoints() {
|
||||||
return _endpoints.filter(function(e) { return e.is_enabled; });
|
return _endpoints.filter(function(e) { return e.is_enabled; });
|
||||||
@@ -534,11 +534,6 @@ async function initDefaultChat() {
|
|||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||||
refreshModels(settings.default_model || '');
|
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();
|
renderFallbacks();
|
||||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||||
|
|
||||||
@@ -547,13 +542,11 @@ async function initDefaultChat() {
|
|||||||
|
|
||||||
async function saveDefault() {
|
async function saveDefault() {
|
||||||
try {
|
try {
|
||||||
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
|
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
default_endpoint_id: epSel.value,
|
default_endpoint_id: epSel.value,
|
||||||
default_model: modelSel.value,
|
default_model: modelSel.value
|
||||||
default_model_fallbacks: clean
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
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
|
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)
|
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", \
|
assert test_data["endpoint_id"] == "global-ep-123", \
|
||||||
"Should get global endpoint_id"
|
"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
|
The preserved legacy list must not influence default model resolution.
|
||||||
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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", \
|
guarded_settings = LegacyReadGuard({
|
||||||
"model should be resolved from global default_model"
|
"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", \
|
fake_auth_manager = MagicMock()
|
||||||
"Should get global endpoint_id"
|
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 = {
|
settings = {
|
||||||
"default_endpoint_id": "dead",
|
"default_endpoint_id": "dead",
|
||||||
"default_model": "primary",
|
"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) == [
|
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
|
||||||
"Default Model",
|
"Default Model",
|
||||||
"Default Model Fallbacks",
|
|
||||||
"Utility Model Fallbacks",
|
"Utility Model Fallbacks",
|
||||||
"Vision Model Fallbacks",
|
"Vision Model Fallbacks",
|
||||||
"Speech to Text",
|
"Speech to Text",
|
||||||
]
|
]
|
||||||
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
||||||
"Default Model",
|
"Default Model",
|
||||||
"Default Model Fallbacks",
|
|
||||||
"Utility Model Fallbacks",
|
"Utility Model Fallbacks",
|
||||||
"Vision Model Fallbacks",
|
"Vision Model Fallbacks",
|
||||||
"Speech to Text",
|
"Speech to Text",
|
||||||
@@ -121,6 +119,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
|||||||
assert settings["default_endpoint_id"] == ""
|
assert settings["default_endpoint_id"] == ""
|
||||||
assert settings["default_model"] == ""
|
assert settings["default_model"] == ""
|
||||||
assert settings["default_model_fallbacks"] == [
|
assert settings["default_model_fallbacks"] == [
|
||||||
|
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||||
]
|
]
|
||||||
assert settings["utility_model_fallbacks"] == []
|
assert settings["utility_model_fallbacks"] == []
|
||||||
@@ -129,7 +128,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
|||||||
assert settings["stt_model"] == "base"
|
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 = {
|
scoped = {
|
||||||
"_users": {
|
"_users": {
|
||||||
"alice": {
|
"alice": {
|
||||||
@@ -154,8 +153,10 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
|
|||||||
legacy = {
|
legacy = {
|
||||||
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
|
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
|
||||||
}
|
}
|
||||||
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 1
|
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 0
|
||||||
assert legacy["default_model_fallbacks"] == []
|
assert legacy["default_model_fallbacks"] == [
|
||||||
|
{"endpoint_id": "dead", "model": "chat"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──
|
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user