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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
This commit is contained in:
Alexandre Teixeira
2026-08-14 08:10:30 +01:00
committed by GitHub
co-authored by RaresKeY StressTestor
parent b52296471b
commit c4369305f0
46 changed files with 11418 additions and 1006 deletions
+19 -2
View File
@@ -1491,8 +1491,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}'")
-8
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"}
+6 -2
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,
@@ -689,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
a scrubbed copy with secret keys blanked. The frontend uses this
for keybinds + TTS prefs, so it stays callable without admin."""
user = _get_current_user(request)
settings = _load_settings()
settings = without_retired_settings(_load_settings())
if user and auth_manager.is_admin(user):
return settings
return scrub_settings(settings)
@@ -709,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
if key not in body:
continue
val = body[key]
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
val = max(lo, min(val, hi))
current[key] = val
_save_settings(current)
return current
return without_retired_settings(current)
# ---- Integrations CRUD ----
+47 -100
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,
)
+561 -37
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", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
# non-streaming path can't be used to bypass).
@@ -638,6 +725,11 @@ def setup_chat_routes(
if memory_response:
return {"response": memory_response}
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
)
# Build shared context (preset, preprocess, preface, compact)
ctx = await build_chat_context(
sess, request, chat_handler, chat_processor,
@@ -649,6 +741,7 @@ def setup_chat_routes(
time_filter=time_filter,
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
)
# Research injection
@@ -662,24 +755,88 @@ def setup_chat_routes(
research_ctx = await research_handler.call_research_service(
message, _r_ep, _r_model, llm_headers=_r_headers
)
ctx.messages.insert(
len(ctx.preface),
untrusted_context_message("research context", research_ctx),
)
research_message = untrusted_context_message("research context", research_ctx)
ctx.messages.insert(len(ctx.preface), research_message)
if foreground_policy.enabled:
getattr(ctx, "route_messages", ctx.messages).insert(
len(ctx.preface),
research_message,
)
except Exception as e:
logger.error(f"Research failed: {e}")
reply = await llm_call_async(
foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
ctx.messages,
headers=sess.headers,
sess.headers,
owner=owner,
policy=foreground_policy,
)
route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
sess.model,
sess.headers,
owner=owner,
policy=foreground_policy,
selected_endpoint_id=chat_request.selected_endpoint_id,
)
candidate_request_factory = None
selected_context_length = getattr(ctx, "context_length", 0)
candidate_request_state = {
"context_lengths": {0: selected_context_length},
"requests": {0: ctx.messages},
"trim_stats": {},
}
request_messages = ctx.messages
if foreground_policy.enabled:
request_messages = getattr(ctx, "route_messages", ctx.messages)
candidate_request_factory, candidate_request_state = _chat_candidate_request_factory(
request_messages,
selected_context_length,
session=sess,
owner=owner,
)
requested_model = sess.model
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
foreground_candidates,
request_messages,
fallback_statuses=foreground_policy.eligible_statuses,
candidate_request_factory=candidate_request_factory,
temperature=ctx.preset.temperature,
max_tokens=ctx.preset.max_tokens,
prompt_type=preset_id,
session_id=session,
)
_clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model})
actual_index = _candidate_index(foreground_candidates, actual_candidate)
apply_compaction_state(
sess,
candidate_request_state.get("compactions", {}).get(actual_index),
)
requested_route = route_descriptors[0]
actual_route = route_descriptors[actual_index]
actual_trim = candidate_request_state.get("trim_stats", {}).get(actual_index, {})
_clean_reply, _clean_md = clean_thinking_for_save(
reply,
{
"model": actual_model,
"requested_model": requested_model,
"endpoint_id": actual_route.get("endpoint_id"),
"endpoint_label": actual_route.get("endpoint_label"),
"requested_endpoint_id": requested_route.get("endpoint_id"),
"requested_endpoint_label": requested_route.get("endpoint_label"),
"context_length": candidate_request_state["context_lengths"].get(
actual_index,
selected_context_length,
),
"context_trimmed": bool(
actual_trim
and (
actual_trim.get("messages_after") < actual_trim.get("messages_before")
or actual_trim.get("tokens_after") < actual_trim.get("tokens_before")
)
),
},
)
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
from core.database import update_session_last_accessed
@@ -695,7 +852,15 @@ def setup_chat_routes(
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
return {"response": reply}
return {
"response": reply,
"requested_model": requested_model,
"model": actual_model,
"requested_endpoint_id": requested_route.get("endpoint_id"),
"requested_endpoint_label": requested_route.get("endpoint_label"),
"endpoint_id": actual_route.get("endpoint_id"),
"endpoint_label": actual_route.get("endpoint_label"),
}
# ------------------------------------------------------------------ #
# POST /api/chat_stream
@@ -724,6 +889,11 @@ def setup_chat_routes(
use_research = form_data.get("use_research")
time_filter = form_data.get("time_filter")
preset_id = form_data.get("preset_id")
selected_endpoint_id = str(
form_data.get("selected_endpoint_id")
or (body or {}).get("selected_endpoint_id")
or ""
).strip()
# Issue #3229: API callers send JSON, not FormData. Read from the
# JSON body as fallback so callers who send {"allow_bash": true}
# actually get bash enabled.
@@ -896,6 +1066,8 @@ def setup_chat_routes(
400,
"No model selected for this chat. Open the model picker and choose one before sending.",
)
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if (
chat_mode == "chat"
and isinstance(message, str)
@@ -971,6 +1143,10 @@ def setup_chat_routes(
last_user_message=message,
)
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
)
# Build shared context (stream path uses enhanced_message for context preface)
ctx = await build_chat_context(
@@ -993,6 +1169,7 @@ def setup_chat_routes(
# index would be useless / unwanted noise.
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1292,6 +1469,8 @@ def setup_chat_routes(
"what aspects matter most, are they comparing to something, what's their context "
"(moving, traveling, curiosity). Be conversational. Keep it short."
})
if foreground_policy.enabled:
getattr(ctx, "route_messages", ctx.messages).insert(0, dict(ctx.messages[0]))
_skip_research = True
else:
_skip_research = False
@@ -1388,7 +1567,12 @@ def setup_chat_routes(
_active_streams.pop(session, None)
return
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
context_source = (
getattr(ctx, "route_messages", ctx.messages)
if foreground_policy.enabled
else ctx.messages
)
messages = _ensure_current_request_is_latest_user(context_source, message)
# Auto-compact notification
if ctx.was_compacted:
@@ -1400,25 +1584,56 @@ def setup_chat_routes(
thinking_response = ""
last_metrics = None
# Foreground Chat and Agent requests use one owner-aware policy
# boundary. Legacy `default_model_fallbacks` data is not eligible.
# Foreground Chat and Agent requests share one explicit owner-aware
# policy. Strict mode is the default; legacy values are unrelated.
_foreground_policy = foreground_policy
_foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
policy=_foreground_policy,
)
_foreground_route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
policy=_foreground_policy,
selected_endpoint_id=selected_endpoint_id,
)
_chat_request_factory = None
_selected_context_length = getattr(ctx, "context_length", 0)
_chat_request_state = {
"context_lengths": {0: _selected_context_length},
"requests": {0: messages},
"trim_stats": {},
}
if _foreground_policy.enabled:
_chat_request_factory, _chat_request_state = _chat_candidate_request_factory(
messages,
_selected_context_length,
session=sess,
owner=_user,
)
# Send model name early so the frontend can show it during streaming
_model_suffix = "Research" if effective_do_research else None
_model_info = {"type": "model_info", "model": sess.model}
_selected_route = _foreground_route_descriptors[0]
_model_info = {
"type": "model_info",
"model": sess.model,
"endpoint_id": _selected_route.get("endpoint_id"),
"endpoint_label": _selected_route.get("endpoint_label"),
}
if _model_suffix:
_model_info["suffix"] = _model_suffix
if ctx.preset.character_name:
_model_info["character_name"] = ctx.preset.character_name
yield f'data: {json.dumps(_model_info)}\n\n'
if image_generation_session:
_terminal_saved = False
if _is_image_generation_session(sess, owner=_user):
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
@@ -1521,6 +1736,16 @@ def setup_chat_routes(
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
_requested_route = _foreground_route_descriptors[0]
_actual_route = _requested_route
_actual_candidate_index = 0
_chat_terminal_saved = False
def _commit_chat_compaction(candidate_index: int) -> bool:
return apply_compaction_state(
sess,
_chat_request_state.get("compactions", {}).get(candidate_index),
)
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
async for chunk in stream_llm_with_fallback(
@@ -1536,11 +1761,21 @@ def setup_chat_routes(
prompt_type=preset_id,
tools=None,
session_id=session,
fallback_statuses=_foreground_policy.eligible_statuses,
fallback_on_empty=_foreground_policy.fallback_on_empty,
candidate_request_factory=_chat_request_factory,
candidate_route_descriptors=_foreground_route_descriptors,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
data = json.loads(chunk[6:])
if "delta" in data:
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
# Reasoning tokens arrive flagged thinking:true.
# Forward them so the client can show a thinking
# indicator, but don't fold them into the saved
@@ -1556,29 +1791,82 @@ def setup_chat_routes(
# Forward the notice and remember the real model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
_actual_candidate_index = data.get("candidate_index", 0)
if not isinstance(_actual_candidate_index, int):
_actual_candidate_index = 0
if 0 <= _actual_candidate_index < len(_foreground_route_descriptors):
_actual_route = _foreground_route_descriptors[_actual_candidate_index]
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "model_actual":
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
_actual_model = data.get("model") or _actual_model
data["requested_model"] = _requested_model
data["requested_endpoint_id"] = _requested_route.get("endpoint_id")
data["requested_endpoint_label"] = _requested_route.get("endpoint_label")
data["endpoint_id"] = _actual_route.get("endpoint_id")
data["endpoint_label"] = _actual_route.get("endpoint_label")
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "usage":
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
last_metrics = data.get("data", {})
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
if ctx.context_trimmed:
last_metrics["requested_endpoint_id"] = _requested_route.get("endpoint_id")
last_metrics["requested_endpoint_label"] = _requested_route.get("endpoint_label")
last_metrics["endpoint_id"] = _actual_route.get("endpoint_id")
last_metrics["endpoint_label"] = _actual_route.get("endpoint_label")
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_route_trim = _chat_request_state.get("trim_stats", {}).get(
_actual_candidate_index,
{},
)
if _route_trim and (
_route_trim.get("messages_after") < _route_trim.get("messages_before")
or _route_trim.get("tokens_after") < _route_trim.get("tokens_before")
):
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = _route_trim.get("messages_before")
last_metrics["context_messages_after_trim"] = _route_trim.get("messages_after")
last_metrics["context_tokens_before_trim"] = _route_trim.get("tokens_before")
last_metrics["context_tokens_after_trim"] = _route_trim.get("tokens_after")
elif ctx.context_trimmed:
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
last_metrics["request_context_tokens"] = request_context_tokens
if ctx.context_length and request_context_tokens:
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
if _actual_context_length and last_metrics.get("input_tokens"):
pct = min(round((last_metrics["input_tokens"] / _actual_context_length) * 100, 1), 100.0)
last_metrics["context_percent"] = pct
last_metrics["context_length"] = ctx.context_length
last_metrics["context_length"] = _actual_context_length
# The frontend reads `tokens_per_second`; the raw usage event
# carries the backend's true gen speed as `gen_tps` (llama.cpp
# timings). Map it through so this direct-chat path shows real
@@ -1593,17 +1881,121 @@ def setup_chat_routes(
yield chunk
elif chunk.startswith("event: error"):
logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}")
if (
not _chat_terminal_saved
and (full_response.strip() or thinking_response.strip())
):
_failure_status = _stream_failure_status(chunk)
_failure_message = (
f"Model request failed (HTTP {_failure_status})"
if _failure_status is not None
else "Model request failed"
)
_terminal_content = full_response.strip()
_failure_note = f"[Response stopped: {_failure_message}]"
_terminal_content = (
f"{_terminal_content}\n\n{_failure_note}"
if _terminal_content
else _failure_note
)
_had_terminal_usage = bool(last_metrics)
_terminal_metrics = dict(last_metrics or {})
if not _had_terminal_usage:
_actual_request_messages = _chat_request_state["requests"].get(
_actual_candidate_index,
messages,
)
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_estimated_input = estimate_tokens(_actual_request_messages)
_estimated_output = max(
len(full_response + thinking_response) // 4,
0,
)
_terminal_metrics.update({
"input_tokens": _estimated_input,
"output_tokens": _estimated_output,
"total_tokens": _estimated_input + _estimated_output,
"usage_source": "estimated",
"response_time": round(time.time() - _chat_start, 2),
"context_length": _actual_context_length,
"context_percent": (
min(
round(
(_estimated_input / _actual_context_length) * 100,
1,
),
100.0,
)
if _actual_context_length
else 0
),
})
_terminal_metrics.update({
"failed": True,
"failure": {
"status": _failure_status,
"message": _failure_message,
},
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
})
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
_terminal_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
if thinking_response.strip():
_terminal_metrics["thinking"] = thinking_response.strip()
_commit_chat_compaction(_actual_candidate_index)
_saved_id = save_assistant_response(
sess,
session_manager,
session,
_terminal_content,
_terminal_metrics,
character_name=ctx.preset.character_name,
incognito=incognito,
)
accumulate_token_usage(session, _terminal_metrics)
_chat_terminal_saved = True
_stream_set(session, status="error")
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
yield f'data: {json.dumps({"type": "chat_terminal", "data": _terminal_metrics})}\n\n'
yield chunk
elif chunk.startswith("event: "):
yield chunk
elif chunk == "data: [DONE]\n\n":
if _chat_terminal_saved:
# Some providers append DONE after a terminal
# error. The failed partial is already saved;
# never re-save/post-process it as a success or
# advertise successful completion to the client.
continue
# Generate fallback metrics if LLM didn't send usage
if not last_metrics and full_response:
_elapsed = time.time() - _chat_start
_est_in = estimate_tokens(messages)
_est_out = len(full_response) // 4
_tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0
_ctx_pct = min(round((_est_in / ctx.context_length) * 100, 1), 100.0) if ctx.context_length else 0
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_actual_request_messages = _chat_request_state["requests"].get(
_actual_candidate_index,
messages,
)
_est_in = estimate_tokens(_actual_request_messages)
_ctx_pct = min(round((_est_in / _actual_context_length) * 100, 1), 100.0) if _actual_context_length else 0
last_metrics = {
"response_time": round(_elapsed, 2),
"input_tokens": _est_in,
@@ -1611,13 +2003,25 @@ def setup_chat_routes(
"tokens_per_second": _tps,
"request_context_tokens": _est_in,
"context_percent": _ctx_pct,
"context_length": ctx.context_length,
"context_length": _actual_context_length,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"usage_source": "estimated",
}
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
if full_response:
_commit_chat_compaction(_actual_candidate_index)
_metrics_to_save = dict(last_metrics or {})
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip()
@@ -1652,6 +2056,10 @@ def setup_chat_routes(
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
},
)
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
@@ -1666,6 +2074,12 @@ def setup_chat_routes(
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
_agent_requested_route = _foreground_route_descriptors[0]
_agent_actual_endpoint_id = _agent_requested_route.get("endpoint_id")
_agent_actual_endpoint_label = _agent_requested_route.get("endpoint_label")
_agent_round_models = {1: _requested_model}
_agent_round_endpoint_ids = {1: _agent_actual_endpoint_id}
_agent_round_endpoint_labels = {1: _agent_actual_endpoint_label}
try:
from src.settings import get_setting
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
@@ -1703,19 +2117,24 @@ def setup_chat_routes(
prompt_type=preset_id,
max_tool_calls=_tool_budget,
max_rounds=_max_rounds,
context_length=ctx.context_length,
context_length=_selected_context_length,
active_document=active_doc,
active_email=active_email_ctx,
session_id=session,
history_session=sess,
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
owner=_user,
fallbacks=_foreground_candidates[1:],
route_descriptors=_foreground_route_descriptors,
fallback_statuses=_foreground_policy.eligible_statuses,
fallback_on_empty=_foreground_policy.fallback_on_empty,
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -1744,7 +2163,20 @@ def setup_chat_routes(
"plan_update",
):
if data.get("type") == "agent_step":
_agent_rounds = max(_agent_rounds, data.get("round", 1))
_event_round = data.get("round", 1)
_agent_rounds = max(_agent_rounds, _event_round)
_agent_round_models.setdefault(
_event_round,
_actual_model or _answered_by or _requested_model,
)
_agent_round_endpoint_ids.setdefault(
_event_round,
_agent_actual_endpoint_id,
)
_agent_round_endpoint_labels.setdefault(
_event_round,
_agent_actual_endpoint_label,
)
elif data.get("type") == "tool_start":
_agent_tool_calls += 1
yield chunk
@@ -1754,13 +2186,70 @@ def setup_chat_routes(
# model so metrics reflect it, not the masked
# selected model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
_actual_model = _answered_by or _actual_model
if "answered_by_endpoint_id" in data:
_agent_actual_endpoint_id = data.get("answered_by_endpoint_id")
if data.get("answered_by_endpoint_label"):
_agent_actual_endpoint_label = data.get("answered_by_endpoint_label")
_event_round = data.get("round") or max(_agent_rounds, 1)
_agent_round_models[_event_round] = _answered_by or _requested_model
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
elif data.get("type") == "model_actual":
_actual_model = data.get("model") or _actual_model
if "endpoint_id" in data:
_agent_actual_endpoint_id = data.get("endpoint_id")
if data.get("endpoint_label"):
_agent_actual_endpoint_label = data.get("endpoint_label")
_event_round = data.get("round") or max(_agent_rounds, 1)
_agent_round_models[_event_round] = _actual_model or _requested_model
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
data["requested_model"] = _requested_model
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "agent_terminal":
terminal_metadata = dict(data.get("data") or {})
last_metrics = terminal_metadata
failure = terminal_metadata.get("failure") or {}
failure_status = _normalize_http_status(
failure.get("status")
)
failure_message = (
f"Model request failed (HTTP {failure_status})"
if failure_status is not None
else "Model request failed"
)
terminal_metadata["failure"] = {
"status": failure_status,
"message": failure_message,
}
terminal_content = full_response.strip()
failure_note = f"[Agent stopped: {failure_message}]"
if terminal_content:
terminal_content = f"{terminal_content}\n\n{failure_note}"
else:
terminal_content = failure_note
if not _terminal_saved:
_saved_id = save_assistant_response(
sess,
session_manager,
session,
terminal_content,
terminal_metadata,
character_name=ctx.preset.character_name,
web_sources=web_sources,
rag_sources=ctx.rag_sources,
used_memories=ctx.used_memories,
incognito=incognito,
)
_terminal_saved = True
accumulate_token_usage(session, terminal_metadata)
_stream_set(session, status="error")
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
yield chunk
elif data.get("type") == "metrics":
last_metrics = data.get("data", {})
_reported_model = last_metrics.get("model")
@@ -1772,7 +2261,16 @@ def setup_chat_routes(
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
_metrics_event = {"type": "metrics", "data": last_metrics}
# Inline teacher escalation marks its
# recursively emitted events at the SSE
# envelope. Preserve that non-secret marker
# when normalizing metrics so the browser's
# replay-stable ledger keeps primary and
# teacher segments distinct.
if data.get("teacher") is True:
_metrics_event["teacher"] = True
yield f'data: {json.dumps(_metrics_event)}\n\n'
except json.JSONDecodeError:
yield chunk
elif chunk.startswith("event: "):
@@ -1824,6 +2322,22 @@ def setup_chat_routes(
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _agent_actual_endpoint_id,
"endpoint_label": _agent_actual_endpoint_label,
"requested_endpoint_id": _agent_requested_route.get("endpoint_id"),
"requested_endpoint_label": _agent_requested_route.get("endpoint_label"),
"round_models": [
_agent_round_models.get(i, _actual_model or _requested_model)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
"round_endpoint_ids": [
_agent_round_endpoint_ids.get(i)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
"round_endpoint_labels": [
_agent_round_endpoint_labels.get(i)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
},
)
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
@@ -1866,8 +2380,12 @@ def setup_chat_routes(
if compare_mode:
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
agent_runs.start(session, _safe_stream())
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream")
_detached_run = agent_runs.start(session, _safe_stream())
return StreamingResponse(
agent_runs.subscribe(session, _detached_run),
media_type="text/event-stream",
headers={"X-Odysseus-Run-Id": _detached_run.run_id},
)
# ------------------------------------------------------------------ #
# GET /api/chat/resume — reconnect to a detached run that's still going
@@ -1876,9 +2394,14 @@ def setup_chat_routes(
@router.get("/api/chat/resume/{session_id}")
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
_verify_session_owner(request, session_id)
if not agent_runs.is_active(session_id):
_active_run = agent_runs.get_active_run(session_id)
if _active_run is None:
raise HTTPException(404, "No active run for this session")
return StreamingResponse(agent_runs.subscribe(session_id), media_type="text/event-stream")
return StreamingResponse(
agent_runs.subscribe(session_id, _active_run),
media_type="text/event-stream",
headers={"X-Odysseus-Run-Id": _active_run.run_id},
)
# ------------------------------------------------------------------ #
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
@@ -1887,7 +2410,8 @@ def setup_chat_routes(
@router.post("/api/chat/stop/{session_id}")
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
_verify_session_owner(request, session_id)
stopped = agent_runs.stop(session_id)
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
stopped = agent_runs.stop(session_id, _expected_run_id)
return {"stopped": stopped}
# ------------------------------------------------------------------ #
+3 -10
View File
@@ -5004,7 +5004,6 @@ def setup_email_routes():
from src.endpoint_resolver import (
resolve_endpoint,
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
from src.llm_core import llm_call_async_with_fallback
@@ -5066,8 +5065,6 @@ def setup_email_routes():
pass
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
if not candidates:
return {"success": False, "error": "No LLM endpoint configured"}
@@ -5327,13 +5324,11 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints and the active Utility
# fallback chain. The retired default-fallback hook stays empty.
# Dedupe by url+model so we don't retry the same broken endpoint.
# user's Utility / Default endpoints and active Utility fallback
# chain. Dedupe by url+model so we don't retry the same endpoint.
from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import (
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
_seen = set()
_candidates = []
@@ -5358,11 +5353,9 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers)
except Exception:
pass
# Active Utility fallbacks, then the retired default hook.
# Active Utility fallbacks last.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
_messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
+7 -1
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):
+51 -11
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():
@@ -26,14 +30,27 @@ def _save(prefs):
def _load_for_user(user: Optional[str] = None) -> dict:
"""Load preferences for a specific user."""
all_prefs = _load()
if "_users" in all_prefs:
users = all_prefs.get("_users")
if isinstance(users, dict):
if user is None:
# Auth disabled — return first user's prefs for backward compat
users = all_prefs["_users"]
return dict(next(iter(users.values()), {}))
return dict(all_prefs["_users"].get(user, {}))
# Legacy flat format — return as-is
return dict(all_prefs)
prefs = dict(next(iter(users.values()), {}))
# Foreground fallback consent is never borrowed from a named
# owner. Auth-disabled operation has a separate flat/root opt-in
# that remains inert when authentication is enabled again.
for key in _FOREGROUND_POLICY_KEYS:
prefs.pop(key, None)
if key in all_prefs:
prefs[key] = all_prefs[key]
return prefs
prefs = users.get(user, {})
return dict(prefs) if isinstance(prefs, dict) else {}
# A legacy flat store belongs only to auth-disabled single-user mode.
# Copying it into the first named user's new `_users` record during an
# auth transition would silently transfer another user's preferences and,
# critically, foreground fallback consent. Named owners therefore start
# with an empty record and must write their own preferences explicitly.
return dict(all_prefs) if user is None else {}
def _save_for_user(user: Optional[str], prefs: dict):
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
# `prefs` flat would overwrite the whole `_users` map and destroy every
# other user's preferences. Instead write back into the same (first)
# slot _load_for_user(None) reads from, preserving the others.
if "_users" in all_prefs:
users = all_prefs["_users"]
users = all_prefs.get("_users")
if isinstance(users, dict):
first_key = next(iter(users), None)
if first_key is not None:
users[first_key] = prefs
existing_named = users.get(first_key)
existing_named = (
dict(existing_named)
if isinstance(existing_named, dict)
else {}
)
named_foreground = {
key: existing_named[key]
for key in _FOREGROUND_POLICY_KEYS
if key in existing_named
}
users[first_key] = {
key: value
for key, value in prefs.items()
if key not in _FOREGROUND_POLICY_KEYS
}
users[first_key].update(named_foreground)
for key in _FOREGROUND_POLICY_KEYS:
if key in prefs:
all_prefs[key] = prefs[key]
_save(all_prefs)
return
_save(prefs)
return
if "_users" not in all_prefs:
all_prefs = {"_users": {}}
if not isinstance(all_prefs.get("_users"), dict):
# Preserve the flat single-user object as inert legacy data while
# creating the first named-owner namespace. In particular, historical
# fallback values must not be deleted or copied into the new owner.
all_prefs = dict(all_prefs)
all_prefs["_users"] = {}
all_prefs["_users"][user] = prefs
_save(all_prefs)
+1090 -291
View File
File diff suppressed because it is too large Load Diff
+84 -26
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,24 @@ def _publish(run: _Run, ev: str) -> None:
pass
def _schedule_evict(session_id: str) -> None:
def _wake_run_subscribers(run: _Run) -> None:
"""Close subscribers even when the drain task never reached its body."""
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
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 +100,38 @@ 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
_wake_run_subscribers(run)
# 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 +144,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 +165,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:
@@ -145,20 +179,37 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
prev_task: Optional[asyncio.Task] = None
if prev:
if prev.task and not prev.task.done():
# A task cancelled before its first instruction never enters
# _drain(), so its except/finally blocks cannot update status or
# wake a response already bound to this exact run. Terminalize it
# synchronously before cancelling; _drain's cleanup is idempotent
# when the task had already started.
if prev.status == "running":
prev.status = "stopped"
_wake_run_subscribers(prev)
prev.task.cancel()
prev_task = prev.task # new run awaits this before it starts writing
if prev.evict_task and not prev.evict_task.done():
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 +252,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
+18 -6
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}
+62 -2
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.
+216 -20
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,43 @@ _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
try:
ip = ipaddress.ip_address(host)
return ip.is_global
except ValueError:
pass
if "." not in host:
return False
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 +434,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 +468,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 +503,101 @@ 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_route_descriptor_by_id(
endpoint_id: str,
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> Optional[dict]:
"""Resolve a selected route's identity without relying on row order.
The explicit endpoint id is still verified against the resolved runtime
route. This prevents stale or mismatched request metadata from being used
for attribution while disambiguating endpoints whose routes are otherwise
identical.
"""
resolved = _resolve_endpoint_by_id_with_descriptor(
endpoint_id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
return None
candidate, descriptor = resolved
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
return descriptor if actual == expected else None
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
@@ -460,17 +611,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
+189 -14
View File
@@ -1,22 +1,155 @@
"""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,
resolve_route_descriptor_by_id,
)
_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()
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()
entries = entries[:MAX_FOREGROUND_FALLBACKS]
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,
)
# Known limitation of this test-only seam: alignment matches on model
# alone, so when two entries share a model and the resolver skips the
# first, the surviving candidate inherits the skipped entry's
# endpoint_id. Production uses the descriptor-aware branch below,
# which is unaffected.
resolved_routes = []
remaining_entries = list(entries)
for candidate in compatibility_candidates:
matching_index = next(
(
index for index, entry in enumerate(remaining_entries)
if isinstance(entry, dict)
and entry.get("model") == candidate[1]
),
None,
)
matching_entry = (
remaining_entries.pop(matching_index)
if matching_index is not None
else {}
)
descriptor = {
"endpoint_id": matching_entry.get("endpoint_id"),
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
}
resolved_routes.append((candidate, descriptor))
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 +157,50 @@ 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,
selected_endpoint_id: Optional[str] = None,
) -> list:
"""Build safe route metadata parallel to foreground candidates."""
policy = policy or resolve_foreground_model_policy(owner)
selected = None
if selected_endpoint_id:
selected = resolve_route_descriptor_by_id(
selected_endpoint_id,
endpoint_url,
model,
headers or {},
owner=owner,
)
if selected is None:
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
+885 -126
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
@field_validator('message')
@classmethod
+19 -1
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",
}
-5
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
-7
View File
@@ -1504,13 +1504,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>
+512 -118
View File
@@ -29,9 +29,16 @@ import {
createThinkingAnalysisGate,
stripLiveThinkingTags,
} from './liveThinkingThrottle.js';
import {
applyModelMetricsState,
applyModelRouteEventState,
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
let API_BASE = '';
@@ -394,13 +401,27 @@ import {
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');
}
@@ -570,6 +591,11 @@ import {
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt, cancelViewWork, finalizeView }
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 current send's detached run
const _streamGenerations = new Map(); // sessionId -> generation of the current (latest) send
const _sendStates = new Map(); // sessionId -> { generation, abortCtrl } of the current send, installed synchronously at send commit so Stop never has to borrow an older send's controller
const _pendingRunStops = new Map(); // 'sessionId:generation' -> abortCtrl|null; Stop queued for that send while it awaits headers. Keyed per send so concurrent sends' cancellation intents never displace each other.
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
@@ -608,6 +634,60 @@ import {
return now;
}
/** 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'}`;
}
/** POST the exact Stop for one observed run identity. */
function _postExactStop(sessionId, runId) {
fetch(`/api/chat/stop/${encodeURIComponent(sessionId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Odysseus-Run-Id': runId },
}).catch(() => {});
}
/** Stop only the exact detached run whose identity this browser observed. */
function _stopExactRun(sessionId, abortCtrl = null) {
if (!sessionId) return false;
const runId = _streamRunIds.get(sessionId);
if (!runId) {
// Queue against the CURRENT send's generation: its POST is the only
// identity channel that can name the run, so the Stop fires from that
// send's own header arrival even if a replacement starts meanwhile.
const generation = _streamGenerations.get(sessionId) || 0;
const pendingKey = sessionId + ':' + generation;
if (abortCtrl || !_pendingRunStops.has(pendingKey)) {
_pendingRunStops.set(pendingKey, abortCtrl);
}
return false;
}
_postExactStop(sessionId, runId);
return true;
}
function _rememberStreamRunId(sessionId, runId, generation) {
if (!sessionId || !runId) return;
// A superseded send must not record its run id as the session's current
// identity, but it must still flush its own queued Stop: this is the only
// channel that can cancel that run when the replacement dies before its
// own POST reaches the server.
if (_streamGenerations.get(sessionId) === generation) {
_streamRunIds.set(sessionId, runId);
}
const pendingKey = sessionId + ':' + generation;
if (!_pendingRunStops.has(pendingKey)) return;
const pendingAbort = _pendingRunStops.get(pendingKey);
_pendingRunStops.delete(pendingKey);
_postExactStop(sessionId, runId);
if (pendingAbort && !pendingAbort.signal.aborted) {
pendingAbort._reason = 'user-stop';
pendingAbort.abort();
}
}
// Sources box builder and toggleSources are now in chatRenderer.js
var _buildSourcesBox = chatRenderer.buildSourcesBox;
@@ -1342,6 +1422,26 @@ import {
if (messageInput) messageInput.disabled = false;
updateSubmitButton('streaming', submitBtn);
if (submitBtn) submitBtn.classList.remove('send-pending');
// Per-send generation, reserved SYNCHRONOUSLY before the send gate clears
// and before the first await: from this instant the superseded send may
// not clean session state, register, or POST (each checked at its own
// await boundaries). Session-keyed state (run id, queued Stop, cleanup
// rights) belongs to the latest generation only. A queued Stop from the
// superseded send is deliberately left in place, tagged with ITS
// generation: that send's still-alive POST is the only identity channel
// able to name its run, so the Stop fires from its own header arrival
// (see _rememberStreamRunId) even if this replacement dies before fetch.
const streamSessionId = sessionModule.getCurrentSessionId();
const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const _sendState = { generation: streamGeneration, abortCtrl: null };
_sendStates.set(streamSessionId, _sendState);
// The previous send's run identity dies with its ownership: a Stop after
// this instant must queue for THIS send, not fire against the old run.
// (The old send's own queued Stop still works — its flush carries the run
// id from its header, and its stale generation cannot repopulate this map.)
_streamRunIds.delete(streamSessionId);
_streamSessionId = streamSessionId;
_sendInFlight = false;
try {
@@ -1350,10 +1450,12 @@ import {
await pendingSwitch;
}
} catch (_) {}
// Superseded while awaiting the model switch: the replacement owns the
// session now, and everything below (state resets, registration, POST)
// is its business alone.
if (_streamGenerations.get(streamSessionId) !== streamGeneration) return;
// Capture session ID for background stream detection
const streamSessionId = sessionModule.getCurrentSessionId();
_streamSessionId = streamSessionId;
_terminalSavedStreams.delete(streamSessionId);
const streamQuery = msg;
_touchStreamActivity(streamSessionId);
@@ -1373,6 +1475,7 @@ import {
let _thinkOpen = false;
let holder = null;
let finalMeta = null;
let _canonicalTerminalSaved = false;
let spinner = null;
let timedOut = false;
let processingProbeTimer = null;
@@ -1742,8 +1845,26 @@ import {
}
// Superseded during preflight (uploads, document saves): a newer send
// owns the session. Bailing here — before registration and before the
// POST — keeps this stale send from overwriting the replacement's
// stream entry or reaching the server last, where agent_runs.start
// would cancel the newer run in favor of this old one.
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
// The optimistic user bubble is already in the DOM looking sent, but
// this message never reaches the server. Say so instead of leaving a
// ghost that vanishes on refresh.
if (_userMsgEl && _userMsgEl.parentNode) {
const _notSentNote = document.createElement('div');
_notSentNote.style.cssText = 'color: var(--color-error); font-style: italic; font-size: 0.85em; padding: 2px 0;';
_notSentNote.textContent = '[Not sent — superseded by a newer message]';
_userMsgEl.appendChild(_notSentNote);
}
return;
}
abortCtrl = new AbortController();
abortCtrl._reason = '';
_sendState.abortCtrl = abortCtrl;
currentAbort = abortCtrl;
const _tState = Storage.loadToggleState();
@@ -1755,15 +1876,28 @@ import {
if (!abortCtrl.signal.aborted) {
timedOut = true;
abortCtrl._reason = 'timeout';
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
// Superseded send: the session's run id and Stop queue belong to
// the replacement now. Just kill this hung POST.
abortCtrl.abort();
return;
}
let abortNow = true;
try {
if (streamSessionId) {
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {});
}
abortNow = _streamRunIds.has(streamSessionId)
? _stopExactRun(streamSessionId)
: _stopExactRun(streamSessionId, abortCtrl);
} catch (_) {}
abortCtrl.abort();
if (abortNow) {
abortCtrl.abort();
} else {
// The Stop is queued on the run-id header, but a request this
// stalled may never send one. Hard-abort after a short grace so
// the timeout still guarantees cancellation.
setTimeout(() => {
if (!abortCtrl.signal.aborted) abortCtrl.abort();
}, RUN_ID_ABORT_GRACE_MS);
}
}
}, timeoutMs);
clearResponseTimeout = () => {
@@ -1912,6 +2046,8 @@ import {
enableResearchBtn();
return;
}
const streamRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (streamRunId) _rememberStreamRunId(streamSessionId, streamRunId, streamGeneration);
// Mark the chat log busy while streaming so screen readers wait for the
// settled response instead of announcing every token. Cleared in finally.
@@ -1986,9 +2122,17 @@ import {
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');
@@ -2514,6 +2658,7 @@ import {
let _nextIsError = false;
let _streamSawDone = false;
let _streamTerminalError = null;
let _firstVisibleOutputSeen = false;
const markFirstVisibleOutput = () => {
if (_firstVisibleOutputSeen) return;
@@ -2638,10 +2783,9 @@ import {
// 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') {
@@ -3040,18 +3184,6 @@ import {
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) {
@@ -3059,6 +3191,10 @@ import {
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() : '');
@@ -3066,6 +3202,10 @@ import {
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
requestedEndpointId: holder._requestedEndpointId,
requestedEndpointLabel: holder._requestedEndpointLabel,
actualEndpointId: holder._actualEndpointId,
actualEndpointLabel: holder._actualEndpointLabel,
});
}
}
@@ -3076,9 +3216,10 @@ import {
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) ';
@@ -3086,13 +3227,14 @@ import {
(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,
});
}
}
@@ -3136,12 +3278,15 @@ import {
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') {
@@ -3227,15 +3372,60 @@ import {
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) {
@@ -3616,9 +3806,17 @@ import {
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');
@@ -3725,8 +3923,21 @@ import {
}
}
if (_streamTerminalError) {
throw _streamTerminalError;
}
if (!_streamSawDone) {
throw new Error('Stream closed before completion');
if (!_canonicalTerminalSaved) {
throw new Error('Stream closed before completion');
}
// The backend persisted a canonical terminal record (partial output +
// failure metadata) before the connection died. Route through the
// terminal-error path so that record is reloaded; falling through to
// the success renderer would present the partial output as a clean
// completion.
throw createTerminalStreamError({
text: 'Stream closed after canonical terminal event',
});
}
// The final foreground render below is authoritative. Cancel any delayed
@@ -3746,15 +3957,25 @@ import {
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;
@@ -4013,6 +4234,21 @@ import {
} // end if (!_isBgFinal)
} catch (err) {
// If a Stop or timeout was waiting for an identity header and the POST
// failed before producing one, keep this on the cancellation path. There
// is no safe headerless server cancel to send, but it must not be turned
// into an automatic recovery attempt either. Only this send's own
// queued Stop counts; a replacement's queued Stop is not ours to spend.
const _pendingCatchKey = streamSessionId + ':' + streamGeneration;
if (
_pendingRunStops.has(_pendingCatchKey)
&& abortCtrl
&& !abortCtrl.signal.aborted
) {
_pendingRunStops.delete(_pendingCatchKey);
abortCtrl._reason = 'user-stop';
abortCtrl.abort();
}
// Check if this stream was running in background — needed before any
// stop-state write, so an errored background stream can't clobber the
// foreground session's text.
@@ -4021,6 +4257,18 @@ import {
_closeOpenThinkingMarkup(_isBgCatch);
if (_isBgCatch) {
_cancelLiveThinkingWork();
// A canonical terminal event may have been persisted immediately
// before the stream moved into the background. Preserve that terminal
// state instead of allowing the catch path to turn it back into a
// running/error stream.
const bgTerminal = _backgroundStreams.get(streamSessionId);
if (bgTerminal && _terminalSavedStreams.has(streamSessionId)) {
bgTerminal.status = 'completed';
if (sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(streamSessionId);
}
}
} else if (accumulated) {
_catchTerminalView = _finalizeInterruptedView();
} else {
@@ -4039,7 +4287,10 @@ import {
// 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) {
@@ -4191,8 +4442,36 @@ import {
// 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(_catchViewHolder, accumulated, streamSessionId))) {
const errorHolder = _catchViewHolder?.querySelector('.body') || document.querySelector('.msg-ai:last-of-type .body');
if (!(isRecoverableStreamError(err) && _tryAutoRecover(_catchViewHolder, 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 =
_catchViewHolder?.querySelector('.body')
|| roundHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .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 =
_catchViewHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .body');
if (errorHolder) {
let errMsg = `Error: ${err.message}`;
// Add hint for tool-call errors
@@ -4209,23 +4488,52 @@ import {
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
_activeStreams.delete(streamSessionId);
if (_streamSessionId === streamSessionId) _streamSessionId = null;
_syncForegroundStreamGlobals();
// A replacement send bumps the session's generation the moment it
// starts, before it registers or reaches the server, so cleanup rights
// are decided by generation: a superseded send may remove only what it
// itself owns (its stream registration by controller identity, its own
// generation's queued Stop) and must leave session-level state — the
// reader session id, research marker, UI — to the replacement.
const _ownsStreamState =
_streamGenerations.get(streamSessionId) === streamGeneration;
const _finallyRegistered = _activeStreams.get(streamSessionId);
if (!_finallyRegistered || _finallyRegistered.abortCtrl === abortCtrl) {
_activeStreams.delete(streamSessionId);
}
_pendingRunStops.delete(streamSessionId + ':' + streamGeneration);
if (_ownsStreamState) {
if (_streamSessionId === streamSessionId) _streamSessionId = null;
if (_sendStates.get(streamSessionId) === _sendState) {
_sendStates.delete(streamSessionId);
}
// Superseded sends must not resync: with the replacement not yet
// registered, a stale sync would set isStreaming false and drop
// currentAbort while _sendInFlight is already false, reopening the
// send gate mid-preflight. The replacement syncs when it registers
// or finishes.
_syncForegroundStreamGlobals();
}
// Streaming done — let screen readers announce the settled response.
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
// Always clean up research tracking regardless of background state
_researchingStreamIds.delete(streamSessionId);
if (_ownsStreamState) {
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
}
// Research markers gate /api/research/cancel in the Stop handler, so a
// superseded send must not strip a replacement research run's marker.
if (_ownsStreamState) _researchingStreamIds.delete(streamSessionId);
if (_researchingStreamIds.size === 0) {
var _rToggleCleanup = document.getElementById('research-toggle-btn');
if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
}
// Only reset UI state if still on the stream's session and was never backgrounded
// Only reset UI state if still on the stream's session, never
// backgrounded, and no replacement stream owns the session now — the
// replacement disabled the composer for its own send, so re-enabling
// it here would hand input back mid-stream.
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (_ownsStreamState) _terminalSavedStreams.delete(streamSessionId);
if (!_isBgFinally) {
if (!_isBgFinally && _ownsStreamState) {
// Reset button to idle state
updateSubmitButton('idle', submitBtn);
@@ -4320,69 +4628,64 @@ import {
// the server run — otherwise closing the tab would kill the background task,
// defeating the whole point. Only the Stop button cancels the server run.
export function abortCurrentRequest(stopServer = false) {
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
// The CURRENT send's controller comes from its send state, installed at
// send commit — never borrowed from the stream registry, which during the
// replacement's preflight still holds the superseded send's entry.
// Aborting that older controller here would sever the only identity
// channel able to name the old run. A send committed but pre-POST has a
// null controller: the Stop queues and there is nothing to abort yet.
const _sendStateNow = _sid ? _sendStates.get(_sid) : null;
const active = _getForegroundStreamState();
const abortCtrl = active ? active.abortCtrl : currentAbort;
if (abortCtrl) {
abortCtrl.abort();
// Don't set to null here - let catch block handle it
}
const abortCtrl = _sendStateNow
? _sendStateNow.abortCtrl
: (active ? active.abortCtrl : currentAbort);
let abortNow = true;
if (stopServer) {
try {
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
if (_sid) {
fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
// Before response headers arrive there is no safe server-side stop
// identity yet. Keep the POST alive just long enough to receive that
// opaque id, then _rememberStreamRunId sends the exact Stop and aborts
// this reader. Never fall back to a headerless session-wide cancel.
abortNow = _stopExactRun(_sid, abortCtrl);
}
} catch (_) {}
}
if (abortCtrl && abortNow) {
abortCtrl.abort();
// Don't set to null here - let catch block handle it
}
}
// ── 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++;
if (holder && accumulated) {
holder.dataset.raw = accumulated;
}
_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;
}
@@ -4545,9 +4848,13 @@ import {
// view must stop all delayed rendering immediately. The reader loop may not
// receive another SSE line for an arbitrary amount of time.
if (active.cancelViewWork) active.cancelViewWork();
// 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,
@@ -4556,8 +4863,10 @@ import {
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;
@@ -4584,7 +4893,7 @@ import {
* 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;
@@ -4595,9 +4904,12 @@ import {
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
@@ -4612,6 +4924,8 @@ import {
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);
@@ -4629,6 +4943,8 @@ import {
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.
@@ -4665,6 +4981,8 @@ import {
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);
@@ -4674,7 +4992,9 @@ import {
}
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;
@@ -4690,6 +5010,64 @@ import {
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' ||
@@ -4700,7 +5078,8 @@ import {
}
}
} 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();
@@ -4710,6 +5089,18 @@ import {
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.
@@ -4726,6 +5117,9 @@ import {
// 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;
+104
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;
}
+254 -46
View File
@@ -615,10 +615,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);
}
@@ -629,10 +655,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',
};
}
/**
@@ -824,12 +864,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();
@@ -844,6 +922,9 @@ 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;
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) {
@@ -851,7 +932,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; }
}
@@ -863,6 +951,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();
}
@@ -871,21 +962,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));
@@ -895,6 +973,94 @@ 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 || metrics._costRecordPending) && !runId) return cost;
// Recorded is only set once the write actually runs; pending covers the
// window while the write waits on the cross-tab lock, so a replay in that
// window cannot double-add and a tab closed mid-queue never claims recorded.
metrics._costRecordPending = true;
const writeCost = () => {
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. The Web Lock around this
// read/modify/write also keeps distinct runs from two tabs from
// overwriting one another's stale snapshot.
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 */ }
}
metrics._costRecorded = true;
metrics._costRecordPending = false;
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (currentSid === sid) updateSessionCostUI();
};
let writeStarted = false;
const guardedWrite = () => {
writeStarted = true;
writeCost();
};
try {
if (
typeof navigator !== 'undefined'
&& navigator.locks
&& typeof navigator.locks.request === 'function'
) {
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
if (pendingWrite && typeof pendingWrite.catch === 'function') {
pendingWrite.catch(() => {
if (!writeStarted) guardedWrite();
});
}
} else {
guardedWrite();
}
} catch (_e) {
if (!writeStarted) guardedWrite();
}
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. */
@@ -1874,23 +2040,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;
@@ -2307,9 +2469,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;
@@ -2322,7 +2494,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;
@@ -2334,10 +2507,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));
@@ -2492,7 +2686,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)';
}
@@ -2503,8 +2704,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));
@@ -2788,6 +2995,7 @@ const chatRenderer = {
getSessionCost,
resetSessionCost,
updateSessionCostUI,
recordSessionMetricsCost,
roleTimestamp,
stripToolBlocks,
copyMessageText,
+23
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);
}
-73
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);
+6 -6
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')}`,
+4
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
# ---------------------------------------------------------------------------
@@ -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
+203
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,
}
+47
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,
}
+174 -1
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
+61
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
+5 -3
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
@@ -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
+64 -1
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):
+21 -1
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"
@@ -0,0 +1,933 @@
"""Executable regressions for the browser/run-lifecycle review of PR #6020.
These tests intentionally exercise JavaScript under Node rather than treating
``node --check`` or source-string presence as proof that the browser paths are
usable. The detached-run replacement case drives the real Python manager.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import re
import shutil
import subprocess
import pytest
from src import agent_runs
_REPO = Path(__file__).resolve().parents[1]
_CHAT_PATH = _REPO / "static" / "js" / "chat.js"
_CHAT = _CHAT_PATH.read_text(encoding="utf-8")
_RENDERER = (_REPO / "static" / "js" / "chatRenderer.js").read_text(
encoding="utf-8"
)
_STREAM_ERRORS_URI = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
_HAS_NODE = shutil.which("node") is not None
def _extract_source(source: str, start: str, end: str) -> str:
"""Slice module source between two anchors, failing loudly if one moved.
The extracted region ships to Node verbatim, so the anchors must stay
unique strings in the module. A refactor that renames or duplicates an
anchor fails here with the anchor named, not with an opaque split error.
"""
assert source.count(start) == 1, f"start anchor not unique in source: {start!r}"
tail = source.split(start, 1)[1]
assert end in tail, f"end anchor not found after start anchor: {end!r}"
return start + tail.split(end, 1)[0]
def _run_node(source: str) -> dict:
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
# A few imported browser modules log optional-service status at startup.
# Keep the runtime smoke honest while reading only its final JSON result.
return json.loads(proc.stdout.strip().splitlines()[-1])
def _chat_smoke_source(extra_source: str) -> str:
"""Return chat.js source with its real imports made absolute."""
def absolute_import(match: re.Match[str]) -> str:
relative = match.group("relative")
path_part, separator, query = relative.partition("?")
target = (_CHAT_PATH.parent / path_part).resolve().as_uri()
if separator:
target += "?" + query
return match.group("prefix") + target + match.group("quote")
source = re.sub(
r"(?P<prefix>from\s+(?P<quote>['\"]))(?P<relative>\./[^'\"]+)(?P=quote)",
absolute_import,
_CHAT,
)
source += "\n" + extra_source
return source
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_chat_runtime_stream_state_helpers_are_all_callable(tmp_path):
"""Import the real module and execute all three rebased-away helpers."""
module_source = _chat_smoke_source(
"""
export function __pr6020StreamStateSmoke() {
const sid = 'pr6020-runtime-smoke';
const controller = { abort() {}, signal: { aborted: false } };
const originalGetSessionId = sessionModule.getCurrentSessionId;
sessionModule.getCurrentSessionId = () => sid;
try {
_activeStreams.set(sid, {
abortCtrl: controller,
holder: { id: 'holder' },
lastActivity: 0,
});
const active = _getForegroundStreamState();
const touchedAt = _touchStreamActivity(sid);
const synced = _syncForegroundStreamGlobals();
return {
activeController: active && active.abortCtrl === controller,
touched: touchedAt > 0 && _activeStreams.get(sid).lastActivity === touchedAt,
synced: synced === active && currentAbort === controller && isStreaming,
};
} finally {
_activeStreams.delete(sid);
sessionModule.getCurrentSessionId = originalGetSessionId;
}
}
"""
)
module_path = tmp_path / "chat-runtime-smoke.mjs"
module_path.write_text(module_source, encoding="utf-8")
module_uri = module_path.as_uri()
script = f"""
globalThis.window = globalThis;
globalThis.addEventListener = () => {{}};
globalThis.removeEventListener = () => {{}};
globalThis.dispatchEvent = () => {{}};
globalThis.requestAnimationFrame = () => 0;
globalThis.cancelAnimationFrame = () => {{}};
globalThis.fetch = async () => ({{
ok: false,
json: async () => ({{}}),
text: async () => '',
headers: {{ get() {{ return null; }} }},
}});
class Element {{
constructor() {{
this.children = [];
this.classList = {{
add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }},
}};
this.style = {{ setProperty() {{}} }};
this.dataset = {{}};
}}
querySelector() {{ return null; }}
querySelectorAll() {{ return []; }}
appendChild(child) {{ this.children.push(child); return child; }}
addEventListener() {{}}
removeEventListener() {{}}
}}
class HTMLInputElement extends Element {{
get value() {{ return this._value || ''; }}
set value(value) {{ this._value = value; }}
}}
globalThis.HTMLInputElement = HTMLInputElement;
const root = new Element();
globalThis.document = {{
body: root,
head: root,
documentElement: root,
getElementById() {{ return null; }},
querySelector() {{ return null; }},
querySelectorAll() {{ return []; }},
createElement(tag) {{ return tag === 'input' ? new HTMLInputElement() : new Element(); }},
createTextNode(text) {{ return {{ textContent: text }}; }},
addEventListener() {{}},
removeEventListener() {{}},
}};
globalThis.localStorage = {{ getItem() {{ return null; }}, setItem() {{}}, removeItem() {{}} }};
globalThis.location = {{}};
globalThis.history = {{}};
globalThis.MutationObserver = class {{ observe() {{}} }};
globalThis.CustomEvent = class {{}};
globalThis.Storage = class {{}};
const chat = await import({json.dumps(module_uri)});
console.log(JSON.stringify(chat.__pr6020StreamStateSmoke()));
process.exit(0);
"""
assert _run_node(script) == {
"activeController": True,
"touched": True,
"synced": True,
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_canonical_terminal_followed_by_eof_is_not_auto_recovered():
"""A persisted terminal marker owns EOF; a plain premature EOF still retries."""
completion_gate = _extract_source(
_CHAT,
"if (_streamTerminalError)",
"// The final foreground render below is authoritative.",
)
script = f"""
import {{
createTerminalStreamError,
isRecoverableStreamError,
}} from {json.dumps(_STREAM_ERRORS_URI)};
function runCompletionGate(canonicalTerminalSaved) {{
let _streamTerminalError = null;
let _streamSawDone = false;
let _canonicalTerminalSaved = canonicalTerminalSaved;
try {{
{completion_gate}
return {{ recovered: false, completed: true }};
}} catch (error) {{
return {{
recovered: isRecoverableStreamError(error),
completed: false,
terminal: !!error.terminalStreamError,
message: error.message,
}};
}}
}}
console.log(JSON.stringify({{
savedTerminal: runCompletionGate(true),
plainEof: runCompletionGate(false),
}}));
"""
assert _run_node(script) == {
# A saved canonical terminal must neither auto-recover nor render as a
# clean success: it takes the terminal-error path, whose catch handler
# reloads the persisted record.
"savedTerminal": {
"recovered": False,
"completed": False,
"terminal": True,
"message": "Stream closed after canonical terminal event",
},
"plainEof": {
"recovered": True,
"completed": False,
"terminal": False,
"message": "Stream closed before completion",
},
}
@pytest.mark.asyncio
async def test_immediate_replacement_closes_subscriber_bound_to_never_started_run():
"""Cancellation before _drain's first instruction must still terminalize run 1."""
session_id = "pr6020-immediate-replacement"
agent_runs._RUNS.pop(session_id, None)
async def never_started():
yield 'data: {"delta":"old"}\n\n'
async def replacement():
yield 'data: {"delta":"new"}\n\n'
first = agent_runs.start(session_id, never_started())
first_subscription = asyncio.create_task(
_collect_run_events(session_id, first)
)
# Do not yield between starts: first.task is cancelled before _drain gets
# its first instruction, exactly the race a rapid double-send creates.
second = agent_runs.start(session_id, replacement())
assert await asyncio.wait_for(first_subscription, timeout=0.5) == []
await asyncio.wait_for(second.task, timeout=0.5)
assert first.status == "stopped"
assert second.status == "done"
async def _collect_run_events(session_id: str, run: object) -> list[str]:
return [event async for event in agent_runs.subscribe(session_id, run)]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_before_response_headers_waits_for_exact_run_identity():
"""Never send headerless Stop, but flush the queued Stop once headers arrive."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
header_capture = _extract_source(
_CHAT,
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
"// Mark the chat log busy",
)
script = f"""
const calls = [];
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
{state_and_stop}
{{
const streamSessionId = 'normal-session';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'normal-run' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
const normalHeaderCalls = calls.length;
let beforeHeaders;
{{
const streamSessionId = 'session-1';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
_stopExactRun(streamSessionId);
beforeHeaders = calls.length;
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'run-1' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
normalHeaderCalls,
beforeHeaders,
calls: calls.map(call => ({{
url: call.url,
method: call.options.method,
runId: call.options.headers['X-Odysseus-Run-Id'],
}})),
}}));
"""
assert _run_node(script) == {
"normalHeaderCalls": 0,
"beforeHeaders": 0,
"calls": [
{
"url": "/api/chat/stop/session-1",
"method": "POST",
"runId": "run-1",
}
],
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_timeout_before_response_headers_also_waits_for_exact_run_identity():
"""The automatic timeout must preserve the POST until its run id arrives."""
script = f"""
{_timeout_harness_prelude()}
callbacks[0]();
const beforeHeaders = {{ aborted: abortCtrl.signal.aborted, calls: calls.length }};
_rememberStreamRunId(streamSessionId, 'run-1', streamGeneration);
await Promise.resolve();
console.log(JSON.stringify({{
beforeHeaders,
afterHeaders: {{
aborted: abortCtrl.signal.aborted,
runId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
}},
}}));
"""
assert _run_node(script) == {
"beforeHeaders": {"aborted": False, "calls": 0},
"afterHeaders": {"aborted": True, "runId": "run-1"},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_resend_preserves_queued_stop_until_old_run_identity_arrives():
"""A replacement must not sever the superseded POST's identity channel.
The queued Stop stays generation-tagged and fires from the OLD send's own
header arrival, so the old run is cancelled even when the replacement dies
before its POST reaches the server (which is what would otherwise cancel
it). The old run id must not leak into the replacement's identity map.
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
resend_reset = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
header_capture = _extract_source(
_CHAT,
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
"// Mark the chat log busy",
)
script = f"""
const calls = [];
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
{state_and_stop}
const oldCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
// Old send (generation 1) queues a Stop before its headers arrive.
_streamGenerations.set('session-1', 1);
const oldGeneration = 1;
_stopExactRun('session-1', oldCtrl);
const queuedBefore = _pendingRunStops.has('session-1:1');
// Replacement send starts: bumps the generation, leaves the queued Stop.
{{
const streamSessionId = 'session-1';
{resend_reset}
}}
// The replacement is ALSO stopped before its headers arrive: both
// sends' cancellation intents must coexist, neither displacing the
// other (a single session-keyed slot loses the old send's Stop, and
// with it the only cancel for that run if this replacement dies
// before its own POST reaches the server).
const newCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
_stopExactRun('session-1', newCtrl);
const afterResend = {{
oldQueuedKept: _pendingRunStops.has('session-1:1'),
newQueued: _pendingRunStops.has('session-1:2'),
oldAborted: oldCtrl.signal.aborted,
generation: _streamGenerations.get('session-1'),
}};
// The old POST's headers finally arrive: its queued Stop fires with its
// own run id, and the old controller aborts.
{{
const streamSessionId = 'session-1';
const streamGeneration = oldGeneration;
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'old-run' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
queuedBefore,
afterResend,
afterOldHeaders: {{
oldQueued: _pendingRunStops.has('session-1:1'),
newQueuedKept: _pendingRunStops.has('session-1:2'),
oldAborted: oldCtrl.signal.aborted,
oldReason: oldCtrl._reason,
newAborted: newCtrl.signal.aborted,
currentRunIdPolluted: _streamRunIds.has('session-1'),
stopCalls: calls.map(call => ({{
url: call.url,
runId: call.options.headers['X-Odysseus-Run-Id'],
}})),
}},
}}));
"""
assert _run_node(script) == {
"queuedBefore": True,
"afterResend": {
"oldQueuedKept": True,
"newQueued": True,
"oldAborted": False,
"generation": 2,
},
"afterOldHeaders": {
"oldQueued": False,
# The replacement's own queued Stop must survive the old send's
# flush untouched.
"newQueuedKept": True,
"oldAborted": True,
"oldReason": "user-stop",
"newAborted": False,
# The stale send's run id must not become the replacement's
# identity, but its exact Stop must still go out.
"currentRunIdPolluted": False,
"stopCalls": [
{"url": "/api/chat/stop/session-1", "runId": "old-run"}
],
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_superseded_stream_cleanup_leaves_replacement_state_alone():
"""A stale send's finally must not clear state the replacement owns.
Ownership is decided by generation, which the replacement bumps at its
very first synchronous step so the guard holds even in the window
BEFORE the replacement registers its own stream entry (where the old
finally still sees its own registration and controller identity alone
would call it the owner).
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
finally_cleanup = _extract_source(
_CHAT,
"const _ownsStreamState =",
"// Streaming done — let screen readers announce",
)
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
function runCleanup(abortCtrl, streamGeneration) {{
const streamSessionId = 'session-1';
const _sendState = {{ generation: streamGeneration, abortCtrl }};
{finally_cleanup}
return _ownsStreamState;
}}
const oldCtrl = {{ signal: {{ aborted: true }}, abort() {{}} }};
const newCtrl = {{ signal: {{ aborted: false }}, abort() {{}} }};
// Pre-registration supersession: the replacement bumped the generation
// and set the session id, but has NOT registered its stream entry yet
// the old send's own entry is still the one in the map.
_streamGenerations.set('session-1', 2);
_streamSessionId = 'session-1';
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
_pendingRunStops.set('session-1:2', newCtrl);
const preRegOwns = runCleanup(oldCtrl, 1);
const afterPreReg = {{
ownEntryRemoved: !_activeStreams.has('session-1'),
replacementPendingKept: _pendingRunStops.has('session-1:2'),
sessionKept: _streamSessionId === 'session-1',
}};
// Post-registration supersession: the replacement's entry is in the map.
_activeStreams.set('session-1', {{ abortCtrl: newCtrl, holder: null, lastActivity: 2 }});
const postRegOwns = runCleanup(oldCtrl, 1);
const afterPostReg = {{
replacementRegistrationKept: _activeStreams.has('session-1'),
replacementPendingKept: _pendingRunStops.has('session-1:2'),
sessionKept: _streamSessionId === 'session-1',
}};
// Owner: the current-generation send cleans up normally.
const ownerOwns = runCleanup(newCtrl, 2);
const afterOwner = {{
registered: _activeStreams.has('session-1'),
pendingKept: _pendingRunStops.has('session-1:2'),
sessionCleared: _streamSessionId === null,
}};
console.log(JSON.stringify({{
preRegOwns, afterPreReg, postRegOwns, afterPostReg, ownerOwns, afterOwner,
}}));
"""
assert _run_node(script) == {
"preRegOwns": False,
"afterPreReg": {
"ownEntryRemoved": True,
"replacementPendingKept": True,
"sessionKept": True,
},
"postRegOwns": False,
"afterPostReg": {
"replacementRegistrationKept": True,
"replacementPendingKept": True,
"sessionKept": True,
},
"ownerOwns": True,
"afterOwner": {
"registered": False,
"pendingKept": False,
"sessionCleared": True,
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_real_reservation_supersedes_and_stale_cleanup_keeps_gate_closed():
"""Drive the REAL send-commit reservation, then a stale send's cleanup.
The reservation is synchronous, so the previous send is superseded before
any await runs; its cleanup must then neither clear session state nor
resync the foreground globals (a stale sync would set isStreaming false
while _sendInFlight is already false, reopening the send gate before the
replacement registers).
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
reservation = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
finally_cleanup = _extract_source(
_CHAT,
"const _ownsStreamState =",
"// Streaming done — let screen readers announce",
)
script = f"""
let currentAbort = null;
let isStreaming = true;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
// Old send (generation 1) is mid-stream and registered.
_streamGenerations.set('session-1', 1);
_streamSessionId = 'session-1';
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
// Replacement commits: run the REAL reservation block synchronously.
let installed;
{{
const streamSessionId = 'session-1';
{reservation}
installed = {{ generation: streamGeneration, sendState: _sendState }};
}}
const afterReservation = {{
generation: _streamGenerations.get('session-1'),
sendStateInstalled: _sendStates.get('session-1') === installed.sendState,
controllerPending: installed.sendState.abortCtrl === null,
}};
// Old send's cleanup runs mid-preflight (before the replacement
// registers): it must treat itself as superseded.
let staleOwns;
{{
const streamSessionId = 'session-1';
const streamGeneration = 1;
const abortCtrl = oldCtrl;
const _sendState = {{ generation: 1, abortCtrl: oldCtrl }};
{finally_cleanup}
staleOwns = _ownsStreamState;
}}
console.log(JSON.stringify({{
afterReservation,
staleOwns,
afterStaleCleanup: {{
sessionKept: _streamSessionId === 'session-1',
sendStateKept: _sendStates.get('session-1') === installed.sendState,
gateStillClosed: isStreaming === true,
}},
}}));
"""
assert _run_node(script) == {
"afterReservation": {
"generation": 2,
"sendStateInstalled": True,
"controllerPending": True,
},
"staleOwns": False,
"afterStaleCleanup": {
"sessionKept": True,
"sendStateKept": True,
# isStreaming untouched because the superseded send skipped the
# foreground resync entirely.
"gateStillClosed": True,
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stale_preflight_bails_before_creating_controller():
"""A send superseded during preflight must not proceed to register/POST."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
preflight_gate = _extract_source(
_CHAT, "// Superseded during preflight", "currentAbort = abortCtrl;"
) + "currentAbort = abortCtrl;"
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const document = {{
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
function runPreflightGate(streamGeneration, _sendState, _userMsgEl) {{
const streamSessionId = 'session-1';
let abortCtrl = null;
{preflight_gate}
return abortCtrl;
}}
// Stale: generation 1 resumes after generation 2 reserved the session.
// Its optimistic user bubble must be marked undelivered, not left as a
// ghost that looks sent.
_streamGenerations.set('session-1', 2);
const staleState = {{ generation: 1, abortCtrl: null }};
const staleBubble = {{
parentNode: {{}},
notes: [],
appendChild(node) {{ this.notes.push(node.textContent); }},
}};
const staleResult = runPreflightGate(1, staleState, staleBubble);
// Current: generation 2 proceeds and wires its controller.
const currentState = {{ generation: 2, abortCtrl: null }};
const currentBubble = {{
parentNode: {{}},
notes: [],
appendChild(node) {{ this.notes.push(node.textContent); }},
}};
const currentResult = runPreflightGate(2, currentState, currentBubble);
console.log(JSON.stringify({{
staleBailed: staleResult === undefined,
staleControllerNever: staleState.abortCtrl === null,
staleBubbleNotes: staleBubble.notes,
currentProceeded: !!currentResult,
currentWired: currentState.abortCtrl === currentResult && currentAbort === currentResult,
currentBubbleNotes: currentBubble.notes,
}}));
"""
assert _run_node(script) == {
"staleBailed": True,
"staleControllerNever": True,
"staleBubbleNotes": ["[Not sent — superseded by a newer message]"],
"currentProceeded": True,
"currentWired": True,
"currentBubbleNotes": [],
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_during_replacement_preflight_never_borrows_old_controller():
"""Stop must take the current send's controller from its send state.
During the replacement's preflight the stream registry still holds the
superseded send's entry; borrowing that controller would abort the only
identity channel able to name the old run while queueing a Stop for the
new one. A committed-but-pre-POST send has a null controller: the Stop
queues under the new generation and nothing is aborted yet.
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
abort_current = _extract_source(
_CHAT, "export function abortCurrentRequest", "// ── Stall watchdog"
).replace("export function", "function")
reservation = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const calls = [];
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
{abort_current}
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
// Generation 1 is registered, streaming, and its run id is KNOWN the
// exact window daybreak probed: a Stop right after the replacement
// commits must not consume the old run identity.
_streamGenerations.set('session-1', 1);
_streamRunIds.set('session-1', 'old-run');
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
currentAbort = oldCtrl;
// Replacement (generation 2) commits via the REAL reservation block; no
// controller exists yet and the model-switch await has not resolved.
{{
const streamSessionId = 'session-1';
{reservation}
}}
abortCurrentRequest(true);
const preRegistration = {{
oldRunIdCleared: !_streamRunIds.has('session-1'),
queuedForNew: _pendingRunStops.has('session-1:2'),
queuedController: _pendingRunStops.get('session-1:2') || null,
oldAborted: oldCtrl.signal.aborted,
stopCalls: calls.length,
}};
// Normal case: the current send's own controller, run id known.
const ownCtrl = {{ _reason: '', signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
_sendStates.set('session-1', {{ generation: 2, abortCtrl: ownCtrl }});
_streamRunIds.set('session-1', 'run-2');
abortCurrentRequest(true);
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
preRegistration,
normal: {{
ownAborted: ownCtrl.signal.aborted,
oldStillUntouched: oldCtrl.signal.aborted,
stopRunId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
}},
}}));
"""
assert _run_node(script) == {
"preRegistration": {
# The old run identity dies at reservation: the Stop queues for
# the NEW send instead of firing against the old run and skipping
# the queue entirely.
"oldRunIdCleared": True,
"queuedForNew": True,
"queuedController": None,
"oldAborted": False,
"stopCalls": 0,
},
"normal": {
"ownAborted": True,
"oldStillUntouched": False,
"stopRunId": "run-2",
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_timeout_grace_hard_aborts_when_run_identity_never_arrives():
"""A POST hung before headers is still cancelled by the timeout's grace."""
script = f"""
{_timeout_harness_prelude()}
callbacks[0]();
const afterTimeout = {{ aborted: abortCtrl.signal.aborted, pending: callbacks.length }};
callbacks[1]();
console.log(JSON.stringify({{
afterTimeout,
afterGrace: {{ aborted: abortCtrl.signal.aborted, stopCalls: calls.length }},
}}));
"""
assert _run_node(script) == {
"afterTimeout": {"aborted": False, "pending": 2},
"afterGrace": {"aborted": True, "stopCalls": 0},
}
def _timeout_harness_prelude() -> str:
"""Shared Node harness: real stop/state and timeout blocks, fake timers."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
timeout_setup = _extract_source(
_CHAT, "timeoutId = setTimeout(() =>", "}, timeoutMs);"
) + "}, timeoutMs);"
return f"""
const calls = [];
const callbacks = [];
function _setForegroundChatBusy() {{}}
function setTimeout(callback) {{ callbacks.push(callback); return 1; }}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
const RUN_ID_ABORT_GRACE_MS = 2000;
{state_and_stop}
const streamSessionId = 'session-1';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const timeoutMs = 1;
let timeoutId;
let timedOut = false;
const abortCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
{timeout_setup}
"""
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_cost_ledger_serializes_stale_cross_tab_writers():
"""A stale writer must merge, not overwrite a distinct run recorded by a peer."""
ledger = _extract_source(
_RENDERER, "const _COST_KEY", "/** Create a timestamp span"
).replace("export function", "function")
script = f"""
const state = {{}};
let triggerPeerWrite = true;
let lockTail = Promise.resolve();
const navigator = {{ locks: {{
request(_name, callback) {{
const next = lockTail.then(callback);
lockTail = next.catch(() => {{}});
return next;
}},
}} }};
const window = {{ sessionModule: {{ getCurrentSessionId() {{ return 'session'; }} }} }};
const document = {{ getElementById() {{ return null; }} }};
function _metricsBillableCost(metrics) {{ return metrics.testCost; }}
const peerMetrics = {{ testCost: 0.22, _costRecordId: 'run-b' }};
let tabA;
let tabB;
const localStorage = {{
getItem(key) {{
const staleSnapshot = state[key] || null;
if (key === 'ody-session-cost-runs' && triggerPeerWrite) {{
triggerPeerWrite = false;
tabB.recordSessionMetricsCost(peerMetrics, 'session');
}}
return staleSnapshot;
}},
setItem(key, value) {{ state[key] = value; }},
}};
function createTab() {{
{ledger}
return {{ recordSessionMetricsCost }};
}}
tabA = createTab();
tabB = createTab();
const metricsA = {{ testCost: 0.11, _costRecordId: 'run-a' }};
tabA.recordSessionMetricsCost(metricsA, 'session');
const queued = {{
recorded: !!metricsA._costRecorded,
pending: !!metricsA._costRecordPending,
}};
await new Promise(resolve => setTimeout(resolve, 0));
await lockTail;
const runs = JSON.parse(state['ody-session-cost-runs'] || '{{}}').session || {{}};
console.log(JSON.stringify({{
queued,
settled: {{
recorded: !!metricsA._costRecorded,
pending: !!metricsA._costRecordPending,
}},
runs,
}}));
"""
assert _run_node(script) == {
# Recorded must not be claimed while the write only sits queued behind
# the lock; it flips once the write has actually run.
"queued": {"recorded": False, "pending": True},
"settled": {"recorded": True, "pending": False},
"runs": {"run-a": 0.11, "run-b": 0.22},
}
+335
View File
@@ -0,0 +1,335 @@
"""Executable regression coverage for behavior lost in PR #6020's rebase."""
import asyncio
import json
import src.agent_loop as agent_loop
ODY_QWEN = "odysseus-qwen3-4b"
NOTES_TOOLS = {
"manage_notes",
"manage_calendar",
"manage_tasks",
"ask_user",
"update_plan",
}
def _collect(generator):
async def _run():
return [chunk async for chunk in generator]
return asyncio.run(_run())
def _events(chunks):
return [
json.loads(chunk[6:])
for chunk in chunks
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
]
def _install_route_probe(monkeypatch):
prompt_calls = []
stream_calls = []
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
monkeypatch.setattr(
agent_loop,
"_agent_route_tool_mode",
lambda *args, **kwargs: (True, False, False),
)
def fake_build(
messages,
model,
_active_document,
_mcp_mgr,
disabled_tools=None,
**kwargs,
):
prompt_calls.append(
{
"model": model,
"relevant_tools": set(kwargs.get("relevant_tools") or set()),
"disabled_tools": set(disabled_tools or set()),
"workspace": kwargs.get("workspace"),
}
)
return (list(messages), [])
async def fake_stream(_candidates, _messages, **kwargs):
stream_calls.append(kwargs)
yield 'data: {"delta": "ok"}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
return prompt_calls, stream_calls
def _run_probe(messages, *, relevant_tools, **kwargs):
return _collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
kwargs.pop("model", ODY_QWEN),
messages,
max_rounds=1,
relevant_tools=set(relevant_tools),
_is_teacher_run=True,
**kwargs,
)
)
def test_odysseus_notes_mode_clamps_and_reenables_all_personal_managers(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"bash", "manage_notes", "manage_calendar", "manage_tasks"},
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
)
route = prompt_calls[0]
assert route["relevant_tools"] == NOTES_TOOLS
assert route["disabled_tools"].isdisjoint(
{"manage_notes", "manage_calendar", "manage_tasks"}
)
def test_odysseus_general_mode_disables_every_tool(monkeypatch):
from src.tool_policy import known_tool_names
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Explain the CAP theorem."}],
relevant_tools={"bash", "manage_notes", "ask_user"},
)
route = prompt_calls[0]
assert route["relevant_tools"] == set()
assert known_tool_names() <= route["disabled_tools"]
def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add lunch tomorrow to my calendar."}],
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
messages = [
{"role": "user", "content": "Add lunch tomorrow to my calendar."},
{
"role": "assistant",
"content": "Done.",
"metadata": {
"tool_events": [
{
"tool": "manage_calendar",
"command": '{"action":"create_event","summary":"Lunch"}',
"output": "Created event evt-123 at noon.",
}
]
},
},
{"role": "user", "content": "Move it to 3pm."},
]
_run_probe(
messages,
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Fix the failing test in this project."}],
model="gpt-4o",
relevant_tools={"bash", "read_file", "apply_patch"},
workspace="/tmp/example-repo",
)
assert prompt_calls[0]["workspace"] == "/tmp/example-repo"
def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"manage_notes"},
temperature=1.2,
)
assert stream_calls[0]["temperature"] == 0.2
def test_qwen_fallback_candidate_gets_capped_temperature(monkeypatch):
"""A non-qwen primary must not leak its temperature into a qwen fallback."""
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Explain the CAP theorem."}],
model="gpt-4o",
relevant_tools={"bash"},
temperature=1.2,
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
)
assert stream_calls[0]["temperature"] == 1.2
factory = stream_calls[0]["candidate_request_factory"]
request = asyncio.run(factory(1, "https://qwen.example/v1", ODY_QWEN, {}))
assert request["kwargs"]["temperature"] == 0.2
def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
"""A qwen primary's 0.2 cap must not leak into a non-qwen fallback."""
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"manage_notes"},
temperature=1.2,
fallbacks=[("https://backup.example/v1", "gpt-4o", {})],
)
assert stream_calls[0]["temperature"] == 0.2
factory = stream_calls[0]["candidate_request_factory"]
request = asyncio.run(factory(1, "https://backup.example/v1", "gpt-4o", {}))
assert request["kwargs"]["temperature"] == 1.2
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
"""The answering candidate's notes mode must unblock the managers for
execution, not just enable them in its own route schemas."""
_install_route_probe(monkeypatch)
stream_round = 0
resolve_round = 0
seen_exec = {}
async def fake_stream(_candidates, _messages, **kwargs):
nonlocal stream_round
stream_round += 1
if stream_round == 1:
yield (
"data: "
+ json.dumps(
{
"type": "fallback",
"answered_by": ODY_QWEN,
"candidate_index": 1,
}
)
+ "\n\n"
)
yield 'data: {"delta": "Adding the note."}\n\n'
else:
yield 'data: {"delta": "Done."}\n\n'
yield "data: [DONE]\n\n"
def fake_resolve(*args, **kwargs):
nonlocal resolve_round
resolve_round += 1
if resolve_round == 1:
return ([agent_loop.ToolBlock("manage_notes", "{}")], False, [])
return ([], False, [])
async def fake_execute(block, *args, **kwargs):
# Execution is the consumer daybreak's probe showed rejecting the
# managers: it receives the shared disabled_tools set, not the
# answering route's own tool state.
seen_exec["disabled_tools"] = set(kwargs.get("disabled_tools") or [])
return ("manage_notes: saved", {"output": "noted", "exit_code": 0})
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
_collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
"gpt-4o",
[{"role": "user", "content": "Add buy milk to my notes."}],
max_rounds=2,
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
_is_teacher_run=True,
)
)
assert seen_exec["disabled_tools"].isdisjoint(
{"manage_notes", "manage_calendar", "manage_tasks"}
)
def test_persisted_mcp_tool_event_keeps_description_and_resolved_name(monkeypatch):
_install_route_probe(monkeypatch)
stream_round = 0
resolve_round = 0
async def fake_stream(_candidates, _messages, **kwargs):
nonlocal stream_round
stream_round += 1
if stream_round == 1:
yield 'data: {"delta": "Calling calendar."}\n\n'
else:
yield 'data: {"delta": "Finished."}\n\n'
yield "data: [DONE]\n\n"
def fake_resolve(*args, **kwargs):
nonlocal resolve_round
resolve_round += 1
if resolve_round == 1:
return ([agent_loop.ToolBlock("mcp", "{}")], False, [])
return ([], False, [])
async def fake_execute(block, *args, **kwargs):
assert block.tool_type == "mcp"
return (
"mcp__calendar__create_event: created team sync",
{"output": "Created event evt-456.", "exit_code": 0},
)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
chunks = _collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
"gpt-4o",
[{"role": "user", "content": "Create the team sync event."}],
max_rounds=2,
relevant_tools={"mcp"},
_is_teacher_run=True,
)
)
metrics = next(
event["data"] for event in _events(chunks) if event.get("type") == "metrics"
)
persisted = metrics["tool_events"][0]
assert persisted["tool"] == "mcp__calendar__create_event"
assert persisted["desc"] == "mcp__calendar__create_event: created team sync"
+23 -1
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"
@@ -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,
}
+150 -1
View File
@@ -3,8 +3,16 @@
import json
from types import SimpleNamespace
import pytest
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 +42,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 +60,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 +203,140 @@ 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_exact_id_descriptor_wins_when_routes_are_identical(monkeypatch):
first = _endpoint("account-one", "same-model")
second = _endpoint("account-two", "same-model")
for endpoint in (first, second):
endpoint.base_url = "https://provider.example/v1"
endpoint.api_key = "shared-key"
_install_resolver_fakes(monkeypatch, {}, [first, second])
import src.auth_helpers as auth_helpers
seen_owners = []
def scoped(query, model_cls, owner, *, include_shared=True):
seen_owners.append(owner)
return query
monkeypatch.setattr(auth_helpers, "owner_filter", scoped)
resolver = getattr(endpoint_resolver, "resolve_route_descriptor_by_id", None)
assert resolver is not None
assert resolver(
"account-two",
"https://provider.example/v1/chat/completions",
"same-model",
{"Authorization": "Bearer shared-key"},
owner="alice",
) == {
"endpoint_id": "account-two",
"endpoint_label": "Endpoint account-two",
"endpoint_cost_tracked": True,
}
assert seen_owners == ["alice"]
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
@pytest.mark.parametrize(
("url", "expected"),
[
("https://[2606:4700:4700::1111]/v1", True),
("http://169.254.10.20:8000/v1", False),
],
)
def test_endpoint_cost_tracking_classifies_public_ipv6_and_link_local_ipv4(url, expected):
assert endpoint_cost_tracked(url) is expected
@@ -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()
+119
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
+62 -1
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
+25
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