refactor(model-routing): centralize explicit foreground fallback policy (#6020)

* refactor(model-routing): centralize explicit foreground fallback policy

Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs.

Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate.

* fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp

* fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility

* fix(chat): restore stream helpers and harden run stop lifecycle

* fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses

* fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate

* fix(chat): honor queued stop across resend and reload canonical terminal on EOF

* fix(chat): track stop queue and cleanup ownership by per-send generation

* fix(agent-loop): preserve requested temperature for non-qwen fallback candidates

* fix(chat): reserve send ownership before any await and scope stop to the current send

* fix(chat): clear the previous run identity at send reservation

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
This commit is contained in:
Alexandre Teixeira
2026-08-14 08:10:30 +01:00
committed by GitHub
co-authored by RaresKeY StressTestor
parent b52296471b
commit c4369305f0
46 changed files with 11418 additions and 1006 deletions
+47 -100
View File
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens
from src.model_context import estimate_tokens, get_context_length
from src.auth_helpers import effective_user
from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref
@@ -152,10 +152,38 @@ class ChatContext:
# Uploads attached to this user turn, resolved and owner-checked for the
# agent's private context. This is not emitted to the browser.
uploaded_files: list = field(default_factory=list)
# Route-neutral prompt before any model-window compaction/trimming. This is
# retained only when explicit foreground fallbacks are enabled so each
# concrete candidate can apply its own context budget independently.
route_messages: list = field(default_factory=list)
# ── Helpers ────────────────────────────────────────────────────────────── #
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
if privs.get("block_all_models"):
return frozenset()
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
try:
user = effective_user(request)
except Exception:
user = None
if not user:
return None
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not auth_manager:
return None
privs = auth_manager.get_privileges(user) or {}
return _allowed_models_from_privileges(privs)
def _enforce_chat_privileges(request, sess) -> None:
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
@@ -185,10 +213,8 @@ def _enforce_chat_privileges(request, sess) -> None:
if privs.get("block_all_models"):
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
if restricted and sess.model and sess.model not in allowed:
allowed_models = _allowed_models_from_privileges(privs)
if allowed_models is not None and sess.model and sess.model not in allowed_models:
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
cap = int(privs.get("max_messages_per_day") or 0)
@@ -287,96 +313,6 @@ async def auto_name_session(session_manager, sess):
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
"""Find an alternative working endpoint when the current one fails.
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import (
build_chat_url,
build_headers,
build_models_url,
normalize_base,
resolve_endpoint_runtime,
)
from src.chatgpt_subscription import is_chatgpt_subscription_base
current_url = sess.endpoint_url or ""
owner = getattr(sess, "owner", None)
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True
)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
finally:
db.close()
for ep in endpoints:
base = normalize_base(ep.base_url)
# Skip current endpoint
if current_url and base in current_url:
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
ping_url = build_models_url(base)
headers = build_headers(api_key, base)
try:
if ping_url:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
models = json.loads(ep.cached_models or "[]")
if not models:
continue
# Found a working endpoint — update session
new_model = models[0]
chat_url = build_chat_url(base)
new_headers = build_headers(api_key, base)
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
sess.model = new_model
sess.endpoint_url = chat_url
sess.headers = new_headers
# Persist
_db = SessionLocal()
try:
_db.query(DBSession).filter(DBSession.id == session_id).update({
"model": new_model,
"endpoint_url": chat_url,
"headers": persisted_headers,
})
_db.commit()
finally:
_db.close()
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
return {
"model": new_model,
"endpoint_url": chat_url,
"endpoint_name": ep.name,
}
except Exception:
continue
return None
def extract_preset(chat_handler, preset_id) -> PresetInfo:
"""Extract preset parameters via chat_handler."""
temperature, max_tokens, system_prompt, char_name = (
@@ -687,6 +623,7 @@ async def build_chat_context(
use_enhanced_message: bool = False,
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -830,13 +767,22 @@ async def build_chat_context(
except Exception:
logger.debug("Failed to add current date/time context", exc_info=True)
# Auto-compact
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
route_messages = list(messages)
# Explicit fallback routing must shape from the same route-neutral prompt
# for every candidate. Running selected-model compaction here would mutate
# session history before we know which route can answer and would make a
# later larger-context candidate unable to recover discarded history.
if defer_context_shaping:
context_length = get_context_length(sess.endpoint_url, sess.model)
was_compacted = False
else:
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
_before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages)
messages = trim_for_context(messages, context_length)
if not defer_context_shaping:
messages = trim_for_context(messages, context_length)
_after_trim_messages = len(messages)
_after_trim_tokens = estimate_tokens(messages)
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
@@ -860,6 +806,7 @@ async def build_chat_context(
context_tokens_after_trim=_after_trim_tokens,
auto_opened_docs=auto_opened_docs,
uploaded_files=uploaded_files,
route_messages=route_messages,
)