mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 02:02:22 +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
@@ -22,6 +22,8 @@ from src.settings import (
|
||||
load_features as _load_features,
|
||||
save_features as _save_features,
|
||||
DEFAULT_SETTINGS,
|
||||
RETIRED_SETTING_KEYS,
|
||||
without_retired_settings,
|
||||
)
|
||||
from src.integrations import (
|
||||
load_integrations,
|
||||
@@ -689,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||
user = _get_current_user(request)
|
||||
settings = _load_settings()
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if user and auth_manager.is_admin(user):
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
@@ -709,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in RETIRED_SETTING_KEYS:
|
||||
continue
|
||||
if key not in body:
|
||||
continue
|
||||
val = body[key]
|
||||
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
val = max(lo, min(val, hi))
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return current
|
||||
return without_retired_settings(current)
|
||||
|
||||
# ---- Integrations CRUD ----
|
||||
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+561
-37
@@ -15,13 +15,28 @@ from pydantic import ValidationError
|
||||
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import ChatRequest
|
||||
from src.llm_core import llm_call_async, stream_llm, stream_llm_with_fallback
|
||||
from src.llm_core import (
|
||||
_normalize_http_status,
|
||||
llm_call_async,
|
||||
llm_call_async_with_route_fallback,
|
||||
stream_llm,
|
||||
stream_llm_with_fallback,
|
||||
)
|
||||
from src.agent_loop import stream_agent_loop
|
||||
from src import agent_runs
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
maybe_compact,
|
||||
trim_for_context,
|
||||
)
|
||||
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.foreground_model_routing import (
|
||||
build_foreground_model_candidates,
|
||||
build_foreground_route_descriptors,
|
||||
resolve_foreground_model_policy,
|
||||
)
|
||||
from src.session_search import search_session_messages
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
@@ -39,7 +54,9 @@ from routes.chat_helpers import (
|
||||
build_chat_context,
|
||||
save_assistant_response,
|
||||
run_post_response_tasks,
|
||||
accumulate_token_usage,
|
||||
clean_thinking_for_save,
|
||||
_allowed_models_for_request,
|
||||
_enforce_chat_privileges,
|
||||
)
|
||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||
@@ -57,6 +74,74 @@ logger = logging.getLogger(__name__)
|
||||
_active_streams: Dict[str, dict] = {}
|
||||
|
||||
|
||||
def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
"""Extract a provider status without retaining provider-supplied detail."""
|
||||
|
||||
try:
|
||||
for line in str(chunk or "").splitlines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
status = json.loads(line[6:]).get("status")
|
||||
return _normalize_http_status(status)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _chat_candidate_request_factory(
|
||||
messages,
|
||||
fallback_context_length: int = 0,
|
||||
*,
|
||||
session=None,
|
||||
owner: Optional[str] = None,
|
||||
):
|
||||
"""Shape one route-neutral Chat prompt for each candidate window."""
|
||||
|
||||
state = {
|
||||
"requests": {},
|
||||
"context_lengths": {},
|
||||
"trim_stats": {},
|
||||
"compactions": {},
|
||||
"was_compacted": {},
|
||||
}
|
||||
|
||||
async def factory(index, candidate_url, candidate_model, candidate_headers):
|
||||
compaction_state = {}
|
||||
candidate_messages, context_length, was_compacted = await maybe_compact(
|
||||
session,
|
||||
candidate_url,
|
||||
candidate_model,
|
||||
list(messages),
|
||||
candidate_headers,
|
||||
owner=owner,
|
||||
persist=False,
|
||||
compaction_state=compaction_state,
|
||||
)
|
||||
if not context_length:
|
||||
context_length = fallback_context_length
|
||||
request_messages = trim_for_context(candidate_messages, context_length)
|
||||
state["requests"][index] = request_messages
|
||||
state["context_lengths"][index] = context_length
|
||||
state["compactions"][index] = compaction_state
|
||||
state["was_compacted"][index] = was_compacted
|
||||
state["trim_stats"][index] = {
|
||||
"messages_before": len(messages),
|
||||
"messages_after": len(request_messages),
|
||||
"tokens_before": estimate_tokens(messages),
|
||||
"tokens_after": estimate_tokens(request_messages),
|
||||
}
|
||||
return {"messages": request_messages}
|
||||
|
||||
return factory, state
|
||||
|
||||
|
||||
def _candidate_index(candidates, actual_candidate) -> int:
|
||||
for index, candidate in enumerate(candidates):
|
||||
if candidate == actual_candidate:
|
||||
return index
|
||||
return 0
|
||||
|
||||
|
||||
def _stream_set(session_id: str, **fields) -> None:
|
||||
"""Update fields on the active-stream entry for `session_id`, or
|
||||
no-op if the entry has already been popped. Using .get() avoids a
|
||||
@@ -590,8 +675,8 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat (non-streaming)
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat", response_model=Dict[str, str])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
|
||||
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
|
||||
_set_user_time_from_request(request)
|
||||
|
||||
message = chat_request.message
|
||||
@@ -623,6 +708,8 @@ def setup_chat_routes(
|
||||
400,
|
||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
@@ -638,6 +725,11 @@ def setup_chat_routes(
|
||||
if memory_response:
|
||||
return {"response": memory_response}
|
||||
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
)
|
||||
|
||||
# Build shared context (preset, preprocess, preface, compact)
|
||||
ctx = await build_chat_context(
|
||||
sess, request, chat_handler, chat_processor,
|
||||
@@ -649,6 +741,7 @@ def setup_chat_routes(
|
||||
time_filter=time_filter,
|
||||
webhook_manager=webhook_manager,
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
)
|
||||
|
||||
# Research injection
|
||||
@@ -662,24 +755,88 @@ def setup_chat_routes(
|
||||
research_ctx = await research_handler.call_research_service(
|
||||
message, _r_ep, _r_model, llm_headers=_r_headers
|
||||
)
|
||||
ctx.messages.insert(
|
||||
len(ctx.preface),
|
||||
untrusted_context_message("research context", research_ctx),
|
||||
)
|
||||
research_message = untrusted_context_message("research context", research_ctx)
|
||||
ctx.messages.insert(len(ctx.preface), research_message)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, "route_messages", ctx.messages).insert(
|
||||
len(ctx.preface),
|
||||
research_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Research failed: {e}")
|
||||
|
||||
reply = await llm_call_async(
|
||||
foreground_candidates = build_foreground_model_candidates(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
ctx.messages,
|
||||
headers=sess.headers,
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
)
|
||||
route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id,
|
||||
)
|
||||
candidate_request_factory = None
|
||||
selected_context_length = getattr(ctx, "context_length", 0)
|
||||
candidate_request_state = {
|
||||
"context_lengths": {0: selected_context_length},
|
||||
"requests": {0: ctx.messages},
|
||||
"trim_stats": {},
|
||||
}
|
||||
request_messages = ctx.messages
|
||||
if foreground_policy.enabled:
|
||||
request_messages = getattr(ctx, "route_messages", ctx.messages)
|
||||
candidate_request_factory, candidate_request_state = _chat_candidate_request_factory(
|
||||
request_messages,
|
||||
selected_context_length,
|
||||
session=sess,
|
||||
owner=owner,
|
||||
)
|
||||
requested_model = sess.model
|
||||
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
|
||||
foreground_candidates,
|
||||
request_messages,
|
||||
fallback_statuses=foreground_policy.eligible_statuses,
|
||||
candidate_request_factory=candidate_request_factory,
|
||||
temperature=ctx.preset.temperature,
|
||||
max_tokens=ctx.preset.max_tokens,
|
||||
prompt_type=preset_id,
|
||||
session_id=session,
|
||||
)
|
||||
_clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model})
|
||||
actual_index = _candidate_index(foreground_candidates, actual_candidate)
|
||||
apply_compaction_state(
|
||||
sess,
|
||||
candidate_request_state.get("compactions", {}).get(actual_index),
|
||||
)
|
||||
requested_route = route_descriptors[0]
|
||||
actual_route = route_descriptors[actual_index]
|
||||
actual_trim = candidate_request_state.get("trim_stats", {}).get(actual_index, {})
|
||||
_clean_reply, _clean_md = clean_thinking_for_save(
|
||||
reply,
|
||||
{
|
||||
"model": actual_model,
|
||||
"requested_model": requested_model,
|
||||
"endpoint_id": actual_route.get("endpoint_id"),
|
||||
"endpoint_label": actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": requested_route.get("endpoint_label"),
|
||||
"context_length": candidate_request_state["context_lengths"].get(
|
||||
actual_index,
|
||||
selected_context_length,
|
||||
),
|
||||
"context_trimmed": bool(
|
||||
actual_trim
|
||||
and (
|
||||
actual_trim.get("messages_after") < actual_trim.get("messages_before")
|
||||
or actual_trim.get("tokens_after") < actual_trim.get("tokens_before")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
|
||||
|
||||
from core.database import update_session_last_accessed
|
||||
@@ -695,7 +852,15 @@ def setup_chat_routes(
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
)
|
||||
|
||||
return {"response": reply}
|
||||
return {
|
||||
"response": reply,
|
||||
"requested_model": requested_model,
|
||||
"model": actual_model,
|
||||
"requested_endpoint_id": requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": requested_route.get("endpoint_label"),
|
||||
"endpoint_id": actual_route.get("endpoint_id"),
|
||||
"endpoint_label": actual_route.get("endpoint_label"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat_stream
|
||||
@@ -724,6 +889,11 @@ def setup_chat_routes(
|
||||
use_research = form_data.get("use_research")
|
||||
time_filter = form_data.get("time_filter")
|
||||
preset_id = form_data.get("preset_id")
|
||||
selected_endpoint_id = str(
|
||||
form_data.get("selected_endpoint_id")
|
||||
or (body or {}).get("selected_endpoint_id")
|
||||
or ""
|
||||
).strip()
|
||||
# Issue #3229: API callers send JSON, not FormData. Read from the
|
||||
# JSON body as fallback so callers who send {"allow_bash": true}
|
||||
# actually get bash enabled.
|
||||
@@ -896,6 +1066,8 @@ def setup_chat_routes(
|
||||
400,
|
||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if (
|
||||
chat_mode == "chat"
|
||||
and isinstance(message, str)
|
||||
@@ -971,6 +1143,10 @@ def setup_chat_routes(
|
||||
last_user_message=message,
|
||||
)
|
||||
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
)
|
||||
|
||||
# Build shared context (stream path uses enhanced_message for context preface)
|
||||
ctx = await build_chat_context(
|
||||
@@ -993,6 +1169,7 @@ def setup_chat_routes(
|
||||
# index would be useless / unwanted noise.
|
||||
agent_mode=(chat_mode == "agent"),
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
)
|
||||
|
||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||
@@ -1292,6 +1469,8 @@ def setup_chat_routes(
|
||||
"what aspects matter most, are they comparing to something, what's their context "
|
||||
"(moving, traveling, curiosity). Be conversational. Keep it short."
|
||||
})
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, "route_messages", ctx.messages).insert(0, dict(ctx.messages[0]))
|
||||
_skip_research = True
|
||||
else:
|
||||
_skip_research = False
|
||||
@@ -1388,7 +1567,12 @@ def setup_chat_routes(
|
||||
_active_streams.pop(session, None)
|
||||
return
|
||||
|
||||
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
|
||||
context_source = (
|
||||
getattr(ctx, "route_messages", ctx.messages)
|
||||
if foreground_policy.enabled
|
||||
else ctx.messages
|
||||
)
|
||||
messages = _ensure_current_request_is_latest_user(context_source, message)
|
||||
|
||||
# Auto-compact notification
|
||||
if ctx.was_compacted:
|
||||
@@ -1400,25 +1584,56 @@ def setup_chat_routes(
|
||||
thinking_response = ""
|
||||
last_metrics = None
|
||||
|
||||
# Foreground Chat and Agent requests use one owner-aware policy
|
||||
# boundary. Legacy `default_model_fallbacks` data is not eligible.
|
||||
# Foreground Chat and Agent requests share one explicit owner-aware
|
||||
# policy. Strict mode is the default; legacy values are unrelated.
|
||||
_foreground_policy = foreground_policy
|
||||
_foreground_candidates = build_foreground_model_candidates(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
)
|
||||
_foreground_route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
selected_endpoint_id=selected_endpoint_id,
|
||||
)
|
||||
_chat_request_factory = None
|
||||
_selected_context_length = getattr(ctx, "context_length", 0)
|
||||
_chat_request_state = {
|
||||
"context_lengths": {0: _selected_context_length},
|
||||
"requests": {0: messages},
|
||||
"trim_stats": {},
|
||||
}
|
||||
if _foreground_policy.enabled:
|
||||
_chat_request_factory, _chat_request_state = _chat_candidate_request_factory(
|
||||
messages,
|
||||
_selected_context_length,
|
||||
session=sess,
|
||||
owner=_user,
|
||||
)
|
||||
|
||||
# Send model name early so the frontend can show it during streaming
|
||||
_model_suffix = "Research" if effective_do_research else None
|
||||
_model_info = {"type": "model_info", "model": sess.model}
|
||||
_selected_route = _foreground_route_descriptors[0]
|
||||
_model_info = {
|
||||
"type": "model_info",
|
||||
"model": sess.model,
|
||||
"endpoint_id": _selected_route.get("endpoint_id"),
|
||||
"endpoint_label": _selected_route.get("endpoint_label"),
|
||||
}
|
||||
if _model_suffix:
|
||||
_model_info["suffix"] = _model_suffix
|
||||
if ctx.preset.character_name:
|
||||
_model_info["character_name"] = ctx.preset.character_name
|
||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||
|
||||
if image_generation_session:
|
||||
_terminal_saved = False
|
||||
if _is_image_generation_session(sess, owner=_user):
|
||||
from src.settings import get_setting
|
||||
if tool_policy.blocks("generate_image"):
|
||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||
@@ -1521,6 +1736,16 @@ def setup_chat_routes(
|
||||
_answered_by = None # set if the selected model failed and a fallback answered
|
||||
_requested_model = sess.model
|
||||
_actual_model = None
|
||||
_requested_route = _foreground_route_descriptors[0]
|
||||
_actual_route = _requested_route
|
||||
_actual_candidate_index = 0
|
||||
_chat_terminal_saved = False
|
||||
def _commit_chat_compaction(candidate_index: int) -> bool:
|
||||
return apply_compaction_state(
|
||||
sess,
|
||||
_chat_request_state.get("compactions", {}).get(candidate_index),
|
||||
)
|
||||
|
||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||
try:
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
@@ -1536,11 +1761,21 @@ def setup_chat_routes(
|
||||
prompt_type=preset_id,
|
||||
tools=None,
|
||||
session_id=session,
|
||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
||||
candidate_request_factory=_chat_request_factory,
|
||||
candidate_route_descriptors=_foreground_route_descriptors,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
if "delta" in data:
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
# Reasoning tokens arrive flagged thinking:true.
|
||||
# Forward them so the client can show a thinking
|
||||
# indicator, but don't fold them into the saved
|
||||
@@ -1556,29 +1791,82 @@ def setup_chat_routes(
|
||||
# Forward the notice and remember the real model.
|
||||
_answered_by = data.get("answered_by") or _answered_by
|
||||
_actual_model = _actual_model or _answered_by
|
||||
_actual_candidate_index = data.get("candidate_index", 0)
|
||||
if not isinstance(_actual_candidate_index, int):
|
||||
_actual_candidate_index = 0
|
||||
if 0 <= _actual_candidate_index < len(_foreground_route_descriptors):
|
||||
_actual_route = _foreground_route_descriptors[_actual_candidate_index]
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
data["selected_model"] = data.get("selected_model") or _requested_model
|
||||
yield chunk
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "model_actual":
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
_actual_model = data.get("model") or _actual_model
|
||||
data["requested_model"] = _requested_model
|
||||
data["requested_endpoint_id"] = _requested_route.get("endpoint_id")
|
||||
data["requested_endpoint_label"] = _requested_route.get("endpoint_label")
|
||||
data["endpoint_id"] = _actual_route.get("endpoint_id")
|
||||
data["endpoint_label"] = _actual_route.get("endpoint_label")
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "usage":
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
last_metrics = data.get("data", {})
|
||||
_reported_model = last_metrics.get("model")
|
||||
last_metrics["requested_model"] = _requested_model
|
||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
|
||||
if ctx.context_trimmed:
|
||||
last_metrics["requested_endpoint_id"] = _requested_route.get("endpoint_id")
|
||||
last_metrics["requested_endpoint_label"] = _requested_route.get("endpoint_label")
|
||||
last_metrics["endpoint_id"] = _actual_route.get("endpoint_id")
|
||||
last_metrics["endpoint_label"] = _actual_route.get("endpoint_label")
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_route_trim = _chat_request_state.get("trim_stats", {}).get(
|
||||
_actual_candidate_index,
|
||||
{},
|
||||
)
|
||||
if _route_trim and (
|
||||
_route_trim.get("messages_after") < _route_trim.get("messages_before")
|
||||
or _route_trim.get("tokens_after") < _route_trim.get("tokens_before")
|
||||
):
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = _route_trim.get("messages_before")
|
||||
last_metrics["context_messages_after_trim"] = _route_trim.get("messages_after")
|
||||
last_metrics["context_tokens_before_trim"] = _route_trim.get("tokens_before")
|
||||
last_metrics["context_tokens_after_trim"] = _route_trim.get("tokens_after")
|
||||
elif ctx.context_trimmed:
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
|
||||
last_metrics["request_context_tokens"] = request_context_tokens
|
||||
if ctx.context_length and request_context_tokens:
|
||||
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
|
||||
if _actual_context_length and last_metrics.get("input_tokens"):
|
||||
pct = min(round((last_metrics["input_tokens"] / _actual_context_length) * 100, 1), 100.0)
|
||||
last_metrics["context_percent"] = pct
|
||||
last_metrics["context_length"] = ctx.context_length
|
||||
last_metrics["context_length"] = _actual_context_length
|
||||
# The frontend reads `tokens_per_second`; the raw usage event
|
||||
# carries the backend's true gen speed as `gen_tps` (llama.cpp
|
||||
# timings). Map it through so this direct-chat path shows real
|
||||
@@ -1593,17 +1881,121 @@ def setup_chat_routes(
|
||||
yield chunk
|
||||
elif chunk.startswith("event: error"):
|
||||
logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}")
|
||||
if (
|
||||
not _chat_terminal_saved
|
||||
and (full_response.strip() or thinking_response.strip())
|
||||
):
|
||||
_failure_status = _stream_failure_status(chunk)
|
||||
_failure_message = (
|
||||
f"Model request failed (HTTP {_failure_status})"
|
||||
if _failure_status is not None
|
||||
else "Model request failed"
|
||||
)
|
||||
_terminal_content = full_response.strip()
|
||||
_failure_note = f"[Response stopped: {_failure_message}]"
|
||||
_terminal_content = (
|
||||
f"{_terminal_content}\n\n{_failure_note}"
|
||||
if _terminal_content
|
||||
else _failure_note
|
||||
)
|
||||
_had_terminal_usage = bool(last_metrics)
|
||||
_terminal_metrics = dict(last_metrics or {})
|
||||
if not _had_terminal_usage:
|
||||
_actual_request_messages = _chat_request_state["requests"].get(
|
||||
_actual_candidate_index,
|
||||
messages,
|
||||
)
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_estimated_input = estimate_tokens(_actual_request_messages)
|
||||
_estimated_output = max(
|
||||
len(full_response + thinking_response) // 4,
|
||||
0,
|
||||
)
|
||||
_terminal_metrics.update({
|
||||
"input_tokens": _estimated_input,
|
||||
"output_tokens": _estimated_output,
|
||||
"total_tokens": _estimated_input + _estimated_output,
|
||||
"usage_source": "estimated",
|
||||
"response_time": round(time.time() - _chat_start, 2),
|
||||
"context_length": _actual_context_length,
|
||||
"context_percent": (
|
||||
min(
|
||||
round(
|
||||
(_estimated_input / _actual_context_length) * 100,
|
||||
1,
|
||||
),
|
||||
100.0,
|
||||
)
|
||||
if _actual_context_length
|
||||
else 0
|
||||
),
|
||||
})
|
||||
_terminal_metrics.update({
|
||||
"failed": True,
|
||||
"failure": {
|
||||
"status": _failure_status,
|
||||
"message": _failure_message,
|
||||
},
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
})
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
_terminal_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
if thinking_response.strip():
|
||||
_terminal_metrics["thinking"] = thinking_response.strip()
|
||||
_commit_chat_compaction(_actual_candidate_index)
|
||||
_saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager,
|
||||
session,
|
||||
_terminal_content,
|
||||
_terminal_metrics,
|
||||
character_name=ctx.preset.character_name,
|
||||
incognito=incognito,
|
||||
)
|
||||
accumulate_token_usage(session, _terminal_metrics)
|
||||
_chat_terminal_saved = True
|
||||
_stream_set(session, status="error")
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
yield f'data: {json.dumps({"type": "chat_terminal", "data": _terminal_metrics})}\n\n'
|
||||
yield chunk
|
||||
elif chunk.startswith("event: "):
|
||||
yield chunk
|
||||
elif chunk == "data: [DONE]\n\n":
|
||||
if _chat_terminal_saved:
|
||||
# Some providers append DONE after a terminal
|
||||
# error. The failed partial is already saved;
|
||||
# never re-save/post-process it as a success or
|
||||
# advertise successful completion to the client.
|
||||
continue
|
||||
# Generate fallback metrics if LLM didn't send usage
|
||||
if not last_metrics and full_response:
|
||||
_elapsed = time.time() - _chat_start
|
||||
_est_in = estimate_tokens(messages)
|
||||
_est_out = len(full_response) // 4
|
||||
_tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0
|
||||
_ctx_pct = min(round((_est_in / ctx.context_length) * 100, 1), 100.0) if ctx.context_length else 0
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_actual_request_messages = _chat_request_state["requests"].get(
|
||||
_actual_candidate_index,
|
||||
messages,
|
||||
)
|
||||
_est_in = estimate_tokens(_actual_request_messages)
|
||||
_ctx_pct = min(round((_est_in / _actual_context_length) * 100, 1), 100.0) if _actual_context_length else 0
|
||||
last_metrics = {
|
||||
"response_time": round(_elapsed, 2),
|
||||
"input_tokens": _est_in,
|
||||
@@ -1611,13 +2003,25 @@ def setup_chat_routes(
|
||||
"tokens_per_second": _tps,
|
||||
"request_context_tokens": _est_in,
|
||||
"context_percent": _ctx_pct,
|
||||
"context_length": ctx.context_length,
|
||||
"context_length": _actual_context_length,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"usage_source": "estimated",
|
||||
}
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
if full_response:
|
||||
_commit_chat_compaction(_actual_candidate_index)
|
||||
_metrics_to_save = dict(last_metrics or {})
|
||||
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
||||
_metrics_to_save["thinking"] = thinking_response.strip()
|
||||
@@ -1652,6 +2056,10 @@ def setup_chat_routes(
|
||||
"stopped": True,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
|
||||
@@ -1666,6 +2074,12 @@ def setup_chat_routes(
|
||||
_answered_by = None # set if the selected model failed and a fallback answered
|
||||
_requested_model = sess.model
|
||||
_actual_model = None
|
||||
_agent_requested_route = _foreground_route_descriptors[0]
|
||||
_agent_actual_endpoint_id = _agent_requested_route.get("endpoint_id")
|
||||
_agent_actual_endpoint_label = _agent_requested_route.get("endpoint_label")
|
||||
_agent_round_models = {1: _requested_model}
|
||||
_agent_round_endpoint_ids = {1: _agent_actual_endpoint_id}
|
||||
_agent_round_endpoint_labels = {1: _agent_actual_endpoint_label}
|
||||
try:
|
||||
from src.settings import get_setting
|
||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||
@@ -1703,19 +2117,24 @@ def setup_chat_routes(
|
||||
prompt_type=preset_id,
|
||||
max_tool_calls=_tool_budget,
|
||||
max_rounds=_max_rounds,
|
||||
context_length=ctx.context_length,
|
||||
context_length=_selected_context_length,
|
||||
active_document=active_doc,
|
||||
active_email=active_email_ctx,
|
||||
session_id=session,
|
||||
history_session=sess,
|
||||
disabled_tools=disabled_tools if disabled_tools else None,
|
||||
tool_policy=tool_policy,
|
||||
owner=_user,
|
||||
fallbacks=_foreground_candidates[1:],
|
||||
route_descriptors=_foreground_route_descriptors,
|
||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
forced_tools=_forced_tools,
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
@@ -1744,7 +2163,20 @@ def setup_chat_routes(
|
||||
"plan_update",
|
||||
):
|
||||
if data.get("type") == "agent_step":
|
||||
_agent_rounds = max(_agent_rounds, data.get("round", 1))
|
||||
_event_round = data.get("round", 1)
|
||||
_agent_rounds = max(_agent_rounds, _event_round)
|
||||
_agent_round_models.setdefault(
|
||||
_event_round,
|
||||
_actual_model or _answered_by or _requested_model,
|
||||
)
|
||||
_agent_round_endpoint_ids.setdefault(
|
||||
_event_round,
|
||||
_agent_actual_endpoint_id,
|
||||
)
|
||||
_agent_round_endpoint_labels.setdefault(
|
||||
_event_round,
|
||||
_agent_actual_endpoint_label,
|
||||
)
|
||||
elif data.get("type") == "tool_start":
|
||||
_agent_tool_calls += 1
|
||||
yield chunk
|
||||
@@ -1754,13 +2186,70 @@ def setup_chat_routes(
|
||||
# model so metrics reflect it, not the masked
|
||||
# selected model.
|
||||
_answered_by = data.get("answered_by") or _answered_by
|
||||
_actual_model = _actual_model or _answered_by
|
||||
_actual_model = _answered_by or _actual_model
|
||||
if "answered_by_endpoint_id" in data:
|
||||
_agent_actual_endpoint_id = data.get("answered_by_endpoint_id")
|
||||
if data.get("answered_by_endpoint_label"):
|
||||
_agent_actual_endpoint_label = data.get("answered_by_endpoint_label")
|
||||
_event_round = data.get("round") or max(_agent_rounds, 1)
|
||||
_agent_round_models[_event_round] = _answered_by or _requested_model
|
||||
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
|
||||
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
|
||||
data["selected_model"] = data.get("selected_model") or _requested_model
|
||||
yield chunk
|
||||
elif data.get("type") == "model_actual":
|
||||
_actual_model = data.get("model") or _actual_model
|
||||
if "endpoint_id" in data:
|
||||
_agent_actual_endpoint_id = data.get("endpoint_id")
|
||||
if data.get("endpoint_label"):
|
||||
_agent_actual_endpoint_label = data.get("endpoint_label")
|
||||
_event_round = data.get("round") or max(_agent_rounds, 1)
|
||||
_agent_round_models[_event_round] = _actual_model or _requested_model
|
||||
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
|
||||
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
|
||||
data["requested_model"] = _requested_model
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "agent_terminal":
|
||||
terminal_metadata = dict(data.get("data") or {})
|
||||
last_metrics = terminal_metadata
|
||||
failure = terminal_metadata.get("failure") or {}
|
||||
failure_status = _normalize_http_status(
|
||||
failure.get("status")
|
||||
)
|
||||
failure_message = (
|
||||
f"Model request failed (HTTP {failure_status})"
|
||||
if failure_status is not None
|
||||
else "Model request failed"
|
||||
)
|
||||
terminal_metadata["failure"] = {
|
||||
"status": failure_status,
|
||||
"message": failure_message,
|
||||
}
|
||||
terminal_content = full_response.strip()
|
||||
failure_note = f"[Agent stopped: {failure_message}]"
|
||||
if terminal_content:
|
||||
terminal_content = f"{terminal_content}\n\n{failure_note}"
|
||||
else:
|
||||
terminal_content = failure_note
|
||||
if not _terminal_saved:
|
||||
_saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager,
|
||||
session,
|
||||
terminal_content,
|
||||
terminal_metadata,
|
||||
character_name=ctx.preset.character_name,
|
||||
web_sources=web_sources,
|
||||
rag_sources=ctx.rag_sources,
|
||||
used_memories=ctx.used_memories,
|
||||
incognito=incognito,
|
||||
)
|
||||
_terminal_saved = True
|
||||
accumulate_token_usage(session, terminal_metadata)
|
||||
_stream_set(session, status="error")
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
yield chunk
|
||||
elif data.get("type") == "metrics":
|
||||
last_metrics = data.get("data", {})
|
||||
_reported_model = last_metrics.get("model")
|
||||
@@ -1772,7 +2261,16 @@ def setup_chat_routes(
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
_metrics_event = {"type": "metrics", "data": last_metrics}
|
||||
# Inline teacher escalation marks its
|
||||
# recursively emitted events at the SSE
|
||||
# envelope. Preserve that non-secret marker
|
||||
# when normalizing metrics so the browser's
|
||||
# replay-stable ledger keeps primary and
|
||||
# teacher segments distinct.
|
||||
if data.get("teacher") is True:
|
||||
_metrics_event["teacher"] = True
|
||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
elif chunk.startswith("event: "):
|
||||
@@ -1824,6 +2322,22 @@ def setup_chat_routes(
|
||||
"stopped": True,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _agent_actual_endpoint_id,
|
||||
"endpoint_label": _agent_actual_endpoint_label,
|
||||
"requested_endpoint_id": _agent_requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _agent_requested_route.get("endpoint_label"),
|
||||
"round_models": [
|
||||
_agent_round_models.get(i, _actual_model or _requested_model)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
"round_endpoint_ids": [
|
||||
_agent_round_endpoint_ids.get(i)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
"round_endpoint_labels": [
|
||||
_agent_round_endpoint_labels.get(i)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
||||
@@ -1866,8 +2380,12 @@ def setup_chat_routes(
|
||||
if compare_mode:
|
||||
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
|
||||
|
||||
agent_runs.start(session, _safe_stream())
|
||||
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream")
|
||||
_detached_run = agent_runs.start(session, _safe_stream())
|
||||
return StreamingResponse(
|
||||
agent_runs.subscribe(session, _detached_run),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Odysseus-Run-Id": _detached_run.run_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/chat/resume — reconnect to a detached run that's still going
|
||||
@@ -1876,9 +2394,14 @@ def setup_chat_routes(
|
||||
@router.get("/api/chat/resume/{session_id}")
|
||||
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
||||
_verify_session_owner(request, session_id)
|
||||
if not agent_runs.is_active(session_id):
|
||||
_active_run = agent_runs.get_active_run(session_id)
|
||||
if _active_run is None:
|
||||
raise HTTPException(404, "No active run for this session")
|
||||
return StreamingResponse(agent_runs.subscribe(session_id), media_type="text/event-stream")
|
||||
return StreamingResponse(
|
||||
agent_runs.subscribe(session_id, _active_run),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Odysseus-Run-Id": _active_run.run_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
|
||||
@@ -1887,7 +2410,8 @@ def setup_chat_routes(
|
||||
@router.post("/api/chat/stop/{session_id}")
|
||||
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
_verify_session_owner(request, session_id)
|
||||
stopped = agent_runs.stop(session_id)
|
||||
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
||||
stopped = agent_runs.stop(session_id, _expected_run_id)
|
||||
return {"stopped": stopped}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
+3
-10
@@ -5004,7 +5004,6 @@ def setup_email_routes():
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
|
||||
@@ -5066,8 +5065,6 @@ def setup_email_routes():
|
||||
pass
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
|
||||
@@ -5327,13 +5324,11 @@ 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 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.
|
||||
# user's Utility / Default endpoints and active Utility fallback
|
||||
# chain. Dedupe by url+model so we don't retry the same endpoint.
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.endpoint_resolver import (
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
_seen = set()
|
||||
_candidates = []
|
||||
@@ -5358,11 +5353,9 @@ def setup_email_routes():
|
||||
_add(_d_url, _d_model, _d_headers)
|
||||
except Exception:
|
||||
pass
|
||||
# Active Utility fallbacks, then the retired default hook.
|
||||
# Active Utility fallbacks last.
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
_messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_msg},
|
||||
|
||||
@@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
|
||||
}
|
||||
|
||||
_ENDPOINT_FALLBACK_FIELDS = {
|
||||
"foreground_model_fallbacks": "Foreground Model Fallbacks",
|
||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||
}
|
||||
@@ -180,7 +181,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
||||
if not isinstance(all_prefs, dict):
|
||||
return 0
|
||||
users = all_prefs.get("_users")
|
||||
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
|
||||
# A mixed store can contain auth-disabled foreground policy at the root
|
||||
# alongside named-owner preferences. Both are active namespaces; legacy
|
||||
# `default_model_fallbacks` remains untouched by the field allowlist.
|
||||
pref_sets = [all_prefs]
|
||||
if isinstance(users, dict):
|
||||
pref_sets.extend(users.values())
|
||||
cleared_users = 0
|
||||
for prefs in pref_sets:
|
||||
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
||||
|
||||
+51
-11
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
|
||||
from src.constants import USER_PREFS_FILE
|
||||
|
||||
PREFS_FILE = USER_PREFS_FILE
|
||||
_FOREGROUND_POLICY_KEYS = (
|
||||
"foreground_fallback_enabled",
|
||||
"foreground_model_fallbacks",
|
||||
)
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -26,14 +30,27 @@ def _save(prefs):
|
||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||
"""Load preferences for a specific user."""
|
||||
all_prefs = _load()
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
if user is None:
|
||||
# Auth disabled — return first user's prefs for backward compat
|
||||
users = all_prefs["_users"]
|
||||
return dict(next(iter(users.values()), {}))
|
||||
return dict(all_prefs["_users"].get(user, {}))
|
||||
# Legacy flat format — return as-is
|
||||
return dict(all_prefs)
|
||||
prefs = dict(next(iter(users.values()), {}))
|
||||
# Foreground fallback consent is never borrowed from a named
|
||||
# owner. Auth-disabled operation has a separate flat/root opt-in
|
||||
# that remains inert when authentication is enabled again.
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
prefs.pop(key, None)
|
||||
if key in all_prefs:
|
||||
prefs[key] = all_prefs[key]
|
||||
return prefs
|
||||
prefs = users.get(user, {})
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
# A legacy flat store belongs only to auth-disabled single-user mode.
|
||||
# Copying it into the first named user's new `_users` record during an
|
||||
# auth transition would silently transfer another user's preferences and,
|
||||
# critically, foreground fallback consent. Named owners therefore start
|
||||
# with an empty record and must write their own preferences explicitly.
|
||||
return dict(all_prefs) if user is None else {}
|
||||
|
||||
|
||||
def _save_for_user(user: Optional[str], prefs: dict):
|
||||
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
|
||||
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
||||
# other user's preferences. Instead write back into the same (first)
|
||||
# slot _load_for_user(None) reads from, preserving the others.
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs["_users"]
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
first_key = next(iter(users), None)
|
||||
if first_key is not None:
|
||||
users[first_key] = prefs
|
||||
existing_named = users.get(first_key)
|
||||
existing_named = (
|
||||
dict(existing_named)
|
||||
if isinstance(existing_named, dict)
|
||||
else {}
|
||||
)
|
||||
named_foreground = {
|
||||
key: existing_named[key]
|
||||
for key in _FOREGROUND_POLICY_KEYS
|
||||
if key in existing_named
|
||||
}
|
||||
users[first_key] = {
|
||||
key: value
|
||||
for key, value in prefs.items()
|
||||
if key not in _FOREGROUND_POLICY_KEYS
|
||||
}
|
||||
users[first_key].update(named_foreground)
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
if key in prefs:
|
||||
all_prefs[key] = prefs[key]
|
||||
_save(all_prefs)
|
||||
return
|
||||
_save(prefs)
|
||||
return
|
||||
if "_users" not in all_prefs:
|
||||
all_prefs = {"_users": {}}
|
||||
if not isinstance(all_prefs.get("_users"), dict):
|
||||
# Preserve the flat single-user object as inert legacy data while
|
||||
# creating the first named-owner namespace. In particular, historical
|
||||
# fallback values must not be deleted or copied into the new owner.
|
||||
all_prefs = dict(all_prefs)
|
||||
all_prefs["_users"] = {}
|
||||
all_prefs["_users"][user] = prefs
|
||||
_save(all_prefs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user