From f29e1853acf16ba080b1d8dcc5c97337485f6aa2 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:28:17 +0000 Subject: [PATCH 1/2] fix(model-routing): keep selected models strict --- routes/chat_routes.py | 22 +- routes/email_routes.py | 8 +- routes/model_routes.py | 34 +-- src/endpoint_resolver.py | 20 +- src/foreground_model_routing.py | 31 +++ src/llm_core.py | 9 +- src/settings.py | 13 +- src/task_endpoint.py | 2 +- static/index.html | 2 +- static/js/settings.js | 11 +- tests/test_foreground_model_routing.py | 277 +++++++++++++++++++++++ tests/test_legacy_default_fallback_ui.py | 29 +++ tests/test_model_defaults.py | 49 +++- tests/test_model_routes.py | 13 +- 14 files changed, 417 insertions(+), 103 deletions(-) create mode 100644 src/foreground_model_routing.py create mode 100644 tests/test_foreground_model_routing.py create mode 100644 tests/test_legacy_default_fallback_ui.py diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b081d5f1c..6099e72fd 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -21,6 +21,7 @@ from src.model_context import estimate_tokens from src.chat_helpers import coerce_message_and_session from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url +from src.foreground_model_routing import build_foreground_model_candidates from src.session_search import search_session_messages from src.prompt_security import untrusted_context_message from core.exceptions import SessionNotFoundError @@ -1399,14 +1400,14 @@ def _on_research_done(_sid, _result, _sources, _findings): thinking_response = "" last_metrics = None - # Configured fallback chain for the default chat model. Tried in - # order if the session's primary model fails before producing - # output. Resolved once per request. - try: - from src.endpoint_resolver import resolve_chat_fallback_candidates - _fallback_candidates = resolve_chat_fallback_candidates(owner=_user) - except Exception: - _fallback_candidates = [] + # Foreground Chat and Agent requests use one owner-aware policy + # boundary. Legacy `default_model_fallbacks` data is not eligible. + _foreground_candidates = build_foreground_model_candidates( + sess.endpoint_url, + sess.model, + sess.headers, + owner=_user, + ) # Send model name early so the frontend can show it during streaming _model_suffix = "Research" if effective_do_research else None @@ -1522,9 +1523,8 @@ async def _image_progress_callback(progress: Dict[str, Any]): _actual_model = None # ── Chat mode: call stream_llm directly, NO tools, NO document access ── try: - _chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates async for chunk in stream_llm_with_fallback( - _chat_candidates, + _foreground_candidates, messages, temperature=ctx.preset.temperature, # Respect the preset; 0/unset = let the server decide (no @@ -1710,7 +1710,7 @@ async def _image_progress_callback(progress: Dict[str, Any]): disabled_tools=disabled_tools if disabled_tools else None, tool_policy=tool_policy, owner=_user, - fallbacks=_fallback_candidates, + fallbacks=_foreground_candidates[1:], plan_mode=plan_mode, approved_plan=approved_plan or None, workspace=workspace or None, diff --git a/routes/email_routes.py b/routes/email_routes.py index 3c8e407bd..0d15f2573 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -5209,9 +5209,9 @@ async def ai_reply(data: dict, owner: str = Depends(require_owner)): # Build a candidate chain so a stale session-stored API key # (the most common cause of "authentication failed" here) # doesn't kill AI Reply outright — fall through to the - # user's Utility / Default endpoints AND their configured - # fallback chains. Dedupe by url+model so we don't retry - # the same broken endpoint. + # user's Utility / Default endpoints and the active Utility + # fallback chain. The retired default-fallback hook stays empty. + # Dedupe by url+model so we don't retry the same broken endpoint. from src.llm_core import llm_call_async_with_fallback from src.endpoint_resolver import ( resolve_utility_fallback_candidates, @@ -5240,7 +5240,7 @@ def _add(_url, _model, _headers): _add(_d_url, _d_model, _d_headers) except Exception: pass - # Configured fallback chains last. + # Active Utility fallbacks, then the retired default hook. for cand in resolve_utility_fallback_candidates(owner=owner) or []: _add(*cand) for cand in resolve_chat_fallback_candidates(owner=owner) or []: diff --git a/routes/model_routes.py b/routes/model_routes.py index 600150a66..de8f884cb 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -46,10 +46,11 @@ } _ENDPOINT_FALLBACK_FIELDS = { - "default_model_fallbacks": "Default Model Fallbacks", "utility_model_fallbacks": "Utility Model Fallbacks", "vision_model_fallbacks": "Vision Model Fallbacks", } +# `default_model_fallbacks` is intentionally absent. The legacy data remains +# stored as-is even when an endpoint is removed, but no longer affects routing. def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list: @@ -2437,7 +2438,6 @@ def get_default_chat(request: Request): _user_prefs = _load_for_user(_user) or {} ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() model = (_user_prefs.get("default_model") or "").strip() - _fallbacks = _user_prefs.get("default_model_fallbacks") or [] # If user has no personal default, fall back to global default # But only based on the "share_defaults_with_users" flag # (only if share_defaults_with_users is enabled) @@ -2446,12 +2446,9 @@ def get_default_chat(request: Request): ep_id = settings.get("default_endpoint_id", "") if not model: model = settings.get("default_model", "") - if not _fallbacks: - _fallbacks = settings.get("default_model_fallbacks") or [] else: ep_id = settings.get("default_endpoint_id", "") model = settings.get("default_model", "") - _fallbacks = settings.get("default_model_fallbacks") or [] db = SessionLocal() try: ep = None @@ -2466,33 +2463,6 @@ def get_default_chat(request: Request): if _user and not _is_admin: ep_q = owner_filter(ep_q, ModelEndpoint, _user) ep = ep_q.first() - # Configured fallback chain — when the chosen default endpoint is - # gone/disabled, honor the user's configured `default_model_fallbacks` - # in order BEFORE arbitrarily grabbing the first enabled endpoint. - # (Previously this jumped straight to "first enabled", which is why - # deleting/changing the main endpoint silently reassigned the default - # chat to some unrelated endpoint instead of the fallback.) - if not ep: - for entry in _fallbacks: - if not isinstance(entry, dict): - continue - fid = (entry.get("endpoint_id") or "").strip() - if not fid: - continue - cand_q = db.query(ModelEndpoint).filter( - ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True - ) - if _user and not _is_admin: - cand_q = owner_filter(cand_q, ModelEndpoint, _user) - cand = cand_q.first() - if cand: - ep = cand - # Use the fallback entry's model. Reset even when empty - # so we don't carry the prior endpoint's stale model onto - # this fallback — the cached-models lookup below then - # fills it from the fallback endpoint. - model = (entry.get("model") or "").strip() - break # Last resort: first enabled endpoint owned by THIS user. Do not # include null-owner/shared endpoints here: a brand-new user with # no explicit default should not auto-open a pending chat using an diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index 71f260fa2..1bb8fc3af 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -443,28 +443,14 @@ def resolve_endpoint_by_id( def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list: - """Build the configured default-chat fallback chain as a list of - (chat_url, model, headers) tuples, skipping any that can't resolve. + """Compatibility shim for the retired default-chat fallback chain.""" - The primary model is NOT included — callers prepend their session's - current (url, model, headers) so per-session model overrides are honored. - """ - return _resolve_fallback_candidates("default_model_fallbacks", owner=owner) + del owner + return [] def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list: """Configured fallback chain for the Utility model (`utility_model_fallbacks`).""" - try: - from src.settings import get_user_setting, load_settings - settings = load_settings() - utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip() - if not utility_ep: - utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or [] - if utility_chain: - return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner) - return _resolve_fallback_candidates("default_model_fallbacks", owner=owner) - except Exception: - pass return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner) diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py new file mode 100644 index 000000000..241ccf26b --- /dev/null +++ b/src/foreground_model_routing.py @@ -0,0 +1,31 @@ +"""Foreground Chat and Agent model-routing policy. + +The selected session model is strict by default. Historical +``default_model_fallbacks`` values remain stored for compatibility, but this +policy intentionally does not read or migrate them. +""" + +from typing import Any, Dict, Optional + + +def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list: + """Return fallback candidates for a foreground Chat or Agent request. + + Foreground routing is strict, so no alternate endpoint/model is eligible. + ``owner`` is accepted to keep this policy boundary owner-aware. + """ + + del owner + return [] + + +def build_foreground_model_candidates( + endpoint_url: str, + model: str, + headers: Optional[Dict[str, Any]] = None, + owner: Optional[str] = None, +) -> list: + """Build the ordered candidate list for a foreground request.""" + + primary = (endpoint_url, model, headers or {}) + return [primary] + resolve_foreground_fallback_candidates(owner=owner) diff --git a/src/llm_core.py b/src/llm_core.py index 4dec32376..bb735cb3e 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1885,11 +1885,10 @@ def _dedupe_candidates(candidates): """Filter malformed entries and drop a later repeat of an already-seen ``(url, model)`` route, preserving order (first occurrence wins). - The chain is the primary target followed by the configured fallbacks, so a - fallback that repeats the session's current model — a common misconfiguration, - since callers prepend the live ``(url, model)`` to ``default_model_fallbacks`` - — would otherwise make the chain re-attempt the very route that just failed: - a wasted round-trip plus a spurious ``fallback`` notice for a switch that did + The chain is the primary target followed by any caller-authorized + fallbacks. A fallback that repeats the session's current model would + otherwise make the chain re-attempt the very route that just failed: a + wasted round-trip plus a spurious ``fallback`` notice for a switch that did not happen. Headers are not part of the key; the first tuple (with its headers) is the one kept. """ diff --git a/src/settings.py b/src/settings.py index 5836765f1..da08717d5 100644 --- a/src/settings.py +++ b/src/settings.py @@ -138,14 +138,13 @@ def _invalidate_caches(): # Email replies use email_writing_style instead because greetings, # signatures, and mailbox identity rules are medium-specific. "document_writing_style": "", - # Ordered fallback chain for the default chat model. Each entry is - # {"endpoint_id": "...", "model": "..."}. If the primary model fails - # before producing output (endpoint offline / errors), the chat - # dispatch retries the next entry in order. + # Legacy ordered fallback chain for the default chat model. Values remain + # stored for compatibility and rollback reference, but model routing no + # longer reads this key. "default_model_fallbacks": [], - # When True, non-admin users inherit global default model/endpoint/fallbacks - # when they have no personal defaults. When False, users only use their - # personal defaults (no global fallback). Default is False. + # When True, non-admin users inherit the global default model/endpoint when + # they have no personal defaults. When False, users only use their personal + # defaults. Default is False. "share_defaults_with_users": False, "utility_endpoint_id": "", "utility_model": "", diff --git a/src/task_endpoint.py b/src/task_endpoint.py index b9c290d65..ae57a81f7 100644 --- a/src/task_endpoint.py +++ b/src/task_endpoint.py @@ -32,7 +32,7 @@ def resolve_task_candidates( 2. Utility endpoint/model 3. Default endpoint/model 4. Utility fallback chain - 5. Default fallback chain + 5. Retired default-fallback compatibility hook (currently empty) """ candidates = [] diff --git a/static/index.html b/static/index.html index 8257660fe..d1a960188 100644 --- a/static/index.html +++ b/static/index.html @@ -1482,7 +1482,7 @@

+
diff --git a/static/js/chat.js b/static/js/chat.js index ea2d8c1bb..48286a422 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -22,7 +22,13 @@ import codeRunnerModule from './codeRunner.js'; import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260722emailfastindex1'; import createResearchSynapse from './researchSynapse.js'; import { createStreamRenderer } from './streamingRenderer.js'; -import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall'; +import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js'; +import { + applyModelMetricsState, + applyModelRouteEventState, + inheritModelRouteState, +} from './chatModelProvenance.js'; +import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js'; const RESEARCH_TIMEOUT_MS = 360000; const DEFAULT_TIMEOUT_MS = 120000; @@ -385,13 +391,27 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const tsSpan = roleEl.querySelector('.role-timestamp'); const req = requestedModel || actualModel || ''; const actual = actualModel || requestedModel || ''; - let label = _modelRouteLabel(req, actual); + let label = _modelRouteLabel( + req, + actual, + opts.requestedEndpointLabel, + opts.actualEndpointLabel, + opts.requestedEndpointId, + opts.actualEndpointId, + ); if (opts.suffix) label += ' (' + opts.suffix + ')'; if (opts.characterName) label = opts.characterName; roleEl.textContent = label + ' '; _applyModelColor(roleEl, actual || req); - if (req && actual && !_sameModelName(req, actual)) { - roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : ''); + const endpointChanged = Boolean( + opts.requestedEndpointId + && opts.actualEndpointId + && opts.requestedEndpointId !== opts.actualEndpointId + ); + if (req && actual && (!_sameModelName(req, actual) || endpointChanged)) { + roleEl.title = req + ' -> ' + actual + + (endpointChanged ? ' (' + opts.requestedEndpointLabel + ' -> ' + opts.actualEndpointLabel + ')' : '') + + (opts.reason ? ': ' + opts.reason : ''); } else if (!opts.reason) { roleEl.removeAttribute('title'); } @@ -561,6 +581,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics } const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt } const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock) + const _terminalSavedStreams = new Set(); // sessionId -> canonical terminal event seen by active reader + const _streamRunIds = new Map(); // sessionId -> opaque identity of the exact detached run let _streamSessionId = null; // Session ID for the currently active reader loop let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams let _webLockRelease = null; // Function to release the Web Lock held during streaming @@ -573,30 +595,23 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr _resumingStreams.has(sessionId); } - function _getForegroundStreamState() { - try { - const sid = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId(); - return sid ? (_activeStreams.get(sid) || null) : null; - } catch (_) { - return null; - } - } - - function _syncForegroundStreamGlobals() { - const active = _getForegroundStreamState(); - isStreaming = !!active; - currentAbort = active ? active.abortCtrl : null; - currentHolder = active ? active.holder : null; - _setForegroundChatBusy(!!active || !!_sendInFlight); - return active; + /** Stable cost identity for one logical metrics segment within a run. */ + function _metricsCostRecordId(runId, event) { + if (!runId) return ''; + return `${runId}:${event && event.teacher ? 'teacher' : 'primary'}`; } - function _touchStreamActivity(sessionId) { - const now = Date.now(); - _lastReaderActivity = now; - const active = sessionId ? _activeStreams.get(sessionId) : null; - if (active) active.lastActivity = now; - return now; + /** Stop only the exact detached run whose identity this browser observed. */ + function _stopExactRun(sessionId) { + if (!sessionId) return false; + const runId = _streamRunIds.get(sessionId); + if (!runId) return false; + fetch(`/api/chat/stop/${encodeURIComponent(sessionId)}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-Odysseus-Run-Id': runId }, + }).catch(() => {}); + return true; } // Sources box builder and toggleSources are now in chatRenderer.js @@ -1341,6 +1356,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Capture session ID for background stream detection const streamSessionId = sessionModule.getCurrentSessionId(); _streamSessionId = streamSessionId; + _terminalSavedStreams.delete(streamSessionId); + _streamRunIds.delete(streamSessionId); const streamQuery = msg; _touchStreamActivity(streamSessionId); @@ -1360,6 +1377,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr let _thinkOpen = false; let holder = null; let finalMeta = null; + let _canonicalTerminalSaved = false; let spinner = null; let timedOut = false; let processingProbeTimer = null; @@ -1729,14 +1747,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (!abortCtrl.signal.aborted) { timedOut = true; abortCtrl._reason = 'timeout'; - try { - if (streamSessionId) { - fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, { - method: 'POST', - credentials: 'same-origin', - }).catch(() => {}); - } - } catch (_) {} + try { _stopExactRun(streamSessionId); } catch (_) {} abortCtrl.abort(); } }, timeoutMs); @@ -1882,6 +1893,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr enableResearchBtn(); return; } + const streamRunId = res.headers.get('X-Odysseus-Run-Id') || ''; + if (streamRunId) _streamRunIds.set(streamSessionId, streamRunId); // Mark the chat log busy while streaming so screen readers wait for the // settled response instead of announcing every token. Cleared in finally. @@ -1953,9 +1966,17 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const newRole = document.createElement('div'); newRole.className = 'role'; const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId); - const requested = holder?._requestedModel || metaS?.model || modelName; - const actual = holder?._actualModel || requested; - newRole.textContent = _modelRouteLabel(requested, actual) || ''; + inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName); + const requested = newWrap._requestedModel; + const actual = newWrap._actualModel; + newRole.textContent = _modelRouteLabel( + requested, + actual, + newWrap._requestedEndpointLabel, + newWrap._actualEndpointLabel, + newWrap._requestedEndpointId, + newWrap._actualEndpointId, + ) || ''; _applyModelColor(newRole, actual); newWrap.appendChild(newRole); const newBody = document.createElement('div'); @@ -2185,6 +2206,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr let _nextIsError = false; let _streamSawDone = false; + let _streamTerminalError = null; let _firstVisibleOutputSeen = false; const markFirstVisibleOutput = () => { if (_firstVisibleOutputSeen) return; @@ -2310,10 +2332,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Handle SSE error events (e.g. HTTP 404 from provider) if (_nextIsError || json.status >= 400) { _nextIsError = false; - const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`; - console.error('Stream error:', errMsg); + _streamTerminalError = createTerminalStreamError(json); + console.error('Stream error:', _streamTerminalError.message); if (spinner && spinner.element) spinner.destroy(); - typewriterInto(roundHolder.querySelector('.body'), errMsg); break; } if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') { @@ -2757,18 +2778,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr 6000 ); continue; - } else if (json.type === 'model_fallback') { - // Model went offline — switched to fallback - var _fbData = json.data || {}; - uiModule.showToast( - `Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`, - 5000 - ); - // Update the model picker to reflect the new model - if (sessionModule && sessionModule.updateModelPicker) { - sessionModule.updateModelPicker(); - } - continue; } else if (json.type === 'model_info') { // Update role label with model name as soon as we know it if (!_isBg && holder) { @@ -2776,6 +2785,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (roleEl) { holder._requestedModel = json.requested_model || json.model || holder._requestedModel; holder._actualModel = json.model || holder._actualModel || holder._requestedModel; + holder._requestedEndpointId = json.requested_endpoint_id || json.endpoint_id || holder._requestedEndpointId || null; + holder._requestedEndpointLabel = json.requested_endpoint_label || json.endpoint_label || holder._requestedEndpointLabel || 'Selected route'; + holder._actualEndpointId = json.endpoint_id || holder._actualEndpointId || holder._requestedEndpointId; + holder._actualEndpointLabel = json.endpoint_label || holder._actualEndpointLabel || holder._requestedEndpointLabel; if (json.suffix) holder._roleSuffix = json.suffix; // Prepend character name if sent by server or set locally var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''); @@ -2783,6 +2796,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr _setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, { suffix: holder._roleSuffix, characterName: holder._characterName, + requestedEndpointId: holder._requestedEndpointId, + requestedEndpointLabel: holder._requestedEndpointLabel, + actualEndpointId: holder._actualEndpointId, + actualEndpointLabel: holder._actualEndpointLabel, }); } } @@ -2793,9 +2810,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (!_isBg) { var _selM = _shortModel(json.selected_model || ''); var _ansM = _shortModel(json.answered_by || ''); - uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000); - if (holder) { - var _rEl = holder.querySelector('.role'); + uiModule.showToast('Fallback: ' + _selM + ' failed — answered by ' + _ansM, 6000); + var _fallbackHolder = applyModelRouteEventState(json, holder, roundHolder, modelName); + if (_fallbackHolder) { + var _rEl = _fallbackHolder.querySelector('.role'); if (_rEl) { var _tsS = _rEl.querySelector('.role-timestamp'); _rEl.textContent = _ansM + ' (fallback) '; @@ -2803,13 +2821,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr (json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || ''); _applyModelColor(_rEl, json.answered_by); if (_tsS) _rEl.appendChild(_tsS); - holder._requestedModel = json.selected_model || holder._requestedModel || modelName; - const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel); - holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel); - _setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, { - suffix: holder._roleSuffix, - characterName: holder._characterName, + _setRoleModelLabel(_rEl, _fallbackHolder._requestedModel, _fallbackHolder._actualModel, { + suffix: _fallbackHolder._roleSuffix, + characterName: _fallbackHolder._characterName, reason: json.reason, + requestedEndpointId: _fallbackHolder._requestedEndpointId, + requestedEndpointLabel: _fallbackHolder._requestedEndpointLabel, + actualEndpointId: _fallbackHolder._actualEndpointId, + actualEndpointLabel: _fallbackHolder._actualEndpointLabel, }); } } @@ -2853,12 +2872,15 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); } } } else if (json.type === 'model_actual') { - if (!_isBg && holder) { - holder._requestedModel = json.requested_model || holder._requestedModel || modelName; - holder._actualModel = json.model || holder._actualModel || holder._requestedModel; - _setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, { - suffix: holder._roleSuffix, - characterName: holder._characterName, + if (!_isBg) { + var _modelHolder = applyModelRouteEventState(json, holder, roundHolder, modelName); + if (_modelHolder) _setRoleModelLabel(_modelHolder.querySelector('.role'), _modelHolder._requestedModel, _modelHolder._actualModel, { + suffix: _modelHolder._roleSuffix, + characterName: _modelHolder._characterName, + requestedEndpointId: _modelHolder._requestedEndpointId, + requestedEndpointLabel: _modelHolder._requestedEndpointLabel, + actualEndpointId: _modelHolder._actualEndpointId, + actualEndpointLabel: _modelHolder._actualEndpointLabel, }); } } else if (json.type === 'attachments') { @@ -2944,15 +2966,60 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : ''; uiModule.showToast(`Context trimmed for this model${detail}`); } + } else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') { + // The backend persisted canonical partial output, sanitized + // failure metadata, and actual-route provenance before this + // event. The terminal catch below reloads that exact record. + _canonicalTerminalSaved = true; + _terminalSavedStreams.add(streamSessionId); + const priorMetrics = metrics; + metrics = json.data || metrics; + if (metrics && streamRunId) { + metrics._costRecordId = _metricsCostRecordId(streamRunId, json); + } + // Direct Chat may have emitted provider usage before its + // terminal event. Carry that already-recorded state onto the + // canonical terminal metadata instead of billing it twice. + if (priorMetrics && priorMetrics._costRecorded && metrics) { + metrics._costRecorded = true; + } + if (_isBg) { + var bgTerminal = _backgroundStreams.get(streamSessionId); + if (bgTerminal) { + if ( + bgTerminal.metrics + && bgTerminal.metrics._costRecorded + && metrics + ) { + metrics._costRecorded = true; + } + bgTerminal.metrics = metrics; + bgTerminal.status = 'completed'; + if (metrics) { + chatRenderer.recordSessionMetricsCost(metrics, streamSessionId); + } + } + continue; + } + if (holder && metrics) { + applyModelMetricsState(metrics, holder, roundHolder, modelName); + const terminalMetricsTarget = _metricsTargetForTurn(); + if (terminalMetricsTarget) displayMetrics(terminalMetricsTarget, metrics); + } } else if (json.type === 'metrics') { metrics = json.data; + if (metrics && streamRunId) { + metrics._costRecordId = _metricsCostRecordId(streamRunId, json); + } if (!_isBg && holder && metrics) { - holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName; - holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel; + applyModelMetricsState(metrics, holder, roundHolder, modelName); } if (_isBg) { var bgM = _backgroundStreams.get(streamSessionId); - if (bgM) bgM.metrics = json.data; + if (bgM) { + bgM.metrics = json.data; + chatRenderer.recordSessionMetricsCost(bgM.metrics, streamSessionId); + } continue; } if (metrics) { @@ -3341,9 +3408,17 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const newRole = document.createElement('div'); newRole.className = 'role'; const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId); - const _roundRequested = holder?._requestedModel || metaS?.model; - const _roundActual = holder?._actualModel || _roundRequested; - newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || ''; + inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName); + const _roundRequested = newWrap._requestedModel; + const _roundActual = newWrap._actualModel; + newRole.textContent = _modelRouteLabel( + _roundRequested, + _roundActual, + newWrap._requestedEndpointLabel, + newWrap._actualEndpointLabel, + newWrap._requestedEndpointId, + newWrap._actualEndpointId, + ) || ''; _applyModelColor(newRole, _roundActual); newWrap.appendChild(newRole); const newBody = document.createElement('div'); @@ -3449,6 +3524,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } } + if (_streamTerminalError) { + throw _streamTerminalError; + } if (!_streamSawDone) { throw new Error('Stream closed before completion'); } @@ -3467,15 +3545,25 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); if (!_isBgFinal) { finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); - const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model; - const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel; + const _finalModelHolder = applyModelMetricsState( + metrics, + holder, + roundHolder, + finalMeta?.model || modelName, + ) || holder; + const _finalActualModel = _finalModelHolder._actualModel || finalMeta?.model; + const _finalRequestedModel = _finalModelHolder._requestedModel || finalMeta?.model || _finalActualModel; // Prepend character name if set var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''; - const roleEl = holder.querySelector('.role'); + const roleEl = _finalModelHolder.querySelector('.role'); if (roleEl) { _setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, { - suffix: holder._roleSuffix, - characterName: _charNameFinal || holder._characterName, + suffix: _finalModelHolder._roleSuffix, + characterName: _charNameFinal || _finalModelHolder._characterName, + requestedEndpointId: _finalModelHolder._requestedEndpointId, + requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel, + actualEndpointId: _finalModelHolder._actualEndpointId, + actualEndpointLabel: _finalModelHolder._actualEndpointLabel, }); } holder.dataset.raw = accumulated; @@ -3747,7 +3835,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Error happened while backgrounded — update map, don't touch DOM console.error('Background stream error:', err); var bgErr = _backgroundStreams.get(streamSessionId); - if (bgErr && bgErr.status === 'completed') { + if (bgErr && ( + bgErr.status === 'completed' || _terminalSavedStreams.has(streamSessionId) + )) { + bgErr.status = 'completed'; // [DONE] was already processed — this error is benign (e.g. reader.read() after close) // Don't override the completed status; just ensure the completed dot stays if (sessionModule && sessionModule.clearStreaming) { @@ -3907,7 +3998,30 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // cap. Only auto-recover from connection-class failures; deterministic // errors (unsupported tools, 4xx/5xx, parse failures) surface right away // instead of burning the nudge budget on a guaranteed-to-fail retry. - if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) { + if (!(isRecoverableStreamError(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) { + if (err.terminalStreamError) { + if (_canonicalTerminalSaved || accumulated.trim()) { + // Let this stream's finally block clear foreground state before + // reselecting; otherwise selectSession would detach the already + // terminal reader and leave a stale background-stream marker. + setTimeout(async () => { + if (sessionModule.getCurrentSessionId() === streamSessionId) { + await sessionModule.selectSession(streamSessionId, { showLoading: false }); + } else { + await sessionModule.loadSessions(); + } + }, 0); + } else { + const terminalBody = roundHolder && roundHolder.querySelector('.body'); + if (terminalBody) { + const terminalNote = document.createElement('div'); + terminalNote.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;'; + terminalNote.textContent = `[Error: ${err.message}]`; + terminalBody.appendChild(terminalNote); + } + } + return; + } const errorHolder = document.querySelector('.msg-ai:last-of-type .body'); if (errorHolder) { let errMsg = `Error: ${err.message}`; @@ -3921,6 +4035,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } } } finally { + if (_streamSessionId === streamSessionId) _streamSessionId = null; clearResponseTimeout(); clearProcessingProbe(); clearFirstTokenWaitTimers(); @@ -3939,6 +4054,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // Only reset UI state if still on the stream's session and was never backgrounded const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); + _terminalSavedStreams.delete(streamSessionId); if (!_isBgFinally) { // Reset button to idle state @@ -4047,29 +4163,20 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr || _streamSessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId()); if (_sid) { - fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + _stopExactRun(_sid); } } catch (_) {} } } // ── Stall watchdog ────────────────────────────────────────────── - // Auto-recover a turn whose stream died (connection drop) or went silent: - // preserve the partial, then re-submit a completion handshake by reusing the - // existing continue/resume path. Returns false at the cap so the caller can - // surface the failure instead of nudging forever. + // Auto-recover a turn whose browser stream died by reconnecting to the exact + // detached server run. Returns false at the cap so the caller can surface + // the failure instead of retrying forever. // Only auto-recover from connection-class failures (the genuine "silently // died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON // parse failures — will fail identically on retry, so surfacing them // immediately is both more honest and avoids wasting the nudge budget. - function _isRecoverableStreamErr(err) { - if (!err) return false; - if (err.name === 'TypeError') return true; // fetch/reader network failure - const m = (err.message || '').toLowerCase(); - if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false; - return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m); - } - function _tryAutoRecover(holder, accumulated, sessionId) { if (_autoNudges >= _AUTO_NUDGE_CAP) return false; _autoNudges++; @@ -4080,28 +4187,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated)); } catch (_) {} } - _pendingContinue = holder || null; // merge the continuation into the same bubble - _hideUserBubble = true; // no user bubble for the handshake - _autoContinuePending = true; // don't reset the counter on this submit - const _abandon = () => { // clear the pending flags so they can't - _pendingContinue = null; // leak into whatever chat is now open - _hideUserBubble = false; - _autoContinuePending = false; - }; - // Defer so the stream's finally resets state first — otherwise the send - // button is still in "stop" mode and clicking it would toggle, not send. - setTimeout(() => { + // The server run is detached and keeps its exact pinned model/tool state. + // Reconnect to that run instead of submitting a new user turn, which would + // cancel it, retry the selected model, and risk duplicating side effects. + setTimeout(async () => { // The stream that died may not be the chat the user is now looking at — - // never inject the recovery handshake into the wrong conversation. - if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; } - const msgInput = uiModule.el('message'); - const sb = document.querySelector('.send-btn'); - if (!msgInput || !sb) { _abandon(); return; } - const tail = (accumulated || '').slice(-400); - msgInput.value = tail - ? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.` - : `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`; - sb.click(); + // never attach the recovery reader to the wrong conversation. + if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return; + const resumed = await resumeStream(sessionId, holder || null); + if (!resumed && holder && holder.isConnected) { + const body = holder.querySelector('.body'); + if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.'); + } }, 200); return true; } @@ -4257,9 +4354,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr abortCurrentRequest(); return; } - // Store background stream state + const terminalSaved = _terminalSavedStreams.has(sessionId); + // Store background stream state. A canonical terminal event can precede + // its SSE error event; preserve completion if the user switches sessions + // during that gap instead of creating a fresh running/error marker. _backgroundStreams.set(sessionId, { - status: 'running', + status: terminalSaved ? 'completed' : 'running', accumulated: currentAccumulated, sourcesHtml: '', findingsData: null, @@ -4268,8 +4368,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr metrics: null, }); // Mark session with pulsing dot in sidebar - if (sessionModule && sessionModule.markStreaming) { + if (!terminalSaved && sessionModule && sessionModule.markStreaming) { sessionModule.markStreaming(sessionId); + } else if (terminalSaved && sessionModule && sessionModule.clearStreaming) { + sessionModule.clearStreaming(sessionId); } // Clear local state WITHOUT aborting the fetch if (currentAbort === active.abortCtrl) currentAbort = null; @@ -4296,7 +4398,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr * reloaded from the DB so its full render stays faithful. Returns true if it * attached, false to let the caller fall back to spinner+poll. */ - export async function resumeStream(sessionId) { + export async function resumeStream(sessionId, replaceHolder = null) { if (!sessionId) return false; if (hasActiveStream(sessionId)) return false; @@ -4307,9 +4409,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr return false; } if (!res.ok || !res.body) return false; + const resumeRunId = res.headers.get('X-Odysseus-Run-Id') || ''; + if (resumeRunId) _streamRunIds.set(sessionId, resumeRunId); const box = document.getElementById('chat-history'); if (!box) return false; + if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove(); // Block duplicate re-attach attempts while this reader is live. A dedicated // set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this @@ -4324,6 +4429,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr holder.innerHTML = '
' + uiModule.esc(roleLabel) + ' ' + roleTs + '
' + '
'; + holder._requestedModel = meta && meta.model; + holder._actualModel = holder._requestedModel; _applyModelColor(holder.querySelector('.role'), meta && meta.model); const contentDiv = holder.querySelector('.stream-content'); box.appendChild(holder); @@ -4341,6 +4448,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr let gotDelta = false; let leftSession = false; let metricsData = null; + let replayError = null; + let canonicalTerminalSeen = false; // "Rich" responses (tool calls, sources, doc streaming, multi-round) need the // full canonical render, which is rebuilt from the saved DB record on reload. // Plain text replies can be finalized in place without a reload. @@ -4377,6 +4486,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const parts = buffer.split('\n\n'); buffer = parts.pop(); for (const part of parts) { + const eventIsError = part.split('\n').some(l => l.trim() === 'event: error'); + if (eventIsError) rich = true; const line = part.split('\n').find(l => l.startsWith('data: ')); if (!line) continue; const payload = line.slice(6); @@ -4386,7 +4497,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } let json; try { json = JSON.parse(payload); } catch (_) { continue; } - if (json.delta) { + if (eventIsError) { + replayError = createTerminalStreamError(json); + } else if (json.delta) { roundText += json.delta; if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) { docFenceOpened = true; @@ -4402,6 +4515,64 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (documentModule) documentModule.streamDocDelta(json.content || json.delta || ''); } else if (json.type === 'metrics') { metricsData = json.data || metricsData; + if (metricsData && resumeRunId) { + metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json); + } + if (metricsData) { + chatRenderer.recordSessionMetricsCost(metricsData, sessionId); + } + } else if (json.type === 'fallback') { + // Replay can attach after the selected route has already failed. + // Reflect the fallback immediately, then reload the canonical + // multi-round record when the detached run completes. + rich = true; + const fallbackHolder = applyModelRouteEventState(json, holder, null, meta && meta.model); + if (fallbackHolder) { + _setRoleModelLabel( + fallbackHolder.querySelector('.role'), + fallbackHolder._requestedModel, + fallbackHolder._actualModel, + { + reason: json.reason, + requestedEndpointId: fallbackHolder._requestedEndpointId, + requestedEndpointLabel: fallbackHolder._requestedEndpointLabel, + actualEndpointId: fallbackHolder._actualEndpointId, + actualEndpointLabel: fallbackHolder._actualEndpointLabel, + }, + ); + } + uiModule.showToast( + 'Fallback: ' + _shortModel(json.selected_model || '') + ' failed — answered by ' + + _shortModel(json.answered_by || ''), + 6000, + ); + } else if (json.type === 'model_actual') { + rich = true; + const modelHolder = applyModelRouteEventState(json, holder, null, meta && meta.model); + if (modelHolder) { + _setRoleModelLabel( + modelHolder.querySelector('.role'), + modelHolder._requestedModel, + modelHolder._actualModel, + { + requestedEndpointId: modelHolder._requestedEndpointId, + requestedEndpointLabel: modelHolder._requestedEndpointLabel, + actualEndpointId: modelHolder._actualEndpointId, + actualEndpointLabel: modelHolder._actualEndpointLabel, + }, + ); + } + } else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') { + // The server has already persisted canonical partial content plus + // a sanitized failure note and actual route provenance. Do not + // finalize replayed deltas as a successful local-only answer. + rich = true; + canonicalTerminalSeen = true; + metricsData = json.data || metricsData; + if (metricsData && resumeRunId) { + metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json); + } + if (metricsData) displayMetrics(holder, metricsData); } else if (json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'web_sources' || json.type === 'rag_sources' || @@ -4412,7 +4583,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } } } catch (e) { - // Network drop or parse failure: fall through to the reload below. + // Network drop or parse failure: fall through to the canonical reload. + rich = true; } cleanup(); @@ -4422,6 +4594,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const onThisSession = sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId; + // A failure before substantive output has no persisted assistant record to + // recover through a canonical reload. Keep its sanitized provider/request + // error visible in the replay holder instead of deleting the only evidence. + if (onThisSession && replayError && !canonicalTerminalSeen) { + const errorDiv = document.createElement('div'); + errorDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;'; + errorDiv.textContent = `[Error: ${replayError.message}]`; + contentDiv.appendChild(errorDiv); + uiModule.scrollHistory(); + return true; + } + // Plain text reply: finalize in place. Replace the live bubble with a // canonical single message (markdown + footer actions + metrics) using the // same renderer history does. No history refetch, no end-of-stream flicker. @@ -4438,6 +4622,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr // reload from the DB for the full canonical render. if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove(); if (holder.parentNode) holder.remove(); + if (metricsData) { + chatRenderer.recordSessionMetricsCost(metricsData, sessionId); + } if (onThisSession) sessionModule.selectSession(sessionId); else sessionModule.loadSessions(); return true; diff --git a/static/js/chatModelProvenance.js b/static/js/chatModelProvenance.js new file mode 100644 index 000000000..2274537cd --- /dev/null +++ b/static/js/chatModelProvenance.js @@ -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; +} diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 10709679d..bf032d80c 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -612,10 +612,36 @@ export function sameModelName(left, right) { || shortModel(a).toLowerCase() === shortModel(b).toLowerCase(); } -export function modelRouteLabel(requestedModel, actualModel) { +function shortEndpointLabel(label) { + const value = modelValue(label); + if (!value) return ''; + return value.length > 18 ? value.slice(0, 17) + '…' : value; +} + +export function modelRouteLabel( + requestedModel, + actualModel, + requestedEndpointLabel = '', + actualEndpointLabel = '', + requestedEndpointId = '', + actualEndpointId = '', +) { const requested = modelValue(requestedModel); const actual = modelValue(actualModel) || requested; - if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested); + const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel); + const actualRoute = modelValue(actualEndpointId || actualEndpointLabel); + const routeChanged = Boolean( + actualRoute + && requestedRoute + && actualRoute !== requestedRoute + ); + if (!requested || sameModelName(requested, actual)) { + const model = shortModel(actual || requested); + if (!routeChanged) return model; + const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route'); + const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId); + return model + ' (' + from + ' -> ' + to + ')'; + } return shortModel(requested) + ' -> ' + shortModel(actual); } @@ -626,10 +652,24 @@ export function replyModelPair(modelName, metadata) { if (actualFromMeta || requestedFromMeta) { const actual = actualFromMeta || requestedFromMeta || modelValue(modelName); const requested = requestedFromMeta || actual; - return { requestedModel: requested, actualModel: actual }; + return { + requestedModel: requested, + actualModel: actual, + requestedEndpointId: meta.requested_endpoint_id || null, + requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route', + actualEndpointId: meta.endpoint_id || null, + actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route', + }; } const fallback = modelValue(modelName); - return { requestedModel: fallback, actualModel: fallback }; + return { + requestedModel: fallback, + actualModel: fallback, + requestedEndpointId: null, + requestedEndpointLabel: 'Selected route', + actualEndpointId: null, + actualEndpointLabel: 'Selected route', + }; } /** @@ -821,12 +861,50 @@ export function isCostTrackedEndpoint(url) { } /** Cost for the current turn, returning null for non-billable endpoints. */ -function _billableCost(model, inputTokens, outputTokens) { - const url = _currentEndpointUrl(); - if (!isCostTrackedEndpoint(url)) return null; +function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) { + // Foreground fallback can answer on a different endpoint than the session's + // selected route. Prefer the backend's non-secret actual-route + // classification; retain the selected-endpoint check for older history. + if (endpointCostTracked === false) return null; + const selectedUrl = selectedEndpointUrl === undefined + ? _currentEndpointUrl() + : selectedEndpointUrl; + if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) { + return null; + } return getModelCost(model, inputTokens, outputTokens); } +/** Sum cost using the route/model that produced each Agent round. */ +function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) { + const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : []; + if (!buckets.length) { + return _billableCost( + model, + inputTokens, + outputTokens, + metrics.endpoint_cost_tracked, + selectedEndpointUrl, + ); + } + let total = 0; + let hasPricedUsage = false; + for (const bucket of buckets) { + if (!bucket || typeof bucket !== 'object') continue; + const bucketCost = _billableCost( + bucket.model || model, + Number(bucket.input_tokens) || 0, + Number(bucket.output_tokens) || 0, + bucket.endpoint_cost_tracked, + selectedEndpointUrl, + ); + if (bucketCost === null) continue; + total += bucketCost; + hasPricedUsage = true; + } + return hasPricedUsage ? total : null; +} + export function getImageCost(model, quality, size) { if (!model) return null; const m = model.toLowerCase(); @@ -841,6 +919,8 @@ export function getImageCost(model, quality, size) { /* ── Session cost helpers ─────────────────────────────────────────── */ const _COST_KEY = 'ody-session-cost'; +const _COST_RUNS_KEY = 'ody-session-cost-runs'; +const _MAX_COST_RUNS_PER_SESSION = 256; /** Return the accumulated cost for the current (or given) session. */ export function getSessionCost(sessionId) { @@ -848,7 +928,14 @@ export function getSessionCost(sessionId) { if (!sid) return 0; try { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - return costs[sid] || 0; + const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); + const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object' + ? Object.values(runCosts[sid]) + : []; + return (costs[sid] || 0) + recordedRuns.reduce( + (total, value) => total + (Number(value) || 0), + 0, + ); } catch (_e) { return 0; } } @@ -860,6 +947,9 @@ export function resetSessionCost(sessionId) { const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); delete costs[sid]; localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); + delete runCosts[sid]; + localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); } catch (_e) { /* ignore */ } updateSessionCostUI(); } @@ -868,21 +958,8 @@ export function resetSessionCost(sessionId) { export function updateSessionCostUI() { const el = document.getElementById('session-cost-display'); if (!el) return; - // Non-billable endpoint? Hide the badge and clear stale cost that a previous - // cloud-rate calculation may have left in localStorage for this session. - const _url = _currentEndpointUrl(); - if (!isCostTrackedEndpoint(_url)) { - const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); - if (sid && getSessionCost(sid) > 0) { - try { - const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - delete costs[sid]; - localStorage.setItem(_COST_KEY, JSON.stringify(costs)); - } catch (_e) { /* ignore */ } - } - el.style.display = 'none'; - return; - } + // The ledger records billable work already performed in this session. A + // selected local endpoint does not erase cost from a paid fallback route. const cost = getSessionCost(); if (cost > 0) { el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2)); @@ -892,6 +969,61 @@ export function updateSessionCostUI() { } } +/** Record one metrics payload in a session ledger at most once. */ +export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) { + if (!metrics || typeof metrics !== 'object') return null; + const cost = _metricsBillableCost( + metrics, + metrics.model || 'Unknown', + metrics.input_tokens || 0, + metrics.output_tokens || 0, + selectedEndpointUrl, + ); + if (metrics._fromHistory) return cost; + const sid = sessionId || ( + window.sessionModule && window.sessionModule.getCurrentSessionId() + ); + if (!sid || cost === null) return cost; + const runId = typeof metrics._costRecordId === 'string' + ? metrics._costRecordId.trim() + : ''; + if (metrics._costRecorded && !runId) return cost; + metrics._costRecorded = true; + if (runId) { + try { + const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); + const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object' + ? runCosts[sid] + : {}; + // Assigning by detached-run identity is replay-idempotent even when a + // refresh produces a fresh metrics object or two tabs race to write it. + sessionRuns[runId] = cost; + const entries = Object.entries(sessionRuns); + if (entries.length > _MAX_COST_RUNS_PER_SESSION) { + const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION); + const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + costs[sid] = (costs[sid] || 0) + overflow.reduce( + (total, entry) => total + (Number(entry[1]) || 0), + 0, + ); + overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]); + localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + } + runCosts[sid] = sessionRuns; + localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); + } catch (_e) { /* ignore */ } + } else { + try { + const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + costs[sid] = (costs[sid] || 0) + cost; + localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + } catch (_e) { /* ignore */ } + } + const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId(); + if (currentSid === sid) updateSessionCostUI(); + return cost; +} + /** Create a timestamp span for role labels. * Pass an ISO string / Date / epoch-ms to render the message's own time * (used when replaying history). Falls back to "now" when no value is given. */ @@ -1871,23 +2003,19 @@ export function displayMetrics(messageElement, metrics) { const isReal = metrics.usage_source === 'real'; const ctxPct = metrics.context_percent; const model = metrics.model || 'Unknown'; - const cost = _billableCost(model, inputTokens, outputTokens); + const cost = _metricsBillableCost( + metrics, + model, + inputTokens, + outputTokens, + ); // Nothing useful to show — bail out (only if ALL metrics are missing) if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return; - // Accumulate session cost (only on fresh metrics, not history reload) - if (!metrics._fromHistory) { - const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); - if (_sid && cost !== null) { - try { - const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - _costs[_sid] = (_costs[_sid] || 0) + cost; - localStorage.setItem(_COST_KEY, JSON.stringify(_costs)); - } catch (_e) { /* ignore */ } - updateSessionCostUI(); - } - } + // Rendering can occur when metrics arrive and again after [DONE]. The + // ledger mutation is idempotent for that shared payload. + recordSessionMetricsCost(metrics); // Keep token counts in the Message Stats popup; the footer should stay slim. const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null; @@ -2304,9 +2432,19 @@ export function addMessage(role, content, modelName, metadata) { const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; // --- Agent multi-bubble reconstruction from saved metadata --- - if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { + if ( + role === 'assistant' + && metadata + && ( + (Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0) + || (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1) + ) + ) { const roundTexts = metadata.round_texts || []; - const toolEvents = metadata.tool_events; + const roundModels = metadata.round_models || []; + const roundEndpointIds = metadata.round_endpoint_ids || []; + const roundEndpointLabels = metadata.round_endpoint_labels || []; + const toolEvents = metadata.tool_events || []; let pendingAskUser = null; let lastWrap = null; let firstMsgAi = null; @@ -2319,7 +2457,8 @@ export function addMessage(role, content, modelName, metadata) { toolsByRound[r].push(ev); } - const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length); + const toolRounds = Object.keys(toolsByRound).map(Number); + const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length); for (let r = 0; r < maxRound; r++) { const roundNum = r + 1; @@ -2331,10 +2470,31 @@ export function addMessage(role, content, modelName, metadata) { const roleEl = document.createElement('div'); roleEl.className = 'role'; const pair = replyModelPair(modelName, metadata); - const contModel = pair.actualModel || pair.requestedModel; - roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel); - if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) { - roleEl.title = pair.requestedModel + ' -> ' + contModel; + const contModel = roundModels[r] || pair.actualModel || pair.requestedModel; + const contEndpointId = r < roundEndpointIds.length + ? roundEndpointIds[r] + : pair.actualEndpointId; + const contEndpointLabel = r < roundEndpointLabels.length + ? roundEndpointLabels[r] + : pair.actualEndpointLabel; + roleEl.textContent = modelRouteLabel( + pair.requestedModel, + contModel, + pair.requestedEndpointLabel, + contEndpointLabel, + pair.requestedEndpointId, + contEndpointId, + ); + if ( + pair.requestedModel + && contModel + && ( + !sameModelName(pair.requestedModel, contModel) + || (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId) + ) + ) { + roleEl.title = pair.requestedModel + ' -> ' + contModel + + ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')'; } applyModelColor(roleEl, contModel); if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp)); @@ -2489,7 +2649,14 @@ export function addMessage(role, content, modelName, metadata) { const isCompacted = metadata?.compacted; const replyModels = replyModelPair(modelName, metadata); const resolvedModel = replyModels.actualModel || replyModels.requestedModel; - var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel); + var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel( + replyModels.requestedModel, + resolvedModel, + replyModels.requestedEndpointLabel, + replyModels.actualEndpointLabel, + replyModels.requestedEndpointId, + replyModels.actualEndpointId, + ); if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) { _roleText += ' (Research)'; } @@ -2500,8 +2667,14 @@ export function addMessage(role, content, modelName, metadata) { } r.textContent = _roleText; if (role !== 'user') { - if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) { - r.title = replyModels.requestedModel + ' -> ' + resolvedModel; + const endpointChanged = Boolean( + replyModels.requestedEndpointId + && replyModels.actualEndpointId + && replyModels.requestedEndpointId !== replyModels.actualEndpointId + ); + if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) { + r.title = replyModels.requestedModel + ' -> ' + resolvedModel + + ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')'; } if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel); r.appendChild(roleTimestamp(metadata?.timestamp)); @@ -2785,6 +2958,7 @@ const chatRenderer = { getSessionCost, resetSessionCost, updateSessionCostUI, + recordSessionMetricsCost, roleTimestamp, stripToolBlocks, copyMessageText, diff --git a/static/js/chatStreamErrors.js b/static/js/chatStreamErrors.js new file mode 100644 index 000000000..250cb290d --- /dev/null +++ b/static/js/chatStreamErrors.js @@ -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); +} diff --git a/static/js/settings.js b/static/js/settings.js index 0f3b9d52a..3c6e30b44 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -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