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.
This commit is contained in:
RaresKeY 2026-07-21 20:48:47 +00:00
commit 2e9eca3839
43 changed files with 9383 additions and 1070 deletions

View file

@ -1404,8 +1404,25 @@ def _migrate_assign_legacy_owner():
with open(prefs_path, "r", encoding="utf-8") as f:
prefs = _json.load(f)
if "_users" not in prefs and prefs:
# Flat format → nest under admin user
new_prefs = {"_users": {admin_user: prefs}}
# Flat format → nest ordinary preferences under the admin
# user. Foreground fallback is an explicit per-owner opt-in,
# so auth-disabled consent must remain inert at the flat root
# rather than becoming consent for the first named owner.
foreground_keys = {
"foreground_fallback_enabled",
"foreground_model_fallbacks",
}
named_prefs = {
key: value
for key, value in prefs.items()
if key not in foreground_keys
}
new_prefs = {
key: prefs[key]
for key in foreground_keys
if key in prefs
}
new_prefs["_users"] = {admin_user: named_prefs}
with open(prefs_path, "w", encoding="utf-8") as f:
_json.dump(new_prefs, f, indent=2)
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")

View file

@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
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
except Exception as exc:
@ -1843,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
utility_fallbacks = resolve_utility_fallback_candidates() or []
for cand in utility_fallbacks:
_add(*cand)
try:
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
except TypeError:
chat_fallbacks = resolve_chat_fallback_candidates() or []
for cand in chat_fallbacks:
_add(*cand)
if not candidates:
return {"error": "No LLM endpoint configured for AI reply"}

View file

@ -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,
@ -637,7 +639,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)
@ -657,6 +659,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]
@ -669,7 +673,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 ----

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,
)

View file

@ -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", "").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,87 @@ 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,
)
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 +851,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
@ -896,6 +1060,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", "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if (
chat_mode == "chat"
and isinstance(message, str)
@ -971,6 +1137,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 +1163,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 +1463,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 +1561,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 +1578,55 @@ 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,
)
_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 +1729,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 +1754,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 +1784,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 +1874,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 +1996,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 +2049,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 +2067,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 +2110,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 +2156,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 +2179,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 +2254,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 +2315,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 +2373,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 +2387,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 +2403,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}
# ------------------------------------------------------------------ #

View file

@ -4886,7 +4886,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
@ -4948,8 +4947,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"}
@ -5209,13 +5206,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 = []
@ -5240,11 +5235,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},

View file

@ -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):

View file

@ -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():
@ -32,14 +36,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):
@ -51,17 +68,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)

File diff suppressed because it is too large Load diff

View file

@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
import asyncio
import json
import logging
import uuid
from typing import AsyncGenerator, Dict, Optional
logger = logging.getLogger(__name__)
class _Run:
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
def __init__(self) -> None:
self.buffer: list = [] # ordered SSE event strings (replay log)
@ -31,6 +32,9 @@ class _Run:
self.status: str = "running" # running | done | error | stopped
self.task: Optional[asyncio.Task] = None
self.evict_task: Optional[asyncio.Task] = None
# Stable across every subscription/replay of this exact detached run.
# The browser uses it to make local cost accounting replay-idempotent.
self.run_id: str = uuid.uuid4().hex
_RUNS: Dict[str, _Run] = {}
@ -53,13 +57,15 @@ def _publish(run: _Run, ev: str) -> None:
pass
def _schedule_evict(session_id: str) -> None:
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
Identity-checked so a run that gets replaced/reused is never evicted by a
stale timer."""
run = _RUNS.get(session_id)
if run is None:
return
if expected_run is not None and run is not expected_run:
return
if run.evict_task and not run.evict_task.done():
run.evict_task.cancel()
@ -85,25 +91,42 @@ def get_status(session_id: str) -> Optional[str]:
return r.status if r else None
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
def get_run_id(session_id: str) -> Optional[str]:
"""Return the opaque identity of the current detached run, if present."""
r = _RUNS.get(session_id)
return r.run_id if r else None
def get_active_run(session_id: str) -> Optional[_Run]:
"""Return the exact active run currently registered for a session."""
r = _RUNS.get(session_id)
return r if r and r.status == "running" else None
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
prev_task: Optional[asyncio.Task] = None) -> None:
"""Pull every event from the wrapped generator into the run buffer, fanning
each out to live subscribers. Runs to completion regardless of subscribers."""
run = _RUNS.get(session_id)
if run is None:
return
subscribers_woken = False
def _wake_subscribers() -> None:
nonlocal subscribers_woken
if subscribers_woken:
return
subscribers_woken = True
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
# If this run replaced an in-flight one (rapid double-send), wait for that
# one to fully finish first. Its CancelledError handler calls aclose(), which
# persists its partial response — letting it complete before we start writing
# keeps the two runs' session saves sequential instead of interleaved.
if prev_task is not None and not prev_task.done():
try:
await asyncio.wait({prev_task})
except asyncio.CancelledError:
raise # our own cancellation — propagate
except Exception:
pass
try:
if prev_task is not None and not prev_task.done():
await asyncio.wait({prev_task})
async for ev in agen:
_publish(run, ev)
if run.status == "running":
@ -116,6 +139,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
await agen.aclose()
except Exception:
pass
# A rapid third replacement can cancel this task while it is still
# waiting for its predecessor. Close this run's subscribers promptly,
# but keep the task alive until the predecessor finishes so the next
# run still observes the transitive session-save ordering barrier.
_wake_subscribers()
if prev_task is not None and not prev_task.done():
try:
await asyncio.shield(prev_task)
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
run.status = "error"
@ -127,15 +160,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
_publish(run, "data: [DONE]\n\n")
finally:
# Wake every subscriber with the end sentinel so their SSE closes.
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
_wake_subscribers()
# Run is terminal — arm the grace timer so it (and its buffer) is
# eventually freed even if nobody ever reconnects. subscribe() cancels
# this on connect and re-arms on disconnect.
_schedule_evict(session_id)
_schedule_evict(session_id, run)
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
@ -151,14 +180,23 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
prev.evict_task.cancel()
run = _Run()
_RUNS[session_id] = run
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
return run
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
async def subscribe(
session_id: str,
expected_run: Optional[_Run] = None,
) -> AsyncGenerator[str, None]:
"""Replay the run's buffer from the start, then stream live until it ends.
Safe to call repeatedly (reconnect) and from multiple clients at once."""
run = _RUNS.get(session_id)
Safe to call repeatedly (reconnect) and from multiple clients at once.
``expected_run`` binds a lazy StreamingResponse body to the same run whose
identity was put in its response headers. Without that binding, a rapid
replacement between response construction and body iteration could replay
the replacement run under the prior run's identity.
"""
run = expected_run or _RUNS.get(session_id)
if run is None:
return
q: asyncio.Queue = asyncio.Queue()
@ -201,12 +239,19 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
# Last subscriber gone on a finished run — (re)arm eviction so the
# buffer doesn't linger indefinitely.
if not run.subscribers and run.status != "running":
_schedule_evict(session_id)
_schedule_evict(session_id, run)
def stop(session_id: str) -> bool:
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
"""Cancel the matching in-flight run (which saves its partial output).
A stale browser may issue Stop after another tab has replaced the session's
run. Once the caller knows its opaque run identity, fail closed rather than
cancelling that newer run.
"""
run = _RUNS.get(session_id)
if not expected_run_id or run is None or run.run_id != expected_run_id:
return False
if run and run.task and not run.task.done():
run.task.cancel()
return True

View file

@ -510,7 +510,12 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
from src.settings import (
DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
load_settings,
save_settings,
)
# Secrets/credentials the agent must NOT write: kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
@ -562,6 +567,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return k2
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
@ -624,14 +632,18 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if action == "list":
s = load_settings()
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
shown = {
k: _mask(k, v)
for k, v in s.items()
if _is_managed_key(k) and not isinstance(v, dict)
}
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
elif action == "get":
key = _resolve(args.get("key", ""))
if not key:
return {"error": "key is required", "exit_code": 1}
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
@ -642,11 +654,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if not raw:
return {"error": "key is required", "exit_code": 1}
key = _resolve(raw)
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# Structured settings (dicts/lists like keybinds or vision fallbacks)
# have no safe scalar coercion; _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
@ -675,7 +687,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
elif action == "delete" or action == "reset":
key = _resolve(args.get("key", ""))
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}

View file

@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
if essential_system:
sys_text = essential_system[0].get("content", "")
if len(sys_text) > 2000:
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
truncated_system = dict(essential_system[0])
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
essential_system[0] = truncated_system
trimmed = essential_system + convo_msgs
if estimate_tokens(trimmed) <= budget:
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
@ -325,6 +327,9 @@ async def maybe_compact(
messages: List[Dict],
headers: Optional[Dict] = None,
owner: Optional[str] = None,
*,
persist: bool = True,
compaction_state: Optional[Dict[str, Any]] = None,
) -> tuple:
"""Check context usage and compact if above threshold.
@ -416,7 +421,17 @@ async def maybe_compact(
# offset — session.history INCLUDES the system messages, but
# split_point is indexed against convo_msgs which does NOT. Without
# this, the slice drops the leading system message(s).
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
if compaction_state is not None:
compaction_state.update({
"split_point": split_point,
"summary": summary,
"system_msg_count": len(system_msgs),
"applied": False,
})
if persist:
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
if compaction_state is not None:
compaction_state["applied"] = True
new_used = estimate_tokens(compacted)
logger.info(
@ -427,6 +442,51 @@ async def maybe_compact(
return compacted, context_length, True
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
"""Persist a route-specific compaction after that route commits output.
Candidate prompts may be compacted speculatively while an explicit
foreground fallback chain is being tried. Persisting at construction time
would let an unavailable route rewrite history before another route answers,
so callers hold this small plan and apply only the winning route's plan.
"""
state = compaction_state if isinstance(compaction_state, dict) else None
if not state or state.get("applied"):
return False
summary = state.get("summary")
split_point = state.get("split_point")
system_msg_count = state.get("system_msg_count", 0)
if not isinstance(summary, str) or not isinstance(split_point, int):
return False
_update_session_history(
session,
split_point,
summary,
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
)
state["applied"] = True
return True
def apply_compaction_state_for_session(
session_id: Optional[str],
compaction_state: Optional[Dict[str, Any]],
) -> bool:
"""Resolve an in-memory session and apply a deferred compaction plan."""
if not session_id:
return False
try:
from core.models import get_session_manager_instance
manager = get_session_manager_instance()
session = manager.get_session(session_id) if manager else None
except Exception:
session = None
return apply_compaction_state(session, compaction_state) if session else False
def _update_session_history(session, split_point: int, summary: str,
system_msg_count: int = 0):
"""Update the in-memory session history after compaction.

View file

@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
"""
import json
import ipaddress
import logging
import socket
import subprocess
@ -27,6 +28,50 @@ _NON_CHAT_MODEL = (
)
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
"""Return whether token cost should be tracked for a concrete route.
This is intentionally a non-secret route classification. It mirrors the
frontend's local/subscription exclusions without exposing endpoint URLs to
message metadata.
"""
try:
parsed = urlparse(url or "")
host = (parsed.hostname or "").lower().rstrip(".")
path = (parsed.path or "").rstrip("/")
except Exception:
return False
if not host:
return False
if host == "chatgpt.com" and (
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
):
return False
kind = str(endpoint_kind or "auto").strip().lower()
if kind == "local":
return False
if kind in {"api", "proxy"}:
return True
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
return False
if "." not in host:
return False
try:
ip = ipaddress.ip_address(host)
local_networks = (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("100.64.0.0/10"),
)
if ip.is_loopback or any(ip in network for network in local_networks):
return False
except ValueError:
pass
return True
def _first_chat_model(models) -> Optional[str]:
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
for m in (models or []):
@ -396,10 +441,14 @@ def resolve_endpoint(
db.close()
def resolve_endpoint_by_id(
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
def _resolve_endpoint_by_id_with_descriptor(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
Returns None if the endpoint doesn't exist or is disabled. Used to turn
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
@ -426,15 +475,34 @@ def resolve_endpoint_by_id(
chat_url = build_chat_url(base)
headers = build_headers(api_key, base)
m = (model or "").strip()
# Drop a model the user disabled on the endpoint, then pick the first
# enabled chat model rather than a hidden one.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
enabled_models = _endpoint_enabled_models(ep)
if require_exact_model:
# Explicit foreground fallback entries are concrete choices. A
# hidden or known-missing model must disable the entry instead of
# silently substituting another model from the endpoint.
if not m or m in _endpoint_hidden_models(ep):
return None
if enabled_models and m not in enabled_models:
return None
else:
# Legacy Utility/Vision chains retain their model-repair behavior.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(enabled_models) or ""
if not m:
return None
return chat_url, m, headers
return (
(chat_url, m, headers),
{
"endpoint_id": ep.id,
"endpoint_label": getattr(ep, "name", None) or ep.id,
"endpoint_cost_tracked": endpoint_cost_tracked(
chat_url,
getattr(ep, "endpoint_kind", None),
),
},
)
except Exception as e:
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
return None
@ -442,11 +510,72 @@ def resolve_endpoint_by_id(
db.close()
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
"""Compatibility shim for the retired default-chat fallback chain."""
def resolve_endpoint_by_id(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
del owner
return []
resolved = _resolve_endpoint_by_id_with_descriptor(
ep_id,
model,
owner=owner,
require_exact_model=require_exact_model,
)
return resolved[0] if resolved else None
def resolve_route_descriptor(
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> dict:
"""Return the visible endpoint identity for an already-resolved route.
Headers are compared only inside the process so two endpoints using the
same provider URL/model but different credentials remain distinguishable.
No credential material is returned or logged.
"""
if not endpoint_url or not model:
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
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)
expected = (endpoint_url.rstrip("/"), model, headers or {})
for ep in q.all():
resolved = _resolve_endpoint_by_id_with_descriptor(
ep.id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
continue
candidate, descriptor = resolved
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
if actual == expected:
return descriptor
except Exception as e:
logger.debug("Could not identify selected endpoint route: %s", e)
finally:
db.close()
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
@ -460,17 +589,62 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
out = []
try:
from src.settings import get_user_setting, load_settings
settings = load_settings()
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
except Exception:
return out
for entry in chain:
return []
return resolve_fallback_entries(chain, owner=owner)
def resolve_fallback_entries(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
out = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
if resolved:
resolved = resolve_endpoint_by_id(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if resolved and resolved not in out:
out.append(resolved)
return out
def resolve_fallback_entries_with_descriptors(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered entries while retaining safe endpoint provenance."""
out = []
seen = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
resolved = _resolve_endpoint_by_id_with_descriptor(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if not resolved:
continue
candidate, descriptor = resolved
if any(candidate == prior for prior in seen):
continue
seen.append(candidate)
out.append((candidate, descriptor))
return out

View file

@ -1,22 +1,139 @@
"""Foreground Chat and Agent model-routing policy.
"""Explicit 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 dataclasses import dataclass
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
from typing import Any, Dict, Optional
from src.endpoint_resolver import (
endpoint_cost_tracked,
resolve_fallback_entries,
resolve_fallback_entries_with_descriptors,
resolve_route_descriptor,
)
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
})
MAX_FOREGROUND_FALLBACKS = 10
@dataclass(frozen=True)
class ForegroundModelPolicy:
"""Resolved per-user foreground fallback policy."""
enabled: bool = False
fallback_candidates: Tuple[tuple, ...] = ()
fallback_descriptors: Tuple[dict, ...] = ()
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
fallback_on_empty: bool = False
def _load_policy_preferences(owner: Optional[str]) -> dict:
"""Load only preferences that explicitly belong to ``owner``.
The generic preferences loader intentionally treats a legacy flat store as
the single-user preferences object. That compatibility must not cross an
authentication transition: once a named owner is present, foreground
fallback consent exists only in an actual ``_users[owner]`` dictionary.
"""
from routes import prefs_routes
if owner is None:
prefs = prefs_routes._load_for_user(None)
return dict(prefs) if isinstance(prefs, dict) else {}
raw = prefs_routes._load()
users = raw.get("_users") if isinstance(raw, dict) else None
if not isinstance(users, dict):
return {}
prefs = users.get(owner)
return dict(prefs) if isinstance(prefs, dict) else {}
def resolve_foreground_model_policy(
owner: Optional[str] = None,
allowed_models: Optional[Collection[str]] = None,
) -> ForegroundModelPolicy:
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
The policy is stored in user preferences even when authentication is
disabled. Historical ``default_model_fallbacks`` values are deliberately
unrelated and are never read or migrated.
"""
try:
prefs = _load_policy_preferences(owner)
except Exception:
return ForegroundModelPolicy()
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
return ForegroundModelPolicy()
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
if not isinstance(entries, list) or not entries:
return ForegroundModelPolicy()
entries = entries[:MAX_FOREGROUND_FALLBACKS]
if allowed_models is not None:
allowed = frozenset(allowed_models)
entries = [
entry for entry in entries
if (
isinstance(entry, dict)
and isinstance(entry.get("model"), str)
and entry.get("model") in allowed
)
]
if not entries:
return ForegroundModelPolicy()
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
# Preserve the long-standing resolver seam used by downstream tests and
# integrations. Production uses the descriptor-aware resolver below.
compatibility_candidates = resolve_fallback_entries(
entries,
owner=owner,
require_exact_model=True,
)
resolved_routes = [
(
candidate,
{
"endpoint_id": entries[index].get("endpoint_id"),
"endpoint_label": entries[index].get("endpoint_id") or "Fallback route",
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
},
)
for index, candidate in enumerate(compatibility_candidates)
if index < len(entries)
]
else:
resolved_routes = resolve_fallback_entries_with_descriptors(
entries,
owner=owner,
require_exact_model=True,
)
candidates = [candidate for candidate, _descriptor in resolved_routes]
if not candidates:
return ForegroundModelPolicy()
return ForegroundModelPolicy(
enabled=True,
fallback_candidates=tuple(candidates),
fallback_descriptors=tuple(
dict(descriptor) for _candidate, descriptor in resolved_routes
),
)
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
"""Return fallback candidates for a foreground Chat or Agent request.
"""Return only candidates explicitly enabled by the current user."""
Foreground routing is strict, so no alternate endpoint/model is eligible.
``owner`` is accepted to keep this policy boundary owner-aware.
"""
del owner
return []
return list(resolve_foreground_model_policy(owner).fallback_candidates)
def build_foreground_model_candidates(
@ -24,8 +141,39 @@ def build_foreground_model_candidates(
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
) -> list:
"""Build the ordered candidate list for a foreground request."""
policy = policy or resolve_foreground_model_policy(owner)
primary = (endpoint_url, model, headers or {})
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
candidates = [primary]
for candidate in policy.fallback_candidates:
if candidate not in candidates:
candidates.append(candidate)
return candidates
def build_foreground_route_descriptors(
endpoint_url: str,
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
) -> list:
"""Build safe route metadata parallel to foreground candidates."""
policy = policy or resolve_foreground_model_policy(owner)
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
descriptors = [selected]
for candidate, descriptor in zip(
policy.fallback_candidates,
policy.fallback_descriptors,
):
if candidate in candidates:
continue
candidates.append(candidate)
descriptors.append(dict(descriptor))
return descriptors

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
logger = logging.getLogger(__name__)
# Keys retained in the raw settings store for compatibility and rollback, but
# deliberately unavailable through generic settings APIs or agent tools. They
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
# loss; callers that present or mutate settings should use this set as a
# tombstone boundary.
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
# (every chat, every preprocess); without this it re-parses the JSON each call.
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
@ -197,6 +204,17 @@ DEFAULT_SETTINGS = {
},
}
def without_retired_settings(settings: dict) -> dict:
"""Return a shallow copy suitable for generic settings interfaces."""
if not isinstance(settings, dict):
return {}
return {
key: value
for key, value in settings.items()
if key not in RETIRED_SETTING_KEYS
}
DEFAULT_FEATURES = {
"web_search": True,
"web_fetch": True,
@ -269,7 +287,7 @@ _PER_USER_KEYS = {
# Default chat endpoint / model — without per-user resolution every new
# account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open.
"default_endpoint_id", "default_model", "default_model_fallbacks",
"default_endpoint_id", "default_model",
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
"research_endpoint_id", "research_model",
}

View file

@ -1,7 +1,6 @@
"""Shared resolver for background-task AI endpoints."""
from src.endpoint_resolver import (
resolve_chat_fallback_candidates,
resolve_endpoint,
resolve_utility_fallback_candidates,
)
@ -32,7 +31,6 @@ def resolve_task_candidates(
2. Utility endpoint/model
3. Default endpoint/model
4. Utility fallback chain
5. Retired default-fallback compatibility hook (currently empty)
"""
candidates = []
@ -49,9 +47,6 @@ def resolve_task_candidates(
_append(*resolve_endpoint("default", owner=owner))
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
_append(url, model, headers)
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
_append(url, model, headers)
return candidates

View file

@ -1482,13 +1482,6 @@
<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>
</div>
<div class="settings-row" style="align-items:flex-start;" hidden>
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
</div>
</div>
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>

View file

@ -22,7 +22,13 @@ import codeRunnerModule from './codeRunner.js';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260722emailfastindex1';
import createResearchSynapse from './researchSynapse.js';
import { createStreamRenderer } from './streamingRenderer.js';
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js';
import {
applyModelMetricsState,
applyModelRouteEventState,
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
@ -385,13 +391,27 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const tsSpan = roleEl.querySelector('.role-timestamp');
const req = requestedModel || actualModel || '';
const actual = actualModel || requestedModel || '';
let label = _modelRouteLabel(req, actual);
let label = _modelRouteLabel(
req,
actual,
opts.requestedEndpointLabel,
opts.actualEndpointLabel,
opts.requestedEndpointId,
opts.actualEndpointId,
);
if (opts.suffix) label += ' (' + opts.suffix + ')';
if (opts.characterName) label = opts.characterName;
roleEl.textContent = label + ' ';
_applyModelColor(roleEl, actual || req);
if (req && actual && !_sameModelName(req, actual)) {
roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : '');
const endpointChanged = Boolean(
opts.requestedEndpointId
&& opts.actualEndpointId
&& opts.requestedEndpointId !== opts.actualEndpointId
);
if (req && actual && (!_sameModelName(req, actual) || endpointChanged)) {
roleEl.title = req + ' -> ' + actual
+ (endpointChanged ? ' (' + opts.requestedEndpointLabel + ' -> ' + opts.actualEndpointLabel + ')' : '')
+ (opts.reason ? ': ' + opts.reason : '');
} else if (!opts.reason) {
roleEl.removeAttribute('title');
}
@ -561,6 +581,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt }
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
const _terminalSavedStreams = new Set(); // sessionId -> canonical terminal event seen by active reader
const _streamRunIds = new Map(); // sessionId -> opaque identity of the exact detached run
let _streamSessionId = null; // Session ID for the currently active reader loop
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
let _webLockRelease = null; // Function to release the Web Lock held during streaming
@ -573,30 +595,23 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_resumingStreams.has(sessionId);
}
function _getForegroundStreamState() {
try {
const sid = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
return sid ? (_activeStreams.get(sid) || null) : null;
} catch (_) {
return null;
}
/** Stable cost identity for one logical metrics segment within a run. */
function _metricsCostRecordId(runId, event) {
if (!runId) return '';
return `${runId}:${event && event.teacher ? 'teacher' : 'primary'}`;
}
function _syncForegroundStreamGlobals() {
const active = _getForegroundStreamState();
isStreaming = !!active;
currentAbort = active ? active.abortCtrl : null;
currentHolder = active ? active.holder : null;
_setForegroundChatBusy(!!active || !!_sendInFlight);
return active;
}
function _touchStreamActivity(sessionId) {
const now = Date.now();
_lastReaderActivity = now;
const active = sessionId ? _activeStreams.get(sessionId) : null;
if (active) active.lastActivity = now;
return now;
/** Stop only the exact detached run whose identity this browser observed. */
function _stopExactRun(sessionId) {
if (!sessionId) return false;
const runId = _streamRunIds.get(sessionId);
if (!runId) return false;
fetch(`/api/chat/stop/${encodeURIComponent(sessionId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Odysseus-Run-Id': runId },
}).catch(() => {});
return true;
}
// Sources box builder and toggleSources are now in chatRenderer.js
@ -1341,6 +1356,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Capture session ID for background stream detection
const streamSessionId = sessionModule.getCurrentSessionId();
_streamSessionId = streamSessionId;
_terminalSavedStreams.delete(streamSessionId);
_streamRunIds.delete(streamSessionId);
const streamQuery = msg;
_touchStreamActivity(streamSessionId);
@ -1360,6 +1377,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let _thinkOpen = false;
let holder = null;
let finalMeta = null;
let _canonicalTerminalSaved = false;
let spinner = null;
let timedOut = false;
let processingProbeTimer = null;
@ -1729,14 +1747,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (!abortCtrl.signal.aborted) {
timedOut = true;
abortCtrl._reason = 'timeout';
try {
if (streamSessionId) {
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {});
}
} catch (_) {}
try { _stopExactRun(streamSessionId); } catch (_) {}
abortCtrl.abort();
}
}, timeoutMs);
@ -1882,6 +1893,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
enableResearchBtn();
return;
}
const streamRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (streamRunId) _streamRunIds.set(streamSessionId, streamRunId);
// Mark the chat log busy while streaming so screen readers wait for the
// settled response instead of announcing every token. Cleared in finally.
@ -1953,9 +1966,17 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const requested = holder?._requestedModel || metaS?.model || modelName;
const actual = holder?._actualModel || requested;
newRole.textContent = _modelRouteLabel(requested, actual) || '';
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
const requested = newWrap._requestedModel;
const actual = newWrap._actualModel;
newRole.textContent = _modelRouteLabel(
requested,
actual,
newWrap._requestedEndpointLabel,
newWrap._actualEndpointLabel,
newWrap._requestedEndpointId,
newWrap._actualEndpointId,
) || '';
_applyModelColor(newRole, actual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
@ -2185,6 +2206,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let _nextIsError = false;
let _streamSawDone = false;
let _streamTerminalError = null;
let _firstVisibleOutputSeen = false;
const markFirstVisibleOutput = () => {
if (_firstVisibleOutputSeen) return;
@ -2310,10 +2332,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Handle SSE error events (e.g. HTTP 404 from provider)
if (_nextIsError || json.status >= 400) {
_nextIsError = false;
const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
console.error('Stream error:', errMsg);
_streamTerminalError = createTerminalStreamError(json);
console.error('Stream error:', _streamTerminalError.message);
if (spinner && spinner.element) spinner.destroy();
typewriterInto(roundHolder.querySelector('.body'), errMsg);
break;
}
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
@ -2757,18 +2778,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
6000
);
continue;
} else if (json.type === 'model_fallback') {
// Model went offline — switched to fallback
var _fbData = json.data || {};
uiModule.showToast(
`Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`,
5000
);
// Update the model picker to reflect the new model
if (sessionModule && sessionModule.updateModelPicker) {
sessionModule.updateModelPicker();
}
continue;
} else if (json.type === 'model_info') {
// Update role label with model name as soon as we know it
if (!_isBg && holder) {
@ -2776,6 +2785,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (roleEl) {
holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
holder._requestedEndpointId = json.requested_endpoint_id || json.endpoint_id || holder._requestedEndpointId || null;
holder._requestedEndpointLabel = json.requested_endpoint_label || json.endpoint_label || holder._requestedEndpointLabel || 'Selected route';
holder._actualEndpointId = json.endpoint_id || holder._actualEndpointId || holder._requestedEndpointId;
holder._actualEndpointLabel = json.endpoint_label || holder._actualEndpointLabel || holder._requestedEndpointLabel;
if (json.suffix) holder._roleSuffix = json.suffix;
// Prepend character name if sent by server or set locally
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
@ -2783,6 +2796,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
requestedEndpointId: holder._requestedEndpointId,
requestedEndpointLabel: holder._requestedEndpointLabel,
actualEndpointId: holder._actualEndpointId,
actualEndpointLabel: holder._actualEndpointLabel,
});
}
}
@ -2793,9 +2810,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (!_isBg) {
var _selM = _shortModel(json.selected_model || '');
var _ansM = _shortModel(json.answered_by || '');
uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
if (holder) {
var _rEl = holder.querySelector('.role');
uiModule.showToast('Fallback: ' + _selM + ' failed — answered by ' + _ansM, 6000);
var _fallbackHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
if (_fallbackHolder) {
var _rEl = _fallbackHolder.querySelector('.role');
if (_rEl) {
var _tsS = _rEl.querySelector('.role-timestamp');
_rEl.textContent = _ansM + ' (fallback) ';
@ -2803,13 +2821,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
_applyModelColor(_rEl, json.answered_by);
if (_tsS) _rEl.appendChild(_tsS);
holder._requestedModel = json.selected_model || holder._requestedModel || modelName;
const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel);
holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel);
_setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
_setRoleModelLabel(_rEl, _fallbackHolder._requestedModel, _fallbackHolder._actualModel, {
suffix: _fallbackHolder._roleSuffix,
characterName: _fallbackHolder._characterName,
reason: json.reason,
requestedEndpointId: _fallbackHolder._requestedEndpointId,
requestedEndpointLabel: _fallbackHolder._requestedEndpointLabel,
actualEndpointId: _fallbackHolder._actualEndpointId,
actualEndpointLabel: _fallbackHolder._actualEndpointLabel,
});
}
}
@ -2853,12 +2872,15 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
}
} else if (json.type === 'model_actual') {
if (!_isBg && holder) {
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
_setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
if (!_isBg) {
var _modelHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
if (_modelHolder) _setRoleModelLabel(_modelHolder.querySelector('.role'), _modelHolder._requestedModel, _modelHolder._actualModel, {
suffix: _modelHolder._roleSuffix,
characterName: _modelHolder._characterName,
requestedEndpointId: _modelHolder._requestedEndpointId,
requestedEndpointLabel: _modelHolder._requestedEndpointLabel,
actualEndpointId: _modelHolder._actualEndpointId,
actualEndpointLabel: _modelHolder._actualEndpointLabel,
});
}
} else if (json.type === 'attachments') {
@ -2944,15 +2966,60 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
uiModule.showToast(`Context trimmed for this model${detail}`);
}
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
// The backend persisted canonical partial output, sanitized
// failure metadata, and actual-route provenance before this
// event. The terminal catch below reloads that exact record.
_canonicalTerminalSaved = true;
_terminalSavedStreams.add(streamSessionId);
const priorMetrics = metrics;
metrics = json.data || metrics;
if (metrics && streamRunId) {
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
}
// Direct Chat may have emitted provider usage before its
// terminal event. Carry that already-recorded state onto the
// canonical terminal metadata instead of billing it twice.
if (priorMetrics && priorMetrics._costRecorded && metrics) {
metrics._costRecorded = true;
}
if (_isBg) {
var bgTerminal = _backgroundStreams.get(streamSessionId);
if (bgTerminal) {
if (
bgTerminal.metrics
&& bgTerminal.metrics._costRecorded
&& metrics
) {
metrics._costRecorded = true;
}
bgTerminal.metrics = metrics;
bgTerminal.status = 'completed';
if (metrics) {
chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);
}
}
continue;
}
if (holder && metrics) {
applyModelMetricsState(metrics, holder, roundHolder, modelName);
const terminalMetricsTarget = _metricsTargetForTurn();
if (terminalMetricsTarget) displayMetrics(terminalMetricsTarget, metrics);
}
} else if (json.type === 'metrics') {
metrics = json.data;
if (metrics && streamRunId) {
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
}
if (!_isBg && holder && metrics) {
holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName;
holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel;
applyModelMetricsState(metrics, holder, roundHolder, modelName);
}
if (_isBg) {
var bgM = _backgroundStreams.get(streamSessionId);
if (bgM) bgM.metrics = json.data;
if (bgM) {
bgM.metrics = json.data;
chatRenderer.recordSessionMetricsCost(bgM.metrics, streamSessionId);
}
continue;
}
if (metrics) {
@ -3341,9 +3408,17 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const _roundRequested = holder?._requestedModel || metaS?.model;
const _roundActual = holder?._actualModel || _roundRequested;
newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || '';
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
const _roundRequested = newWrap._requestedModel;
const _roundActual = newWrap._actualModel;
newRole.textContent = _modelRouteLabel(
_roundRequested,
_roundActual,
newWrap._requestedEndpointLabel,
newWrap._actualEndpointLabel,
newWrap._requestedEndpointId,
newWrap._actualEndpointId,
) || '';
_applyModelColor(newRole, _roundActual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
@ -3449,6 +3524,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
if (_streamTerminalError) {
throw _streamTerminalError;
}
if (!_streamSawDone) {
throw new Error('Stream closed before completion');
}
@ -3467,15 +3545,25 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (!_isBgFinal) {
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
const _finalModelHolder = applyModelMetricsState(
metrics,
holder,
roundHolder,
finalMeta?.model || modelName,
) || holder;
const _finalActualModel = _finalModelHolder._actualModel || finalMeta?.model;
const _finalRequestedModel = _finalModelHolder._requestedModel || finalMeta?.model || _finalActualModel;
// Prepend character name if set
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
const roleEl = holder.querySelector('.role');
const roleEl = _finalModelHolder.querySelector('.role');
if (roleEl) {
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
suffix: holder._roleSuffix,
characterName: _charNameFinal || holder._characterName,
suffix: _finalModelHolder._roleSuffix,
characterName: _charNameFinal || _finalModelHolder._characterName,
requestedEndpointId: _finalModelHolder._requestedEndpointId,
requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel,
actualEndpointId: _finalModelHolder._actualEndpointId,
actualEndpointLabel: _finalModelHolder._actualEndpointLabel,
});
}
holder.dataset.raw = accumulated;
@ -3747,7 +3835,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Error happened while backgrounded — update map, don't touch DOM
console.error('Background stream error:', err);
var bgErr = _backgroundStreams.get(streamSessionId);
if (bgErr && bgErr.status === 'completed') {
if (bgErr && (
bgErr.status === 'completed' || _terminalSavedStreams.has(streamSessionId)
)) {
bgErr.status = 'completed';
// [DONE] was already processed — this error is benign (e.g. reader.read() after close)
// Don't override the completed status; just ensure the completed dot stays
if (sessionModule && sessionModule.clearStreaming) {
@ -3907,7 +3998,30 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// cap. Only auto-recover from connection-class failures; deterministic
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
// instead of burning the nudge budget on a guaranteed-to-fail retry.
if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) {
if (!(isRecoverableStreamError(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) {
if (err.terminalStreamError) {
if (_canonicalTerminalSaved || accumulated.trim()) {
// Let this stream's finally block clear foreground state before
// reselecting; otherwise selectSession would detach the already
// terminal reader and leave a stale background-stream marker.
setTimeout(async () => {
if (sessionModule.getCurrentSessionId() === streamSessionId) {
await sessionModule.selectSession(streamSessionId, { showLoading: false });
} else {
await sessionModule.loadSessions();
}
}, 0);
} else {
const terminalBody = roundHolder && roundHolder.querySelector('.body');
if (terminalBody) {
const terminalNote = document.createElement('div');
terminalNote.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
terminalNote.textContent = `[Error: ${err.message}]`;
terminalBody.appendChild(terminalNote);
}
}
return;
}
const errorHolder = document.querySelector('.msg-ai:last-of-type .body');
if (errorHolder) {
let errMsg = `Error: ${err.message}`;
@ -3921,6 +4035,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
} finally {
if (_streamSessionId === streamSessionId) _streamSessionId = null;
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
@ -3939,6 +4054,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Only reset UI state if still on the stream's session and was never backgrounded
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
_terminalSavedStreams.delete(streamSessionId);
if (!_isBgFinally) {
// Reset button to idle state
@ -4047,29 +4163,20 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
if (_sid) {
fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
_stopExactRun(_sid);
}
} catch (_) {}
}
}
// ── Stall watchdog ──────────────────────────────────────────────
// Auto-recover a turn whose stream died (connection drop) or went silent:
// preserve the partial, then re-submit a completion handshake by reusing the
// existing continue/resume path. Returns false at the cap so the caller can
// surface the failure instead of nudging forever.
// Auto-recover a turn whose browser stream died by reconnecting to the exact
// detached server run. Returns false at the cap so the caller can surface
// the failure instead of retrying forever.
// Only auto-recover from connection-class failures (the genuine "silently
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
// parse failures — will fail identically on retry, so surfacing them
// immediately is both more honest and avoids wasting the nudge budget.
function _isRecoverableStreamErr(err) {
if (!err) return false;
if (err.name === 'TypeError') return true; // fetch/reader network failure
const m = (err.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m);
}
function _tryAutoRecover(holder, accumulated, sessionId) {
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
_autoNudges++;
@ -4080,28 +4187,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated));
} catch (_) {}
}
_pendingContinue = holder || null; // merge the continuation into the same bubble
_hideUserBubble = true; // no user bubble for the handshake
_autoContinuePending = true; // don't reset the counter on this submit
const _abandon = () => { // clear the pending flags so they can't
_pendingContinue = null; // leak into whatever chat is now open
_hideUserBubble = false;
_autoContinuePending = false;
};
// Defer so the stream's finally resets state first — otherwise the send
// button is still in "stop" mode and clicking it would toggle, not send.
setTimeout(() => {
// The server run is detached and keeps its exact pinned model/tool state.
// Reconnect to that run instead of submitting a new user turn, which would
// cancel it, retry the selected model, and risk duplicating side effects.
setTimeout(async () => {
// The stream that died may not be the chat the user is now looking at —
// never inject the recovery handshake into the wrong conversation.
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
const msgInput = uiModule.el('message');
const sb = document.querySelector('.send-btn');
if (!msgInput || !sb) { _abandon(); return; }
const tail = (accumulated || '').slice(-400);
msgInput.value = tail
? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.`
: `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`;
sb.click();
// never attach the recovery reader to the wrong conversation.
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return;
const resumed = await resumeStream(sessionId, holder || null);
if (!resumed && holder && holder.isConnected) {
const body = holder.querySelector('.body');
if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.');
}
}, 200);
return true;
}
@ -4257,9 +4354,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
abortCurrentRequest();
return;
}
// Store background stream state
const terminalSaved = _terminalSavedStreams.has(sessionId);
// Store background stream state. A canonical terminal event can precede
// its SSE error event; preserve completion if the user switches sessions
// during that gap instead of creating a fresh running/error marker.
_backgroundStreams.set(sessionId, {
status: 'running',
status: terminalSaved ? 'completed' : 'running',
accumulated: currentAccumulated,
sourcesHtml: '',
findingsData: null,
@ -4268,8 +4368,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
metrics: null,
});
// Mark session with pulsing dot in sidebar
if (sessionModule && sessionModule.markStreaming) {
if (!terminalSaved && sessionModule && sessionModule.markStreaming) {
sessionModule.markStreaming(sessionId);
} else if (terminalSaved && sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(sessionId);
}
// Clear local state WITHOUT aborting the fetch
if (currentAbort === active.abortCtrl) currentAbort = null;
@ -4296,7 +4398,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
* reloaded from the DB so its full render stays faithful. Returns true if it
* attached, false to let the caller fall back to spinner+poll.
*/
export async function resumeStream(sessionId) {
export async function resumeStream(sessionId, replaceHolder = null) {
if (!sessionId) return false;
if (hasActiveStream(sessionId)) return false;
@ -4307,9 +4409,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
return false;
}
if (!res.ok || !res.body) return false;
const resumeRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (resumeRunId) _streamRunIds.set(sessionId, resumeRunId);
const box = document.getElementById('chat-history');
if (!box) return false;
if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove();
// Block duplicate re-attach attempts while this reader is live. A dedicated
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
@ -4324,6 +4429,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
' <span class="role-timestamp">' + roleTs + '</span></div>' +
'<div class="body"><div class="stream-content"></div></div>';
holder._requestedModel = meta && meta.model;
holder._actualModel = holder._requestedModel;
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
const contentDiv = holder.querySelector('.stream-content');
box.appendChild(holder);
@ -4341,6 +4448,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let gotDelta = false;
let leftSession = false;
let metricsData = null;
let replayError = null;
let canonicalTerminalSeen = false;
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
// full canonical render, which is rebuilt from the saved DB record on reload.
// Plain text replies can be finalized in place without a reload.
@ -4377,6 +4486,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const eventIsError = part.split('\n').some(l => l.trim() === 'event: error');
if (eventIsError) rich = true;
const line = part.split('\n').find(l => l.startsWith('data: '));
if (!line) continue;
const payload = line.slice(6);
@ -4386,7 +4497,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
let json;
try { json = JSON.parse(payload); } catch (_) { continue; }
if (json.delta) {
if (eventIsError) {
replayError = createTerminalStreamError(json);
} else if (json.delta) {
roundText += json.delta;
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
docFenceOpened = true;
@ -4402,6 +4515,64 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
} else if (json.type === 'metrics') {
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) {
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
}
} else if (json.type === 'fallback') {
// Replay can attach after the selected route has already failed.
// Reflect the fallback immediately, then reload the canonical
// multi-round record when the detached run completes.
rich = true;
const fallbackHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (fallbackHolder) {
_setRoleModelLabel(
fallbackHolder.querySelector('.role'),
fallbackHolder._requestedModel,
fallbackHolder._actualModel,
{
reason: json.reason,
requestedEndpointId: fallbackHolder._requestedEndpointId,
requestedEndpointLabel: fallbackHolder._requestedEndpointLabel,
actualEndpointId: fallbackHolder._actualEndpointId,
actualEndpointLabel: fallbackHolder._actualEndpointLabel,
},
);
}
uiModule.showToast(
'Fallback: ' + _shortModel(json.selected_model || '') + ' failed — answered by ' +
_shortModel(json.answered_by || ''),
6000,
);
} else if (json.type === 'model_actual') {
rich = true;
const modelHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (modelHolder) {
_setRoleModelLabel(
modelHolder.querySelector('.role'),
modelHolder._requestedModel,
modelHolder._actualModel,
{
requestedEndpointId: modelHolder._requestedEndpointId,
requestedEndpointLabel: modelHolder._requestedEndpointLabel,
actualEndpointId: modelHolder._actualEndpointId,
actualEndpointLabel: modelHolder._actualEndpointLabel,
},
);
}
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
// The server has already persisted canonical partial content plus
// a sanitized failure note and actual route provenance. Do not
// finalize replayed deltas as a successful local-only answer.
rich = true;
canonicalTerminalSeen = true;
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) displayMetrics(holder, metricsData);
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
json.type === 'tool_progress' || json.type === 'agent_step' ||
json.type === 'web_sources' || json.type === 'rag_sources' ||
@ -4412,7 +4583,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
} catch (e) {
// Network drop or parse failure: fall through to the reload below.
// Network drop or parse failure: fall through to the canonical reload.
rich = true;
}
cleanup();
@ -4422,6 +4594,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
const onThisSession = sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() === sessionId;
// A failure before substantive output has no persisted assistant record to
// recover through a canonical reload. Keep its sanitized provider/request
// error visible in the replay holder instead of deleting the only evidence.
if (onThisSession && replayError && !canonicalTerminalSeen) {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
errorDiv.textContent = `[Error: ${replayError.message}]`;
contentDiv.appendChild(errorDiv);
uiModule.scrollHistory();
return true;
}
// Plain text reply: finalize in place. Replace the live bubble with a
// canonical single message (markdown + footer actions + metrics) using the
// same renderer history does. No history refetch, no end-of-stream flicker.
@ -4438,6 +4622,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// reload from the DB for the full canonical render.
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
if (holder.parentNode) holder.remove();
if (metricsData) {
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
}
if (onThisSession) sessionModule.selectSession(sessionId);
else sessionModule.loadSessions();
return true;

View file

@ -0,0 +1,104 @@
/** Select and update the response holder for a route-provenance event. */
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
const target = event && event.round && roundHolder ? roundHolder : holder;
if (!target) return null;
target._requestedModel = (
event.requested_model
|| event.selected_model
|| target._requestedModel
|| defaultModel
);
target._actualModel = (
event.model
|| event.answered_by
|| target._actualModel
|| target._requestedModel
);
const hasEndpointRoute = Boolean(
event.requested_endpoint_id
|| event.selected_endpoint_id
|| event.endpoint_id
|| event.answered_by_endpoint_id
|| event.requested_endpoint_label
|| event.selected_endpoint_label
|| event.endpoint_label
|| event.answered_by_endpoint_label
|| target._requestedEndpointLabel
);
if (hasEndpointRoute) {
target._requestedEndpointId = (
event.requested_endpoint_id
|| event.selected_endpoint_id
|| target._requestedEndpointId
|| null
);
target._requestedEndpointLabel = (
event.requested_endpoint_label
|| event.selected_endpoint_label
|| target._requestedEndpointLabel
|| 'Selected route'
);
target._actualEndpointId = (
event.endpoint_id
|| event.answered_by_endpoint_id
|| target._actualEndpointId
|| target._requestedEndpointId
|| null
);
target._actualEndpointLabel = (
event.endpoint_label
|| event.answered_by_endpoint_label
|| target._actualEndpointLabel
|| target._requestedEndpointLabel
);
}
return target;
}
/** Copy the active route into the bubble created for the next Agent round. */
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
if (!target) return null;
const source = roundHolder || holder;
target._requestedModel = source?._requestedModel || defaultModel;
target._actualModel = source?._actualModel || target._requestedModel;
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
target._requestedEndpointId = source?._requestedEndpointId || null;
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
}
return target;
}
/** Apply final/metrics provenance to the active round, not the first bubble. */
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
const target = roundHolder || holder;
if (!target || !metrics) return target || null;
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
const roundModel = roundHolder && roundModels.length
? roundModels[roundModels.length - 1]
: null;
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
if (
metrics.requested_endpoint_label
|| metrics.endpoint_label
|| roundEndpointLabels.length
|| target._requestedEndpointLabel
) {
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
target._actualEndpointId = hasRoundEndpointId
? roundEndpointIds[roundEndpointIds.length - 1]
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
target._actualEndpointLabel = hasRoundEndpointLabel
? roundEndpointLabels[roundEndpointLabels.length - 1]
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
}
return target;
}

View file

@ -612,10 +612,36 @@ export function sameModelName(left, right) {
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
}
export function modelRouteLabel(requestedModel, actualModel) {
function shortEndpointLabel(label) {
const value = modelValue(label);
if (!value) return '';
return value.length > 18 ? value.slice(0, 17) + '…' : value;
}
export function modelRouteLabel(
requestedModel,
actualModel,
requestedEndpointLabel = '',
actualEndpointLabel = '',
requestedEndpointId = '',
actualEndpointId = '',
) {
const requested = modelValue(requestedModel);
const actual = modelValue(actualModel) || requested;
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
const routeChanged = Boolean(
actualRoute
&& requestedRoute
&& actualRoute !== requestedRoute
);
if (!requested || sameModelName(requested, actual)) {
const model = shortModel(actual || requested);
if (!routeChanged) return model;
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
return model + ' (' + from + ' -> ' + to + ')';
}
return shortModel(requested) + ' -> ' + shortModel(actual);
}
@ -626,10 +652,24 @@ export function replyModelPair(modelName, metadata) {
if (actualFromMeta || requestedFromMeta) {
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
const requested = requestedFromMeta || actual;
return { requestedModel: requested, actualModel: actual };
return {
requestedModel: requested,
actualModel: actual,
requestedEndpointId: meta.requested_endpoint_id || null,
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
actualEndpointId: meta.endpoint_id || null,
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
};
}
const fallback = modelValue(modelName);
return { requestedModel: fallback, actualModel: fallback };
return {
requestedModel: fallback,
actualModel: fallback,
requestedEndpointId: null,
requestedEndpointLabel: 'Selected route',
actualEndpointId: null,
actualEndpointLabel: 'Selected route',
};
}
/**
@ -821,12 +861,50 @@ export function isCostTrackedEndpoint(url) {
}
/** Cost for the current turn, returning null for non-billable endpoints. */
function _billableCost(model, inputTokens, outputTokens) {
const url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(url)) return null;
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
// Foreground fallback can answer on a different endpoint than the session's
// selected route. Prefer the backend's non-secret actual-route
// classification; retain the selected-endpoint check for older history.
if (endpointCostTracked === false) return null;
const selectedUrl = selectedEndpointUrl === undefined
? _currentEndpointUrl()
: selectedEndpointUrl;
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
return null;
}
return getModelCost(model, inputTokens, outputTokens);
}
/** Sum cost using the route/model that produced each Agent round. */
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
if (!buckets.length) {
return _billableCost(
model,
inputTokens,
outputTokens,
metrics.endpoint_cost_tracked,
selectedEndpointUrl,
);
}
let total = 0;
let hasPricedUsage = false;
for (const bucket of buckets) {
if (!bucket || typeof bucket !== 'object') continue;
const bucketCost = _billableCost(
bucket.model || model,
Number(bucket.input_tokens) || 0,
Number(bucket.output_tokens) || 0,
bucket.endpoint_cost_tracked,
selectedEndpointUrl,
);
if (bucketCost === null) continue;
total += bucketCost;
hasPricedUsage = true;
}
return hasPricedUsage ? total : null;
}
export function getImageCost(model, quality, size) {
if (!model) return null;
const m = model.toLowerCase();
@ -841,6 +919,8 @@ export function getImageCost(model, quality, size) {
/* ── Session cost helpers ─────────────────────────────────────────── */
const _COST_KEY = 'ody-session-cost';
const _COST_RUNS_KEY = 'ody-session-cost-runs';
const _MAX_COST_RUNS_PER_SESSION = 256;
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) {
@ -848,7 +928,14 @@ export function getSessionCost(sessionId) {
if (!sid) return 0;
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
return costs[sid] || 0;
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? Object.values(runCosts[sid])
: [];
return (costs[sid] || 0) + recordedRuns.reduce(
(total, value) => total + (Number(value) || 0),
0,
);
} catch (_e) { return 0; }
}
@ -860,6 +947,9 @@ export function resetSessionCost(sessionId) {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
delete runCosts[sid];
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
@ -868,21 +958,8 @@ export function resetSessionCost(sessionId) {
export function updateSessionCostUI() {
const el = document.getElementById('session-cost-display');
if (!el) return;
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
// cloud-rate calculation may have left in localStorage for this session.
const _url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(_url)) {
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (sid && getSessionCost(sid) > 0) {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
el.style.display = 'none';
return;
}
// The ledger records billable work already performed in this session. A
// selected local endpoint does not erase cost from a paid fallback route.
const cost = getSessionCost();
if (cost > 0) {
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
@ -892,6 +969,61 @@ export function updateSessionCostUI() {
}
}
/** Record one metrics payload in a session ledger at most once. */
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
if (!metrics || typeof metrics !== 'object') return null;
const cost = _metricsBillableCost(
metrics,
metrics.model || 'Unknown',
metrics.input_tokens || 0,
metrics.output_tokens || 0,
selectedEndpointUrl,
);
if (metrics._fromHistory) return cost;
const sid = sessionId || (
window.sessionModule && window.sessionModule.getCurrentSessionId()
);
if (!sid || cost === null) return cost;
const runId = typeof metrics._costRecordId === 'string'
? metrics._costRecordId.trim()
: '';
if (metrics._costRecorded && !runId) return cost;
metrics._costRecorded = true;
if (runId) {
try {
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? runCosts[sid]
: {};
// Assigning by detached-run identity is replay-idempotent even when a
// refresh produces a fresh metrics object or two tabs race to write it.
sessionRuns[runId] = cost;
const entries = Object.entries(sessionRuns);
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + overflow.reduce(
(total, entry) => total + (Number(entry[1]) || 0),
0,
);
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
}
runCosts[sid] = sessionRuns;
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
} else {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (currentSid === sid) updateSessionCostUI();
return cost;
}
/** Create a timestamp span for role labels.
* Pass an ISO string / Date / epoch-ms to render the message's own time
* (used when replaying history). Falls back to "now" when no value is given. */
@ -1871,23 +2003,19 @@ export function displayMetrics(messageElement, metrics) {
const isReal = metrics.usage_source === 'real';
const ctxPct = metrics.context_percent;
const model = metrics.model || 'Unknown';
const cost = _billableCost(model, inputTokens, outputTokens);
const cost = _metricsBillableCost(
metrics,
model,
inputTokens,
outputTokens,
);
// Nothing useful to show — bail out (only if ALL metrics are missing)
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
// Accumulate session cost (only on fresh metrics, not history reload)
if (!metrics._fromHistory) {
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (_sid && cost !== null) {
try {
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
_costs[_sid] = (_costs[_sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
}
// Rendering can occur when metrics arrive and again after [DONE]. The
// ledger mutation is idempotent for that shared payload.
recordSessionMetricsCost(metrics);
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
@ -2304,9 +2432,19 @@ export function addMessage(role, content, modelName, metadata) {
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
// --- Agent multi-bubble reconstruction from saved metadata ---
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
if (
role === 'assistant'
&& metadata
&& (
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
)
) {
const roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events;
const roundModels = metadata.round_models || [];
const roundEndpointIds = metadata.round_endpoint_ids || [];
const roundEndpointLabels = metadata.round_endpoint_labels || [];
const toolEvents = metadata.tool_events || [];
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
@ -2319,7 +2457,8 @@ export function addMessage(role, content, modelName, metadata) {
toolsByRound[r].push(ev);
}
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
for (let r = 0; r < maxRound; r++) {
const roundNum = r + 1;
@ -2331,10 +2470,31 @@ export function addMessage(role, content, modelName, metadata) {
const roleEl = document.createElement('div');
roleEl.className = 'role';
const pair = replyModelPair(modelName, metadata);
const contModel = pair.actualModel || pair.requestedModel;
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
roleEl.title = pair.requestedModel + ' -> ' + contModel;
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
const contEndpointId = r < roundEndpointIds.length
? roundEndpointIds[r]
: pair.actualEndpointId;
const contEndpointLabel = r < roundEndpointLabels.length
? roundEndpointLabels[r]
: pair.actualEndpointLabel;
roleEl.textContent = modelRouteLabel(
pair.requestedModel,
contModel,
pair.requestedEndpointLabel,
contEndpointLabel,
pair.requestedEndpointId,
contEndpointId,
);
if (
pair.requestedModel
&& contModel
&& (
!sameModelName(pair.requestedModel, contModel)
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
)
) {
roleEl.title = pair.requestedModel + ' -> ' + contModel
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
}
applyModelColor(roleEl, contModel);
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
@ -2489,7 +2649,14 @@ export function addMessage(role, content, modelName, metadata) {
const isCompacted = metadata?.compacted;
const replyModels = replyModelPair(modelName, metadata);
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
replyModels.requestedModel,
resolvedModel,
replyModels.requestedEndpointLabel,
replyModels.actualEndpointLabel,
replyModels.requestedEndpointId,
replyModels.actualEndpointId,
);
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
_roleText += ' (Research)';
}
@ -2500,8 +2667,14 @@ export function addMessage(role, content, modelName, metadata) {
}
r.textContent = _roleText;
if (role !== 'user') {
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
const endpointChanged = Boolean(
replyModels.requestedEndpointId
&& replyModels.actualEndpointId
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
);
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
}
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
r.appendChild(roleTimestamp(metadata?.timestamp));
@ -2785,6 +2958,7 @@ const chatRenderer = {
getSessionCost,
resetSessionCost,
updateSessionCostUI,
recordSessionMetricsCost,
roleTimestamp,
stripToolBlocks,
copyMessageText,

View file

@ -0,0 +1,23 @@
/** Build a terminal stream error while preserving provider-supplied text. */
export function createTerminalStreamError(payload = {}) {
const rawError = payload.error;
const message = (
payload.text
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|| `Error ${payload.status || 'unknown'}`
);
const error = new Error(message);
error.name = 'TerminalStreamError';
error.terminalStreamError = true;
error.status = payload.status;
return error;
}
/** Only connection-class stream failures are safe to resubmit automatically. */
export function isRecoverableStreamError(error) {
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
if (error.name === 'TypeError') return true;
const message = (error.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
}

View file

@ -445,14 +445,7 @@ async function initDefaultChat() {
var epSel = el('set-defaultEpSelect');
var modelSel = el('set-defaultModelSelect');
var msg = el('set-defaultChatMsg');
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
}
// Fill any <select> with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
@ -469,64 +462,6 @@ async function initDefaultChat() {
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
renderFallbacks();
}
// Render the fallback chain. Each row is endpoint + model + remove.
function renderFallbacks() {
fbContainer.innerHTML = '';
_fallbacks.forEach(function(fb, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
var epS = document.createElement('select');
epS.className = 'settings-select';
enabledEndpoints().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
var first = enabledEndpoints()[0];
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
fillModels(mS, epS.value, fb.model);
// Keep the model in sync with the values actually shown.
fb.endpoint_id = epS.value;
fb.model = mS.value;
epS.addEventListener('change', function() {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
saveDefault();
});
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
rm.addEventListener('click', function() {
_fallbacks.splice(idx, 1);
renderFallbacks();
saveDefault();
});
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
row.appendChild(rm);
fbContainer.appendChild(row);
});
}
try {
@ -534,7 +469,6 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
@ -554,13 +488,6 @@ async function initDefaultChat() {
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
if (addFbBtn) addFbBtn.addEventListener('click', function() {
var first = enabledEndpoints()[0];
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
renderFallbacks();
saveDefault();
});
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);

View file

@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
const messageCount = Number(session?.message_count || 0);
const totalTokens = Number(session?.total_tokens || 0);
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = costTracked
? (cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: 'Estimated local cost: unavailable or zero')
: 'Estimated local cost: not tracked for this endpoint';
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: costTracked
? 'Estimated local cost: unavailable or zero'
: 'Estimated local cost: no billable usage recorded';
slashReply(`<pre>${[
`Session: ${ctx.esc(session?.name || 'Current chat')}`,

View file

@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
def test_tool_events_included(self):
events = [{"tool": "bash", "duration": 1.0}]
texts = ["round 1 text"]
models = ["round-1-model"]
m = _compute_final_metrics(**self._base_args(
tool_events=events,
round_texts=texts,
round_models=models,
))
assert m["tool_events"] == events
assert m["round_texts"] == texts
assert m["round_models"] == models
def test_no_tool_events_excluded(self):
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
assert "tool_events" not in m
assert "round_texts" not in m
assert "round_models" not in m
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,237 @@
"""Saved Agent rounds must render and bill with actual per-round provenance."""
import json
from pathlib import Path
import re
import shutil
import subprocess
import pytest
_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
).read_text(encoding="utf-8")
_CHAT_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
).read_text(encoding="utf-8")
_SLASH_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
).read_text(encoding="utf-8")
_HAS_NODE = shutil.which("node") is not None
def _function_source(name):
match = re.search(
rf"^(?:export )?function {name}\(.*?^\}}",
_SOURCE,
re.MULTILINE | re.DOTALL,
)
assert match, f"{name} not found"
return match.group(0).replace("export function", "function", 1)
def _run_node(source):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_saved_agent_rounds_prefer_round_model_provenance():
assert "const roundModels = metadata.round_models || [];" in _SOURCE
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
assert "r < roundEndpointIds.length" in _SOURCE
assert "r < roundEndpointLabels.length" in _SOURCE
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
assert "metrics.endpoint_cost_tracked" in _SOURCE
assert "endpointCostTracked === false" in _SOURCE
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_agent_usage_buckets_sum_only_billable_answering_routes():
source = "\n".join([
"let currentUrl = '';",
"function _currentEndpointUrl() { return currentUrl; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const paidSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
"]};",
"const localSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"currentUrl = 'local';",
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
"currentUrl = 'paid';",
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
])
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
source = "\n".join([
"function _currentEndpointUrl() { return 'local-selected'; }",
"function isCostTrackedEndpoint() { return false; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const metrics = {usage_buckets: [",
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
])
assert _run_node(source) == {"cost": 0.2}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_repeated_live_metrics_render_records_session_cost_once():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
"recordSessionMetricsCost(metrics);",
"recordSessionMetricsCost(metrics);",
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
])
assert _run_node(source) == {"cost": 0.11, "recorded": True}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
"const replayedObject = {...firstObject};",
"recordSessionMetricsCost(firstObject);",
"recordSessionMetricsCost(replayedObject);",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'paid'; }",
"function isCostTrackedEndpoint() { return true; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {
"cost": 0.37,
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const badge = {style: {}, textContent: ''};",
"const document = {getElementById() { return badge; }};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
_function_source("getSessionCost"),
_function_source("updateSessionCostUI"),
"updateSessionCostUI();",
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
])
assert _run_node(source) == {
"stored": 0.125,
"display": "",
"text": "$0.125",
}
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE

View file

@ -0,0 +1,203 @@
"""Execute the round-aware live model-provenance state helper under Node."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const fallbackTarget = applyModelRouteEventState({{
type: 'fallback', round: 2,
selected_model: 'selected-model', answered_by: 'backup-model'
}}, round1, round2, 'selected-model');
const aliasTarget = applyModelRouteEventState({{
type: 'model_actual', round: 2,
requested_model: 'selected-model', model: 'provider-backup-alias'
}}, round1, round2, 'selected-model');
console.log(JSON.stringify({{
fallbackIsRound2: fallbackTarget === round2,
aliasIsRound2: aliasTarget === round2,
round1,
round2,
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state == {
"fallbackIsRound2": True,
"aliasIsRound2": True,
"round1": {
"_requestedModel": "selected-model",
"_actualModel": "selected-model",
},
"round2": {
"_requestedModel": "selected-model",
"_actualModel": "provider-backup-alias",
},
}
def test_next_round_and_final_metrics_preserve_each_agent_round_route():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{
applyModelMetricsState,
applyModelRouteEventState,
inheritModelRouteState,
}} from {json.dumps(_MODULE)};
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const round2 = {{}};
inheritModelRouteState(round1, round1, round2, 'selected-model');
applyModelRouteEventState({{
type: 'fallback', round: 2,
selected_model: 'selected-model', answered_by: 'backup-model'
}}, round1, round2, 'selected-model');
applyModelRouteEventState({{
type: 'model_actual', round: 2,
requested_model: 'selected-model', model: 'provider-backup-alias'
}}, round1, round2, 'selected-model');
const round3 = {{}};
inheritModelRouteState(round1, round2, round3, 'selected-model');
const metricsTarget = applyModelMetricsState({{
requested_model: 'selected-model',
model: 'provider-backup-alias',
round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
}}, round1, round3, 'selected-model');
console.log(JSON.stringify({{
metricsIsRound3: metricsTarget === round3,
round1,
round2,
round3,
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"metricsIsRound3": True,
"round1": {
"_requestedModel": "selected-model",
"_actualModel": "selected-model",
},
"round2": {
"_requestedModel": "selected-model",
"_actualModel": "provider-backup-alias",
},
"round3": {
"_requestedModel": "selected-model",
"_actualModel": "backup-model",
},
}
def test_same_model_fallback_preserves_distinct_endpoint_route_state():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
applyModelRouteEventState({{
type: 'fallback',
selected_model: 'same-model', answered_by: 'same-model',
selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
}}, holder, null, 'same-model');
applyModelMetricsState({{
requested_model: 'same-model', model: 'same-model',
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
endpoint_id: 'account-two', endpoint_label: 'Account two',
}}, holder, null, 'same-model');
console.log(JSON.stringify(holder));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"_requestedModel": "same-model",
"_actualModel": "same-model",
"_requestedEndpointId": "account-one",
"_requestedEndpointLabel": "Account one",
"_actualEndpointId": "account-two",
"_actualEndpointLabel": "Account two",
}
def test_metrics_preserve_explicitly_unknown_round_endpoint():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
const holder = {{
_requestedModel: 'same-model',
_actualModel: 'same-model',
_requestedEndpointId: 'account-one',
_requestedEndpointLabel: 'Account one',
}};
const roundHolder = {{}};
applyModelMetricsState({{
requested_model: 'same-model', model: 'same-model',
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
endpoint_id: 'account-two', endpoint_label: 'Account two',
round_endpoint_ids: [null], round_endpoint_labels: [null],
}}, holder, roundHolder, 'same-model');
console.log(JSON.stringify(roundHolder));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"_requestedModel": "same-model",
"_actualModel": "same-model",
"_requestedEndpointId": "account-one",
"_requestedEndpointLabel": "Account one",
"_actualEndpointId": None,
"_actualEndpointLabel": None,
}

View file

@ -0,0 +1,47 @@
"""Execute terminal stream-error classification under Node."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
console.log(JSON.stringify({{
stringMessage: stringError.message,
objectMessage: objectError.message,
terminalRecoverable: isRecoverableStreamError(stringError),
eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"stringMessage": "invalid key",
"objectMessage": "model missing",
"terminalRecoverable": False,
"eofRecoverable": True,
"networkRecoverable": True,
}

View file

@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
return gen()
async def _collect_subscription(session_id, expected_run=None):
return [
event
async for event in agent_runs.subscribe(session_id, expected_run)
]
# --------------------------------------------------------------------------- #
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
# --------------------------------------------------------------------------- #
@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
break
await sub.aclose()
stopped = agent_runs.stop(session_id)
stopped = agent_runs.stop(session_id, run.run_id)
assert stopped is True
await run.task # propagates promptly — not stuck on the hung await
@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
assert sink.saves == []
@pytest.mark.asyncio
async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
session_id = "sess-detached-run-identity"
agent_runs._RUNS.pop(session_id, None)
first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
first_id = first.run_id
assert agent_runs.get_run_id(session_id) == first_id
await first.task
assert agent_runs.get_run_id(session_id) == first_id
second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
assert second.run_id != first_id
assert agent_runs.get_run_id(session_id) == second.run_id
await second.task
@pytest.mark.asyncio
async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
session_id = "sess-detached-lazy-subscription"
agent_runs._RUNS.pop(session_id, None)
async def stream(label):
yield f'data: {{"delta":"{label}"}}\n\n'
first = agent_runs.start(session_id, stream("first"))
await first.task
# StreamingResponse does not iterate its body until after construction.
# Capture the same exact run object used for its identity header.
lazy_body = agent_runs.subscribe(session_id, first)
second = agent_runs.start(session_id, stream("second"))
await second.task
replayed = [event async for event in lazy_body]
assert replayed == ['data: {"delta":"first"}\n\n']
assert agent_runs.get_run_id(session_id) == second.run_id
@pytest.mark.asyncio
async def test_stale_run_identity_cannot_stop_replacement_run():
session_id = "sess-detached-stale-stop"
agent_runs._RUNS.pop(session_id, None)
release = asyncio.Event()
async def finished():
yield 'data: {"delta":"old"}\n\n'
async def replacement():
yield 'data: {"delta":"new"}\n\n'
await release.wait()
first = agent_runs.start(session_id, finished())
await first.task
second = agent_runs.start(session_id, replacement())
await asyncio.sleep(0)
assert agent_runs.stop(session_id) is False
assert agent_runs.stop(session_id, first.run_id) is False
assert second.task is not None and not second.task.done()
assert agent_runs.stop(session_id, second.run_id) is True
await second.task
@pytest.mark.asyncio
async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
session_id = "sess-detached-triple-replacement"
agent_runs._RUNS.pop(session_id, None)
first_closing = asyncio.Event()
release_first = asyncio.Event()
third_started = asyncio.Event()
async def first_stream():
try:
yield 'data: {"delta":"first"}\n\n'
await asyncio.Event().wait()
finally:
first_closing.set()
await release_first.wait()
async def middle_stream():
yield 'data: {"delta":"middle"}\n\n'
async def third_stream():
third_started.set()
yield 'data: {"delta":"third"}\n\n'
first = agent_runs.start(session_id, first_stream())
while not first.buffer:
await asyncio.sleep(0)
middle = agent_runs.start(session_id, middle_stream())
await first_closing.wait()
assert middle.task is not None and not middle.task.done()
middle_events_task = asyncio.create_task(
_collect_subscription(session_id, middle)
)
while not middle.subscribers:
await asyncio.sleep(0)
third = agent_runs.start(session_id, third_stream())
# The superseded middle response closes immediately even though its task
# remains as the transitive barrier for the first run's partial save.
assert await asyncio.wait_for(middle_events_task, timeout=1) == []
assert middle.status == "stopped"
assert middle.task is not None and not middle.task.done()
assert not third_started.is_set()
release_first.set()
await asyncio.wait_for(first.task, timeout=1)
await asyncio.wait_for(middle.task, timeout=1)
await asyncio.wait_for(third.task, timeout=1)
assert first.status == "stopped"
assert middle.status == "stopped"
assert third.status == "done"
assert third_started.is_set()
@pytest.mark.asyncio
async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
session_id = "sess-detached-fallback-resume"
agent_runs._RUNS.pop(session_id, None)
release = asyncio.Event()
tool_executions = 0
fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
async def pinned_run():
nonlocal tool_executions
yield fallback
tool_executions += 1
yield tool
await release.wait()
yield 'data: {"delta":"backup finished"}\n\n'
yield "data: [DONE]\n\n"
run = agent_runs.start(session_id, pinned_run())
first = agent_runs.subscribe(session_id)
first_events = []
async for event in first:
first_events.append(event)
if len(first_events) == 2:
break
await first.aclose()
assert run.status == "running"
assert tool_executions == 1
assert agent_runs._RUNS[session_id] is run
resumed_events = []
resumed = agent_runs.subscribe(session_id)
async for event in resumed:
resumed_events.append(event)
if len(resumed_events) == 2:
release.set()
await run.task
assert resumed_events[:2] == [fallback, tool]
assert resumed_events[-1] == "data: [DONE]\n\n"
assert tool_executions == 1
assert agent_runs._RUNS[session_id] is run
# --------------------------------------------------------------------------- #
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
# the SSE) cancels the upstream generator promptly — exercising the same

View file

@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
class TestTrimForContext:
def test_system_truncation_preserves_internal_route_metadata(self):
messages = [
{
"role": "system",
"content": "persona\n\n" + ("agent prompt " * 2000),
"_agent_injected": "merged_prompt",
"_agent_base_message": {"role": "system", "content": "persona"},
},
{"role": "user", "content": "latest"},
]
trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
system = next(message for message in trimmed if message.get("role") == "system")
assert system["_agent_injected"] == "merged_prompt"
assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
def test_keeps_current_large_user_message_by_truncating(self):
huge = "A" * 20000
messages = [
@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
assert len(result) == 3 and result[2] is True
@pytest.mark.asyncio
async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
updates = []
state = {}
messages = [
{"role": "system", "content": "system " * 100},
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
{"role": "user", "content": "five"},
]
monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
async def fake_summary(*args, **kwargs):
return "route-specific summary"
monkeypatch.setattr(cc, "llm_call_async", fake_summary)
monkeypatch.setattr(
cc,
"_update_session_history",
lambda *args, **kwargs: updates.append((args, kwargs)),
)
_compacted, _context, was_compacted = await cc.maybe_compact(
object(),
"https://candidate.example/v1",
"candidate-model",
messages,
persist=False,
compaction_state=state,
)
assert was_compacted is True
assert updates == []
assert state["summary"] == "route-specific summary"
assert cc.apply_compaction_state(object(), state) is True
assert len(updates) == 1
assert cc.apply_compaction_state(object(), state) is False
assert len(updates) == 1
class TestResearchPrimerPreserved:
"""A research-spinoff primer (metadata research_spinoff_from) must never be
trimmed away it is the Discuss chat's sole knowledge base (drift fix)."""

File diff suppressed because it is too large Load diff

View file

@ -8,15 +8,15 @@ from bs4 import BeautifulSoup
_REPO = Path(__file__).resolve().parents[1]
def test_legacy_default_fallback_editor_is_hidden():
def test_legacy_default_fallback_editor_is_absent():
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")
assert editor is None
assert soup.find(id="set-defaultAddFallback") is None
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
@ -27,3 +27,5 @@ def test_default_model_save_does_not_rewrite_legacy_fallbacks():
assert "settings.default_model_fallbacks" not in default_chat_source
assert "default_model_fallbacks:" not in default_chat_source
assert "set-defaultFallbacks" not in default_chat_source
assert "set-defaultAddFallback" not in default_chat_source

View file

@ -0,0 +1,284 @@
"""Source contract for live multi-round fallback attribution."""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
CHAT_JS = Path("static/js/chat.js").read_text(encoding="utf-8")
_HAS_NODE = shutil.which("node") is not None
def _resume_function_source():
body = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
return "async function resumeStream" + body.rstrip()
def _run_node(source):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_live_fallback_targets_the_active_round_and_replaces_actual_model():
fallback_block = CHAT_JS.split("json.type === 'fallback'", 1)[1].split(
"json.type === 'doc_stream_open'", 1
)[0]
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in fallback_block
assert "_fallbackHolder.querySelector('.role')" in fallback_block
assert "_hasResolvedActual" not in fallback_block
def test_provider_alias_uses_the_same_round_aware_holder_selection():
actual_block = CHAT_JS.split("json.type === 'model_actual'", 1)[1].split(
"json.type === 'attachments'", 1
)[0]
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in actual_block
assert "_modelHolder.querySelector('.role')" in actual_block
def test_new_round_and_final_metrics_target_the_active_round():
agent_step_block = CHAT_JS.split("} else if (json.type === 'agent_step')", 1)[1].split(
"json.type === 'budget_exceeded'", 1
)[0]
metrics_block = CHAT_JS.split("json.type === 'metrics'", 1)[1].split(
"json.type === 'message_saved'", 1
)[0]
final_block = CHAT_JS.split("const _isBgFinal", 1)[1].split(
"holder.dataset.raw", 1
)[0]
assert "inheritModelRouteState(holder, roundHolder, newWrap" in agent_step_block
assert "applyModelMetricsState(metrics, holder, roundHolder, modelName)" in metrics_block
assert "_finalModelHolder.querySelector('.role')" in final_block
assert "holder.querySelector('.role')" not in final_block
def test_terminal_sse_error_bypasses_eof_auto_recovery():
parser_block = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
"if (json.delta", 1
)[0]
completion_gate = CHAT_JS.split("if (_streamTerminalError)", 1)[1].split(
"if (!_streamSawDone)", 1
)[0]
recovery_block = CHAT_JS.split("isRecoverableStreamError(err)", 1)[1].split(
"const errorHolder", 1
)[0]
assert "createTerminalStreamError(json)" in parser_block
assert "throw _streamTerminalError" in completion_gate
assert "if (err.terminalStreamError)" in recovery_block
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in recovery_block
def test_connection_recovery_resumes_detached_run_without_resubmitting_selected_model():
recovery = CHAT_JS.split("function _tryAutoRecover", 1)[1].split(
"function _removeStallBanner", 1
)[0]
assert "await resumeStream(sessionId, holder || null)" in recovery
assert "/api/chat_stream" not in recovery
assert ".click()" not in recovery
assert "_pendingContinue" not in recovery
assert "if (_streamSessionId === streamSessionId) _streamSessionId = null" in CHAT_JS
def test_detached_resume_reloads_canonical_terminal_failures():
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
assert "l.trim() === 'event: error'" in resume
assert "json.type === 'agent_terminal'" in resume
assert "rich = true" in resume
assert "Network drop or parse failure: fall through to the canonical reload" in resume
assert "if (onThisSession && !rich && roundText.trim())" in resume
assert "res.headers.get('X-Odysseus-Run-Id')" in resume
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in resume
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_detached_resume_surfaces_fallback_then_provider_alias_before_reload():
source = "\n".join([
"import { applyModelRouteEventState } from './static/js/chatModelProvenance.js';",
"class Element {",
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
" set innerHTML(value) {",
" this._html = value;",
" if (value.includes('stream-content')) {",
" this._role = new Element('div'); this._role.parentNode = this;",
" this._body = new Element('div'); this._body.parentNode = this;",
" this._content = new Element('div'); this._body.appendChild(this._content);",
" }",
" }",
" get innerHTML() { return this._html; }",
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
"}",
"const box = new Element('main');",
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
"const window = {};",
"let selectCalls = 0; const labels = []; const toasts = [];",
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected-model'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {}, showToast(value) { toasts.push(value); } };",
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
"const documentModule = null; const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
"function _setRoleModelLabel(role, requested, actual) { labels.push({requested, actual}); role.textContent = requested + ' -> ' + actual; }",
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
"const events = [",
" 'data: {\"type\":\"fallback\",\"selected_model\":\"selected-model\",\"answered_by\":\"fallback-model\",\"reason\":\"429\"}\\n\\n',",
" 'data: {\"type\":\"model_actual\",\"model\":\"provider/fallback-alias\"}\\n\\n',",
" 'data: {\"delta\":\"hello\"}\\n\\n',",
" 'data: [DONE]\\n\\n',",
"].join('');",
"const encoded = new TextEncoder().encode(events); let reads = 0;",
"const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
_resume_function_source(),
"await resumeStream('s1');",
"console.log(JSON.stringify({labels, toasts, selectCalls, holderCount: box.children.length}));",
])
assert _run_node(source) == {
"labels": [
{"requested": "selected-model", "actual": "fallback-model"},
{"requested": "selected-model", "actual": "provider/fallback-alias"},
],
"toasts": ["Fallback: selected-model failed — answered by fallback-model"],
"selectCalls": 1,
"holderCount": 0,
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_detached_resume_renders_preoutput_error_without_empty_reload():
source = "\n".join([
"import { createTerminalStreamError } from './static/js/chatStreamErrors.js';",
"class Element {",
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
" set innerHTML(value) {",
" this._html = value;",
" if (value.includes('stream-content')) {",
" this._role = new Element('div'); this._role.parentNode = this;",
" this._body = new Element('div'); this._body.parentNode = this;",
" this._content = new Element('div'); this._body.appendChild(this._content);",
" }",
" }",
" get innerHTML() { return this._html; }",
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
"}",
"const box = new Element('main');",
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
"const window = {};",
"let selectCalls = 0;",
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {} };",
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
"const documentModule = null;",
"const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
"const encoded = new TextEncoder().encode('event: error\\ndata: {\"status\":401,\"error\":\"invalid key <img src=x>\"}\\n\\n');",
"let reads = 0; const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
_resume_function_source(),
"const result = await resumeStream('s1');",
"const holder = box.children[0]; const errorNode = holder && holder._content.children.find(node => node.textContent.startsWith('[Error:'));",
"console.log(JSON.stringify({result, selectCalls, holderCount: box.children.length, errorText: errorNode && errorNode.textContent}));",
])
assert _run_node(source) == {
"result": True,
"selectCalls": 0,
"holderCount": 1,
"errorText": "[Error: invalid key <img src=x>]",
}
def test_terminal_then_session_switch_preserves_completed_background_state():
terminal = CHAT_JS.split(
"json.type === 'agent_terminal' || json.type === 'chat_terminal'", 1
)[1].split("json.type === 'metrics'", 1)[0]
detach = CHAT_JS.split("export function detachCurrentStream", 1)[1].split(
"export async function resumeStream", 1
)[0]
background_catch = CHAT_JS.split("if (_isBgCatch)", 1)[1].split(
"} else {", 1
)[0]
assert "_terminalSavedStreams.add(streamSessionId)" in terminal
assert "terminalSaved ? 'completed' : 'running'" in detach
assert "!terminalSaved && sessionModule && sessionModule.markStreaming" in detach
assert "_terminalSavedStreams.has(streamSessionId)" in background_catch
def test_detached_run_identity_is_attached_to_live_metrics():
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
assert "headers={\"X-Odysseus-Run-Id\": _detached_run.run_id}" in routes
assert "agent_runs.subscribe(session, _detached_run)" in routes
assert "agent_runs.subscribe(session_id, _active_run)" in routes
assert "const streamRunId = res.headers.get('X-Odysseus-Run-Id')" in CHAT_JS
assert "metrics._costRecordId = _metricsCostRecordId(streamRunId, json)" in CHAT_JS
assert "'X-Odysseus-Run-Id': runId" in CHAT_JS
assert "agent_runs.stop(session_id, _expected_run_id)" in routes
assert "_stopExactRun(streamSessionId)" in CHAT_JS
timeout_block = CHAT_JS.split("timeoutId = setTimeout", 1)[1].split(
"clearResponseTimeout", 1
)[0]
assert "/api/chat/stop/" not in timeout_block
def test_replay_cost_identity_distinguishes_primary_and_teacher_segments():
identity = CHAT_JS.split("function _metricsCostRecordId", 1)[1].split("\n }", 1)[0]
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
assert "event.teacher ? 'teacher' : 'primary'" in identity
assert "_metricsCostRecordId(resumeRunId, json)" in resume
metrics_block = resume.split("json.type === 'metrics'", 1)[1].split(
"json.type === 'agent_terminal'", 1
)[0]
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in metrics_block
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
route_metrics = routes.split('elif data.get("type") == "metrics"', 1)[1].split(
"except json.JSONDecodeError", 1
)[0]
assert 'if data.get("teacher") is True' in route_metrics
assert '_metrics_event["teacher"] = True' in route_metrics
def test_foreground_terminal_error_reloads_saved_partial_without_typewriter_race():
parser = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
"if (json.delta", 1
)[0]
terminal_catch = CHAT_JS.split("if (err.terminalStreamError)", 1)[1].split(
"const errorHolder", 1
)[0]
assert "typewriterInto" not in parser
assert "json.type === 'agent_terminal'" in CHAT_JS
assert "_canonicalTerminalSaved = true" in CHAT_JS
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in terminal_catch

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,8 @@ stream_llm only captured usage when the delta was exactly None / {} /
import asyncio
import json
import pytest
from src import llm_core
@ -116,7 +118,8 @@ def test_null_choice_chunk_does_not_crash(monkeypatch):
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
# Chunk with both choices:[null] and usage:null — neither field should panic.
# Chunk with both choices:[null] and usage:null is a keepalive, not a real
# zero-token accounting record.
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [None], "usage": None}),
@ -124,6 +127,66 @@ def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_empty_usage_object_is_not_reported_as_real_zero_usage(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [], "usage": {}}),
'data: [DONE]',
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_explicit_zero_token_usage_is_preserved(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({
"choices": [],
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
}),
'data: [DONE]',
]
usage = _usage_events(_drive(monkeypatch, lines))
assert usage == [{"input_tokens": 0, "output_tokens": 0}]
@pytest.mark.parametrize(
"usage_payload",
[
{"prompt_tokens": None, "completion_tokens": 1},
{"prompt_tokens": "bad", "completion_tokens": 1},
{"prompt_tokens": -1, "completion_tokens": 1},
{"prompt_tokens": True, "completion_tokens": 1},
{"prompt_tokens": 1.5, "completion_tokens": 1},
{"prompt_tokens": float("inf"), "completion_tokens": 1},
],
)
def test_malformed_token_values_do_not_emit_usage(monkeypatch, usage_payload):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [], "usage": usage_payload}),
'data: [DONE]',
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_missing_usage_counterpart_defaults_to_zero(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({
"choices": [],
"usage": {"completion_tokens": 2},
}),
'data: [DONE]',
]
usage = _usage_events(_drive(monkeypatch, lines))
assert usage == [{"input_tokens": 0, "output_tokens": 2}]
def test_null_tool_call_in_delta_is_skipped(monkeypatch):

View file

@ -98,6 +98,9 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
],
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "foreground"},
],
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
"stt_provider": "endpoint:dead",
@ -106,12 +109,14 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
@ -122,6 +127,7 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
]
assert settings["foreground_model_fallbacks"] == []
assert settings["utility_model_fallbacks"] == []
assert settings["vision_model_fallbacks"] == []
assert settings["stt_provider"] == "disabled"
@ -130,10 +136,19 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
scoped = {
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "ownerless"},
],
"default_model_fallbacks": [
{"endpoint_id": "dead", "model": "legacy-ownerless"},
],
"_users": {
"alice": {
"utility_endpoint_id": "dead",
"utility_model": "utility",
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "foreground"},
],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
},
"bob": {
@ -142,10 +157,15 @@ def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data(
},
},
}
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 2
assert scoped["foreground_model_fallbacks"] == []
assert scoped["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "legacy-ownerless"},
]
assert scoped["_users"]["alice"] == {
"utility_endpoint_id": "",
"utility_model": "",
"foreground_model_fallbacks": [],
"vision_model_fallbacks": [],
}
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"

View file

@ -17,4 +17,26 @@ def test_load_keeps_object_prefs_file(tmp_path, monkeypatch):
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
assert prefs_routes._load_for_user("alice") == {}
def test_named_preference_write_does_not_copy_flat_fallback_consent(tmp_path, monkeypatch):
prefs_file = tmp_path / "user_prefs.json"
prefs_file.write_text(json.dumps({
"theme": "light",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": [
{"endpoint_id": "legacy-single-user", "model": "legacy-model"},
],
}), encoding="utf-8")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
bob = prefs_routes._load_for_user("bob")
bob["theme"] = "dark"
prefs_routes._save_for_user("bob", bob)
raw = prefs_routes._load()
assert raw["_users"] == {"bob": {"theme": "dark"}}
assert raw["foreground_fallback_enabled"] is True
assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "legacy-single-user"

View file

@ -6,6 +6,9 @@ every other user's preferences (a realistic ops transition: auth turned off
on a deployment that previously ran multi-user). It must preserve the other
users and round-trip the change into the same (first) slot _load_for_user
reads from.
Foreground fallback keys are the exception: auth-disabled consent is stored
at the flat root so it can never become consent for the first named owner.
"""
import json
@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
data = json.loads(f.read_text())
assert data["_users"]["alice"] == {"theme": "light"}
assert data["_users"]["bob"] == {"theme": "dark"}
def test_auth_disabled_fallback_consent_does_not_mutate_first_named_user(
tmp_path,
monkeypatch,
):
f = tmp_path / "user_prefs.json"
f.write_text(json.dumps({"_users": {
"alice": {"theme": "light"},
"bob": {"theme": "paper"},
}}), encoding="utf-8")
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
current = pr._load_for_user(None)
current["foreground_fallback_enabled"] = True
current["foreground_model_fallbacks"] = [
{"endpoint_id": "single-user", "model": "single-model"},
]
pr._save_for_user(None, current)
data = json.loads(f.read_text(encoding="utf-8"))
assert data["foreground_fallback_enabled"] is True
assert data["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
assert data["_users"]["alice"] == {"theme": "light"}
assert data["_users"]["bob"] == {"theme": "paper"}
def test_auth_disabled_save_preserves_named_fallback_consent(tmp_path, monkeypatch):
f = tmp_path / "user_prefs.json"
alice_fallbacks = [{"endpoint_id": "alice", "model": "alice-model"}]
f.write_text(json.dumps({"_users": {
"alice": {
"theme": "light",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": alice_fallbacks,
},
}}), encoding="utf-8")
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
current = pr._load_for_user(None)
assert "foreground_fallback_enabled" not in current
assert "foreground_model_fallbacks" not in current
current["theme"] = "dark"
current["foreground_fallback_enabled"] = False
current["foreground_model_fallbacks"] = []
pr._save_for_user(None, current)
data = json.loads(f.read_text(encoding="utf-8"))
assert data["foreground_fallback_enabled"] is False
assert data["foreground_model_fallbacks"] == []
assert data["_users"]["alice"] == {
"theme": "dark",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": alice_fallbacks,
}

View file

@ -4,7 +4,13 @@ import json
from types import SimpleNamespace
import src.endpoint_resolver as endpoint_resolver
from src.endpoint_resolver import resolve_endpoint
from src.endpoint_resolver import (
endpoint_cost_tracked,
resolve_endpoint,
resolve_endpoint_by_id,
resolve_fallback_entries,
resolve_fallback_entries_with_descriptors,
)
class _FakeColumn:
@ -34,6 +40,9 @@ class _FakeQuery:
def first(self):
return self.rows[0] if self.rows else None
def all(self):
return list(self.rows)
class _FakeDb:
def __init__(self, rows):
@ -49,6 +58,7 @@ class _FakeDb:
def _endpoint(ep_id, model, *, hidden=None):
return SimpleNamespace(
id=ep_id,
name=f"Endpoint {ep_id}",
base_url=f"https://{ep_id}.example/v1",
api_key=f"key-{ep_id}",
cached_models=json.dumps([model]),
@ -191,3 +201,95 @@ def test_hidden_configured_model_selects_first_enabled_chat_model(monkeypatch):
assert url == "https://default.example/v1/chat/completions"
assert model == "enabled-chat"
assert headers == {"Authorization": "Bearer key-default"}
def test_exact_fallback_drops_hidden_model_instead_of_substituting(monkeypatch):
endpoint = SimpleNamespace(
id="fallback",
base_url="https://fallback.example/v1",
api_key="key-fallback",
cached_models=json.dumps(["chosen-hidden", "different-live"]),
hidden_models=json.dumps(["chosen-hidden"]),
is_enabled=True,
)
_install_resolver_fakes(monkeypatch, {}, [endpoint])
assert resolve_endpoint_by_id(
"fallback",
"chosen-hidden",
require_exact_model=True,
) is None
assert resolve_endpoint_by_id("fallback", "chosen-hidden")[1] == "different-live"
def test_exact_fallback_drops_known_missing_model(monkeypatch):
_install_resolver_fakes(monkeypatch, {}, [_endpoint("fallback", "known-live")])
assert resolve_endpoint_by_id(
"fallback",
"unlisted-model",
require_exact_model=True,
) is None
def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
seen = []
def fake_resolve(ep_id, model, owner=None, *, require_exact_model=False):
seen.append((ep_id, model, owner, require_exact_model))
return (
"https://provider.example/v1/chat/completions",
model,
{"Authorization": f"Bearer {ep_id}"},
)
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_by_id", fake_resolve)
entries = [
{"endpoint_id": "key-one", "model": "same-model"},
{"endpoint_id": "key-two", "model": "same-model"},
]
assert resolve_fallback_entries(
entries,
owner="alice",
require_exact_model=True,
) == [
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-one"}),
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-two"}),
]
assert seen == [
("key-one", "same-model", "alice", True),
("key-two", "same-model", "alice", True),
]
def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
_install_resolver_fakes(monkeypatch, {}, [_endpoint("backup", "backup-model")])
routes = resolve_fallback_entries_with_descriptors(
[{"endpoint_id": "backup", "model": "backup-model"}],
require_exact_model=True,
)
assert routes == [(
(
"https://backup.example/v1/chat/completions",
"backup-model",
{"Authorization": "Bearer key-backup"},
),
{
"endpoint_id": "backup",
"endpoint_label": "Endpoint backup",
"endpoint_cost_tracked": True,
},
)]
def test_endpoint_cost_tracking_is_non_secret_route_classification():
assert endpoint_cost_tracked("http://localhost:11434/v1") is False
assert endpoint_cost_tracked("http://model-service:8000/v1") is False
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1") is False
assert endpoint_cost_tracked("https://chatgpt.com/backend-api/codex") is False
assert endpoint_cost_tracked("https://api.example.com/v1") is True
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1", "api") is True
assert endpoint_cost_tracked("https://api.example.com/v1", "local") is False

View file

@ -163,53 +163,3 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
"""Fallback endpoint selection must keep the resolved bearer request-local."""
TestSessionLocal = _mem_db(monkeypatch)
db = TestSessionLocal()
try:
db.add(ModelEndpoint(
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
cached_models='["gpt-5.1-codex"]',
))
db.add(DbSession(
id="sess1", name="chat", endpoint_url="https://old.example/v1",
model="old-model", owner="alice", headers={},
))
db.commit()
finally:
db.close()
monkeypatch.setattr(
endpoint_resolver,
"resolve_endpoint_runtime",
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
)
sess = types.SimpleNamespace(
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
owner="alice", headers={},
)
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
assert result == {
"model": "gpt-5.1-codex",
"endpoint_url": _CODEX_BASE + "/responses",
"endpoint_name": "ChatGPT Subscription",
}
assert sess.headers["Authorization"] == "Bearer live-access-token"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
assert row.model == "gpt-5.1-codex"
assert row.endpoint_url == _CODEX_BASE + "/responses"
stored = row.headers or {}
assert not any(k.lower() == "authorization" for k in stored), (
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
)
finally:
db.close()

View file

@ -0,0 +1,119 @@
"""Retired settings stay stored but cannot leak through generic interfaces."""
import asyncio
import json
from types import SimpleNamespace
import pytest
import core.database as database
import routes.auth_routes as auth_routes
import src.settings as settings_mod
from src.agent_tools.admin_tools import do_manage_settings
LEGACY_VALUE = [
{"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
]
class _AuthManager:
def get_username_for_token(self, token):
return "admin" if token == "admin-session" else None
def is_admin(self, username):
return username == "admin"
class _Request(SimpleNamespace):
def __init__(self, body=None, *, admin=False):
super().__init__(
cookies={
auth_routes.SESSION_COOKIE: "admin-session"
} if admin else {},
_body=body,
)
async def json(self):
return self._body
def _route(router, path, method):
return next(
route.endpoint
for route in router.routes
if route.path == path and method in route.methods
)
@pytest.mark.asyncio
async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
store = {
**settings_mod.DEFAULT_SETTINGS,
"default_model_fallbacks": list(LEGACY_VALUE),
"tts_enabled": True,
}
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
def save_settings(updated):
store.clear()
store.update(updated)
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
router = auth_routes.setup_auth_routes(_AuthManager())
get_settings = _route(router, "/api/auth/settings", "GET")
set_settings = _route(router, "/api/auth/settings", "POST")
anonymous = await get_settings(_Request())
admin = await get_settings(_Request(admin=True))
assert "default_model_fallbacks" not in anonymous
assert "default_model_fallbacks" not in admin
assert store["default_model_fallbacks"] == LEGACY_VALUE
response = await set_settings(_Request({
"default_model_fallbacks": [],
"tts_enabled": False,
}, admin=True))
assert "default_model_fallbacks" not in response
assert store["default_model_fallbacks"] == LEGACY_VALUE
assert store["tts_enabled"] is False
def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
store = {
**settings_mod.DEFAULT_SETTINGS,
"default_model_fallbacks": list(LEGACY_VALUE),
}
save_calls = []
class _Db:
def close(self):
return None
monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
def save_settings(updated):
save_calls.append(dict(updated))
store.clear()
store.update(updated)
monkeypatch.setattr(settings_mod, "save_settings", save_settings)
listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
assert "default_model_fallbacks" not in listed["settings"]
for action in ("get", "set", "reset", "delete"):
payload = {"action": action, "key": "default_model_fallbacks"}
if action == "set":
payload["value"] = []
result = asyncio.run(do_manage_settings(json.dumps(payload)))
assert result["exit_code"] == 1
assert "Unknown setting" in result["error"]
assert save_calls == []
assert store["default_model_fallbacks"] == LEGACY_VALUE

View file

@ -6,8 +6,15 @@ Verifies two critical cases:
2. api.deepseek.com must still be treated as tool-capable via the host
allow-list (_API_HOSTS), so cloud deepseek users keep working.
"""
from types import SimpleNamespace
import pytest
from src.agent_loop import _API_HOSTS, _endpoint_lookup_keys, _is_ollama_openai_compat_url
from src.agent_loop import (
_API_HOSTS,
_agent_route_tool_mode,
_endpoint_lookup_keys,
_is_ollama_openai_compat_url,
)
from src.llm_core import _is_ollama_native_url
@ -164,3 +171,57 @@ class TestEndpointLookupKeys:
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
assert "http://host.docker.internal:11434/api" in keys
def test_route_tool_mode_matches_credential_distinct_endpoint(monkeypatch):
from core import database
from src import endpoint_resolver
rows = [
SimpleNamespace(
id="one",
base_url="https://same.example/v1",
api_key="key-one",
provider_auth_id=None,
supports_tools=True,
),
SimpleNamespace(
id="two",
base_url="https://same.example/v1",
api_key="key-two",
provider_auth_id=None,
supports_tools=False,
),
]
class Query:
def filter(self, *args, **kwargs):
return self
def all(self):
return rows
class Db:
def query(self, *args, **kwargs):
return Query()
def close(self):
return None
monkeypatch.setattr(database, "SessionLocal", lambda: Db())
monkeypatch.setattr(
endpoint_resolver,
"resolve_endpoint_runtime",
lambda endpoint, owner=None: (endpoint.base_url, endpoint.api_key),
)
assert _agent_route_tool_mode(
"https://same.example/v1",
"custom-model",
headers={"Authorization": "Bearer key-one"},
)[0] is True
assert _agent_route_tool_mode(
"https://same.example/v1",
"custom-model",
headers={"Authorization": "Bearer key-two"},
)[0] is False

View file

@ -117,6 +117,31 @@ def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
assert "Australia/Brisbane, UTC+10:00" in datetime_messages[0]["content"]
def test_route_prompt_rebuild_restores_leading_user_system_message(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("AGENT PROMPT", ""))
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
original = [
{"role": "system", "content": "USER PERSONA"},
{"role": "user", "content": "hello"},
]
built, _ = agent_loop._build_system_prompt(
original,
model="selected-model",
active_document=None,
mcp_mgr=None,
)
assert built[0]["content"] == "USER PERSONA\n\nAGENT PROMPT"
assert built[0]["_agent_injected"] == "merged_prompt"
assert agent_loop._strip_agent_injected_messages(built) == original
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
import routes.calendar_routes as calendar_routes