mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-05 12:42:20 +01:00
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:
co-authored by
RaresKeY
StressTestor
parent
b52296471b
commit
c4369305f0
+47
-100
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user