mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
refactor(model-routing): centralize explicit foreground fallback policy (#6020)
* refactor(model-routing): centralize explicit foreground fallback policy Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs. Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate. * fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp * fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility * fix(chat): restore stream helpers and harden run stop lifecycle * fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses * fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate * fix(chat): honor queued stop across resend and reload canonical terminal on EOF * fix(chat): track stop queue and cleanup ownership by per-send generation * fix(agent-loop): preserve requested temperature for non-qwen fallback candidates * fix(chat): reserve send ownership before any await and scope stop to the current send * fix(chat): clear the previous run identity at send reservation --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
This commit is contained in:
co-authored by
RaresKeY
StressTestor
parent
b52296471b
commit
c4369305f0
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}`,
|
||||
|
||||
Reference in New Issue
Block a user