diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b081d5f1c..6099e72fd 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -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, diff --git a/routes/email_routes.py b/routes/email_routes.py index 3c8e407bd..0d15f2573 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -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 []: diff --git a/routes/model_routes.py b/routes/model_routes.py index 600150a66..de8f884cb 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -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 diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index 71f260fa2..1bb8fc3af 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -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) diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py new file mode 100644 index 000000000..241ccf26b --- /dev/null +++ b/src/foreground_model_routing.py @@ -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) diff --git a/src/llm_core.py b/src/llm_core.py index 4dec32376..bb735cb3e 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -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. """ diff --git a/src/settings.py b/src/settings.py index 5836765f1..da08717d5 100644 --- a/src/settings.py +++ b/src/settings.py @@ -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": "", diff --git a/src/task_endpoint.py b/src/task_endpoint.py index b9c290d65..ae57a81f7 100644 --- a/src/task_endpoint.py +++ b/src/task_endpoint.py @@ -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 = [] diff --git a/static/index.html b/static/index.html index 8257660fe..d1a960188 100644 --- a/static/index.html +++ b/static/index.html @@ -1482,7 +1482,7 @@ -
+