diff --git a/CLAUDE.md b/CLAUDE.md index 87bbb6e61..651884b7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Full reference: `.env.template` and `config/config.yaml` - **Usage tracking:** `USAGE_ENABLED` (true), `ENFORCE_RETURNING_USAGE_DATA` (true), `USAGE_RETENTION_DAYS` (90) - **Rate limits:** `RATE_LIMITS_ENABLED` (true; no-op until rules exist). Every rule has a scope: `user_path` (consumer control; subtree with ONE shared counter per rule — per-key limits = give each key its own path), `provider` (caps one configured provider instance across all consumers/models), or `model` (subject `openai/gpt-4o` pins one provider's model, bare `gpt-4o` covers it on any provider; matching case-insensitive). Limits: `max_requests`/`max_tokens` per period (`minute`/`hour`/`day`/custom `period_seconds`, sliding window) plus `concurrent` (period_seconds 0: `max_requests` = max in-flight; realtime sessions hold a slot for the session, batch submissions don't — and batch skips provider/model rules since batch files can mix models). Enforcement covers every model endpoint; user-path breaches return 429 (`code: rate_limit_exceeded`) with `Retry-After`, successes carry `x-ratelimit-{limit,remaining,reset}-{requests,tokens}` from the most-constrained matching rule; cache hits bypass. Saturated providers/models are instead routed around: virtual-model load balancing skips them (like stale inventory), a saturated primary route with configured failover rules skips the primary provider and is served by the sweep (which also skips saturated candidates), and only requests with no viable alternative get 429. Token windows are charged to the provider/model that actually executed (from the usage entry), so accounting stays correct under aliasing/failover. Managed in the dashboard (Rate Limits page: scope selector) / `/admin/rate-limits` (GET/PUT/DELETE + `POST .../reset-one`, `POST .../reset`; requests take `scope`+`subject`, with `user_path` as shorthand for user-path rules), or as infrastructure-as-code under `rate_limits.{user_paths,providers,models}:` in `config.yaml` / `SET_RATE_LIMIT_` env vars (`rpm/tpm/rph/tph/rpd/tpd/concurrent=N` compact syntax or a JSON rule array; `__` separates path segments) and `SET_PROVIDER_RATE_LIMIT_` (same syntax; suffix underscores become hyphens; model rules are YAML/admin-only). Env replaces the whole YAML entry for the same subject; config-sourced rules are read-only in the dashboard and manual edits win over config seeds, like budgets. Token limits are post-accounted from usage entries, so they require `USAGE_ENABLED=true` (startup warns otherwise) and one request can overshoot a token window. Counters are in-memory per instance (N replicas ≈ N× limit) and reset on restart — budgets remain the durable cross-instance control. - **Dashboard live logs:** - - `DASHBOARD_LIVE_LOGS_ENABLED` (true): keep enabled for low-latency dashboard previews; set false only when live streams are not needed or memory/socket usage must be minimized. + - `DASHBOARD_LIVE_LOGS_ENABLED` (true): keep enabled for low-latency dashboard previews; set false only when live streams are not needed or memory/socket usage must be minimized. With `LOGGING_LOG_BODIES` also enabled, in-flight streamed responses render chunk-by-chunk in the request log and Interactions drawer (throttled `audit.stream` events, published only while a dashboard is connected; partial bodies are never buffered server-side). - `DASHBOARD_LIVE_LOGS_BUFFER_SIZE` (10000): effective size is capped at `DASHBOARD_LIVE_LOGS_REPLAY_LIMIT + 1` (older events can never be replayed); lower it below the replay limit only to shrink memory at the cost of more replay resets. Buffered events are compact previews — request/response bodies are never retained in the buffer (connected dashboards get them live; history hydrates from persisted audit entries). - `DASHBOARD_LIVE_LOGS_REPLAY_LIMIT` (1000): increase when clients commonly reconnect after long gaps (30+ seconds at high traffic); decrease to reduce replay latency and memory. Also bounds the live log buffer. - `DASHBOARD_LIVE_LOGS_HEARTBEAT_SECONDS` (15): decrease to 5-10s when proxies need frequent liveness checks; increase to reduce idle network chatter. diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index b79971391..a045b8b29 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -3704,6 +3704,23 @@ textarea:focus { padding: 8px 0 0; } +.audit-pane-pending { + display: flex; + align-items: center; + gap: 8px; +} + +.audit-pane-streaming { + display: inline-flex; + align-items: center; + gap: 7px; + padding-left: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); +} + .audit-size-warning { margin-top: 8px; color: var(--warning); @@ -3781,6 +3798,15 @@ body.conversation-drawer-open { gap: 10px; } +.conversation-live-status { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 16px 20px; + color: var(--text-muted); + font-size: 13px; +} + .chat-message { border: 1px solid var(--border); border-radius: 10px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index da56bef6e..3600cbebe 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -198,6 +198,7 @@ function dashboard() { conversationMessages: [], conversationRequestToken: 0, conversationReturnFocusEl: null, + conversationLiveEntryId: "", bodyPointerStart: null, _parseRoute(pathname) { diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index f3b2c53d9..ab1d382d3 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -321,6 +321,10 @@ if (liveState === 'audit.completed' || liveState === 'audit.flushed' || liveState === 'audit.detail') { return false; } + // A partial response body means the stream is still running, + // regardless of the other completion signals (streamed entries + // carry status 200 from the moment headers were committed). + if (entry._response_partial) return true; if (entry.status_code !== null && entry.status_code !== undefined && entry.status_code !== '') return false; if (Number(entry.duration_ns || 0) > 0) return false; if (entry.error_type || entry.error_message) return false; @@ -692,6 +696,8 @@ auditRequestPane(entry) { const data = entry && entry.data ? entry.data : null; + const empty = !data || (!data.request_headers && !data.request_body); + const pending = empty && this.auditEntryLiveInProgress(entry); return { title: 'Request', @@ -708,8 +714,10 @@ body: data && data.request_body, bodyCacheRatioLabel: this.auditCacheRatioPillLabel(entry), promptCacheHighlight: this.auditPromptCacheHighlight(entry), - showEmpty: !data || (!data.request_headers && !data.request_body), + showEmpty: empty && !pending, emptyMessage: 'Request details were not captured.', + showPending: pending, + pendingMessage: 'Waiting for request data…', showTooLarge: !!(data && data.request_body_too_big_to_handle), tooLargeMessage: 'Request body was too large to capture.' }; @@ -718,6 +726,8 @@ auditResponsePane(entry) { const data = entry && entry.data ? entry.data : null; const errorMessage = this.auditEntryErrorMessage(entry); + const empty = !data || (!errorMessage && !data.response_headers && !data.response_body); + const pending = empty && this.auditEntryLiveInProgress(entry); return { title: 'Response', @@ -732,8 +742,12 @@ headers: data && data.response_headers, showBody: !!(data && data.response_body), body: data && data.response_body, - showEmpty: !data || (!errorMessage && !data.response_headers && !data.response_body), + streaming: !!(entry && entry._response_partial && data && data.response_body) && + this.auditEntryLiveInProgress(entry), + showEmpty: empty && !pending, emptyMessage: 'Response details were not captured.', + showPending: pending, + pendingMessage: 'Response in progress…', showTooLarge: !!(data && data.response_body_too_big_to_handle), tooLargeMessage: 'Response body was too large to capture.' }; diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs index 1c41ff5f1..970b1e991 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs +++ b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs @@ -91,6 +91,67 @@ test('auditResponsePane returns the shared response-pane contract', () => { assert.equal(pane.tooLargeMessage, 'Response body was too large to capture.'); }); +test('auditEntryLiveInProgress stays true while a partial response body is streaming', () => { + const module = createAuditListModule(); + const streaming = { + _live: true, + _live_pending: true, + _live_state: 'audit.stream', + _response_partial: true, + status_code: 200, + data: { response_body: { choices: [] } } + }; + + assert.equal(module.auditEntryLiveInProgress(streaming), true); + assert.equal(module.auditEntryLiveInProgress({ + ...streaming, + _live_state: 'audit.completed', + _response_partial: false, + duration_ns: 1000 + }), false); +}); + +test('audit panes surface pending spinners and streaming badges for live rows', () => { + const module = createAuditListModule(); + + const waiting = { _live: true, _live_pending: true, _live_state: 'audit.updated', data: {} }; + const waitingResponse = module.auditResponsePane(waiting); + assert.equal(waitingResponse.showPending, true); + assert.equal(waitingResponse.showEmpty, false); + assert.equal(waitingResponse.pendingMessage, 'Response in progress…'); + const waitingRequest = module.auditRequestPane(waiting); + assert.equal(waitingRequest.showPending, true); + assert.equal(waitingRequest.showEmpty, false); + assert.equal(waitingRequest.pendingMessage, 'Waiting for request data…'); + + const streaming = { + _live: true, + _live_pending: true, + _live_state: 'audit.stream', + _response_partial: true, + status_code: 200, + data: { response_body: { choices: [{ index: 0, message: { role: 'assistant', content: 'partial' } }] } } + }; + const streamingPane = module.auditResponsePane(streaming); + assert.equal(streamingPane.streaming, true); + assert.equal(streamingPane.showBody, true); + assert.equal(streamingPane.showPending, false); + + // A stale partial flag on an already-settled entry must not keep the badge. + const settled = { + ...streaming, + _live_state: 'audit.completed', + duration_ns: 1000 + }; + assert.equal(module.auditResponsePane(settled).streaming, false); + + const persisted = { data: {} }; + const persistedPane = module.auditResponsePane(persisted); + assert.equal(persistedPane.showPending, false); + assert.equal(persistedPane.showEmpty, true); + assert.equal(persistedPane.streaming, false); +}); + test('audit cache helpers summarize cached prompt usage and derive a preview from the request body', () => { const module = createAuditListModule({ window: { diff --git a/internal/admin/dashboard/static/js/modules/conversation-drawer.js b/internal/admin/dashboard/static/js/modules/conversation-drawer.js index f04082cc2..28c9fc3f9 100644 --- a/internal/admin/dashboard/static/js/modules/conversation-drawer.js +++ b/internal/admin/dashboard/static/js/modules/conversation-drawer.js @@ -86,19 +86,78 @@ const requestToken = ++this.conversationRequestToken; this.conversationOpen = true; - this.conversationLoading = true; this.conversationError = ''; this.conversationAnchorID = entry.id; this.conversationEntries = []; this.conversationMessages = []; document.body.classList.add('conversation-drawer-open'); requestAnimationFrame(() => this._focusConversationDrawer()); + + // A live entry has no persisted row to fetch yet — render it + // from the live preview data and keep re-rendering as stream + // events arrive (see refreshLiveConversation). + if (this._conversationEntryLivePending(entry)) { + this.conversationLiveEntryId = String(entry.id).trim(); + this.conversationLoading = false; + this.applyLiveConversationEntry(entry); + return; + } + this.conversationLiveEntryId = ''; + this.conversationLoading = true; await this.fetchConversation(entry.id, requestToken); }, + // Guarded like every cross-module call: modules mix optionally. + // Without the live-logs module no entry is ever marked _live, so + // false is the correct degraded answer. + _conversationEntryLivePending(entry) { + return typeof this.auditEntryLiveDetailPending === 'function' && + this.auditEntryLiveDetailPending(entry); + }, + + applyLiveConversationEntry(entry) { + this.conversationEntries = [entry]; + this.conversationMessages = this.buildConversationMessages([entry], entry.id); + }, + + // refreshLiveConversation re-renders an open live conversation when + // its audit entry merges a new live event. Once the entry is + // persisted, the full thread (prior turns, final bodies) is + // hydrated from the store instead. + refreshLiveConversation(entry) { + if (!this.conversationOpen || !this.conversationLiveEntryId || !entry) return; + if (String(entry.id || '').trim() !== this.conversationLiveEntryId) return; + const state = String(entry._live_state || '').trim(); + if (state === 'audit.flushed' || state === 'audit.detail') { + this.conversationLiveEntryId = ''; + const requestToken = ++this.conversationRequestToken; + this.fetchConversation(entry.id, requestToken); + return; + } + this.applyLiveConversationEntry(entry); + }, + + // conversationLiveWaiting reports whether the open live conversation + // is still waiting on the in-flight request (drives the drawer's + // progress spinner). + conversationLiveWaiting() { + if (!this.conversationOpen || !this.conversationLiveEntryId) return false; + const entry = (this.conversationEntries || [])[0]; + if (!entry) return true; + return typeof this.liveAuditStateSettled !== 'function' || + !this.liveAuditStateSettled(entry._live_state); + }, + + conversationLiveStatusText() { + return (this.conversationMessages || []).length > 0 + ? 'Model is responding…' + : 'Waiting for request data…'; + }, + closeConversation() { this.conversationOpen = false; this.conversationRequestToken++; + this.conversationLiveEntryId = ''; document.body.classList.remove('conversation-drawer-open'); const returnFocusEl = this.conversationReturnFocusEl; this.conversationReturnFocusEl = null; diff --git a/internal/admin/dashboard/static/js/modules/conversation-drawer.test.cjs b/internal/admin/dashboard/static/js/modules/conversation-drawer.test.cjs new file mode 100644 index 000000000..05805d57c --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/conversation-drawer.test.cjs @@ -0,0 +1,148 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +// Loads the drawer together with the real live-logs module so live-state +// helpers (auditEntryLiveDetailPending, liveAuditStateSettled, …) behave +// exactly as in production instead of via drifting stubs. +function loadDrawerWindow() { + const context = { + console, + setTimeout, + clearTimeout, + window: {}, + HTMLElement: class HTMLElement {}, + requestAnimationFrame: () => {}, + document: { + activeElement: null, + body: { classList: { add() {}, remove() {} } }, + contains() { return false; } + } + }; + vm.createContext(context); + for (const file of ['conversation-helpers.js', 'live-logs.js', 'conversation-drawer.js']) { + vm.runInContext(fs.readFileSync(path.join(__dirname, file), 'utf8'), context); + } + return context.window; +} + +function createDrawerApp() { + const win = loadDrawerWindow(); + return { + ...win.dashboardLiveLogsModule(), + ...win.dashboardConversationDrawerModule(), + conversationOpen: false, + conversationLoading: false, + conversationError: '', + conversationAnchorID: '', + conversationEntries: [], + conversationMessages: [], + conversationRequestToken: 0, + conversationReturnFocusEl: null, + conversationLiveEntryId: '', + bodyPointerStart: null, + fetchCalls: [], + fetchConversation(logID, token) { + this.fetchCalls.push({ logID, token }); + } + }; +} + +function liveEntry(overrides = {}) { + return { + id: 'audit-1', + _live: true, + _live_pending: true, + _live_state: 'audit.updated', + path: '/v1/chat/completions', + timestamp: '2026-07-06T12:00:00Z', + data: { + request_body: { messages: [{ role: 'user', content: 'Hi' }] } + }, + ...overrides + }; +} + +test('openConversation renders live entries locally without a persisted fetch', async () => { + const app = createDrawerApp(); + const entry = liveEntry(); + + await app.openConversation(entry, null, false, null); + + assert.equal(app.conversationOpen, true); + assert.equal(app.conversationLiveEntryId, 'audit-1'); + assert.equal(app.conversationLoading, false); + assert.equal(app.fetchCalls.length, 0); + assert.equal(app.conversationMessages.length, 1); + assert.equal(app.conversationMessages[0].text, 'Hi'); + assert.equal(app.conversationLiveWaiting(), true); + assert.equal(app.conversationLiveStatusText(), 'Model is responding…'); +}); + +test('openConversation fetches persisted threads for non-live entries', async () => { + const app = createDrawerApp(); + + await app.openConversation({ id: 'audit-2', path: '/v1/chat/completions' }, null, false, null); + + assert.equal(app.conversationLiveEntryId, ''); + assert.equal(app.conversationLoading, true); + assert.equal(app.fetchCalls.length, 1); + assert.equal(app.fetchCalls[0].logID, 'audit-2'); +}); + +test('refreshLiveConversation re-renders streaming chunks for the open entry', async () => { + const app = createDrawerApp(); + await app.openConversation(liveEntry(), null, false, null); + + const streamed = liveEntry({ + _live_state: 'audit.stream', + _response_partial: true, + data: { + request_body: { messages: [{ role: 'user', content: 'Hi' }] }, + response_body: { choices: [{ index: 0, message: { role: 'assistant', content: 'Par' } }] } + } + }); + app.refreshLiveConversation(streamed); + + assert.equal(app.conversationMessages.length, 2); + assert.equal(app.conversationMessages[1].text, 'Par'); + assert.equal(app.conversationLiveWaiting(), true); + + // Events for other entries or a closed drawer are ignored. + app.refreshLiveConversation(liveEntry({ id: 'audit-9' })); + assert.equal(app.conversationMessages.length, 2); +}); + +test('refreshLiveConversation hydrates the persisted thread once the entry flushes', async () => { + const app = createDrawerApp(); + await app.openConversation(liveEntry(), null, false, null); + + app.refreshLiveConversation(liveEntry({ + _live_state: 'audit.completed', + _live_pending: true + })); + assert.equal(app.conversationLiveWaiting(), false, 'spinner stops once the response completed'); + assert.equal(app.fetchCalls.length, 0); + + app.refreshLiveConversation(liveEntry({ + _live_state: 'audit.flushed', + _live_pending: false, + _audit_flushed: true + })); + assert.equal(app.conversationLiveEntryId, ''); + assert.equal(app.fetchCalls.length, 1); + assert.equal(app.fetchCalls[0].logID, 'audit-1'); +}); + +test('closeConversation clears the live conversation binding', async () => { + const app = createDrawerApp(); + await app.openConversation(liveEntry(), null, false, null); + + app.closeConversation(); + + assert.equal(app.conversationOpen, false); + assert.equal(app.conversationLiveEntryId, ''); + assert.equal(app.conversationLiveWaiting(), false); +}); diff --git a/internal/admin/dashboard/static/js/modules/live-logs.js b/internal/admin/dashboard/static/js/modules/live-logs.js index aefd11dcd..3366e4ff7 100644 --- a/internal/admin/dashboard/static/js/modules/live-logs.js +++ b/internal/admin/dashboard/static/js/modules/live-logs.js @@ -183,11 +183,12 @@ }); const previous = index >= 0 ? currentEntries[index] || {} : {}; if (eventType === 'audit.detail') { - const patch = { ...incoming, _detail_loaded: true }; + const patch = { ...incoming, _detail_loaded: true, _response_partial: false }; if (index >= 0) { const merged = this.mergeLiveAuditPatch(previous, patch); currentEntries.splice(index, 1, merged); this.auditLog.entries = [...currentEntries]; + this.notifyLiveConversation(merged); return merged; } if (!this.auditLiveInsertAllowed()) return; @@ -203,11 +204,21 @@ } else { patch._live_pending = false; } + // A stream event's response body is a partial reconstruction of a + // still-running stream; the flag drops once a settled state + // delivers the real body. Other events leave the previous flag + // untouched. + if (eventType === 'audit.stream') { + patch._response_partial = true; + } else if (this.liveAuditStateSettled(eventType)) { + patch._response_partial = false; + } if (index >= 0) { const merged = this.mergeLiveAuditPatch(previous, patch); currentEntries.splice(index, 1, merged); this.auditLog.entries = [...currentEntries]; this.fetchExpandedAuditDetailIfReady(merged); + this.notifyLiveConversation(merged); return merged; } if (!this.auditLiveInsertAllowed()) return; @@ -215,6 +226,7 @@ this.auditLog.total = Number(this.auditLog.total || 0) + 1; const inserted = this.auditLog.entries[0]; this.fetchExpandedAuditDetailIfReady(inserted); + this.notifyLiveConversation(inserted); return inserted; }, @@ -249,6 +261,15 @@ return this.skippedLiveUsageByRequestId && this.skippedLiveUsageByRequestId[requestID] || null; }, + // notifyLiveConversation forwards merged live entries to the + // Interactions drawer (when its module is mixed in) so an open + // live conversation re-renders as stream chunks arrive. + notifyLiveConversation(entry) { + if (entry && typeof this.refreshLiveConversation === 'function') { + this.refreshLiveConversation(entry); + } + }, + fetchExpandedAuditDetailIfReady(entry) { if (!entry || !this.isAuditEntryExpanded || !this.isAuditEntryExpanded(entry)) return; const state = String(entry._live_state || '').trim(); @@ -263,6 +284,7 @@ case 'audit.started': return 10; case 'audit.updated': + case 'audit.stream': return 20; case 'audit.completed': return 30; @@ -281,6 +303,13 @@ return this.liveAuditStateRank(previous) > this.liveAuditStateRank(incoming) ? previous : incoming; }, + // liveAuditStateSettled reports whether a live state already + // carries its final response (audit.completed or later); below + // that the request is still in flight. + liveAuditStateSettled(state) { + return this.liveAuditStateRank(state) >= this.liveAuditStateRank('audit.completed'); + }, + liveAuditEventFlushed(state) { const normalized = String(state || '').trim(); return normalized === 'audit.failed' || normalized === 'audit.flushed' || normalized === 'audit.detail'; diff --git a/internal/admin/dashboard/static/js/modules/live-logs.test.cjs b/internal/admin/dashboard/static/js/modules/live-logs.test.cjs index 6ea0f395b..2e8ca014b 100644 --- a/internal/admin/dashboard/static/js/modules/live-logs.test.cjs +++ b/internal/admin/dashboard/static/js/modules/live-logs.test.cjs @@ -99,6 +99,75 @@ test('live audit lifecycle events merge into one dashboard row by request id', ( assert.equal(app.auditLog.entries[0]._audit_flushed, true); }); +test('audit.stream events merge partial response bodies and keep rows pending', () => { + const app = createLiveLogsApp(); + + app.applyLiveLogEvent({ + seq: 1, + type: 'audit.started', + data: { id: 'audit-1', request_id: 'req-1', method: 'POST', path: '/v1/chat/completions' } + }); + app.applyLiveLogEvent({ + seq: 2, + type: 'audit.stream', + data: { + id: 'audit-1', + request_id: 'req-1', + status_code: 200, + stream: true, + data: { + response_body: { choices: [{ index: 0, message: { role: 'assistant', content: 'partial' } }] }, + response_body_partial: true + } + } + }); + + const streaming = app.auditLog.entries[0]; + assert.equal(streaming._response_partial, true); + assert.equal(streaming._live_pending, true); + assert.equal(streaming._live_state, 'audit.stream'); + assert.equal(streaming.data.response_body.choices[0].message.content, 'partial'); + + app.applyLiveLogEvent({ + seq: 3, + type: 'audit.completed', + data: { + id: 'audit-1', + request_id: 'req-1', + status_code: 200, + duration_ns: 1000, + data: { + response_body: { choices: [{ index: 0, message: { role: 'assistant', content: 'final' } }] } + } + } + }); + + const completed = app.auditLog.entries[0]; + assert.equal(completed._response_partial, false); + assert.equal(completed._live_state, 'audit.completed'); + assert.equal(completed.data.response_body.choices[0].message.content, 'final'); +}); + +test('live audit merges notify an open live conversation drawer', () => { + const app = createLiveLogsApp(); + const seen = []; + app.refreshLiveConversation = (entry) => seen.push(entry); + + app.applyLiveLogEvent({ + seq: 1, + type: 'audit.started', + data: { id: 'audit-1', request_id: 'req-1' } + }); + app.applyLiveLogEvent({ + seq: 2, + type: 'audit.stream', + data: { id: 'audit-1', request_id: 'req-1', data: { response_body: { choices: [] }, response_body_partial: true } } + }); + + assert.equal(seen.length, 2); + assert.equal(seen[1]._response_partial, true); +}); + test('live audit removed event drops suppressed preview rows', () => { const app = createLiveLogsApp(); app.auditLog.entries = [{ id: 'audit-1', request_id: 'req-1' }]; diff --git a/internal/admin/dashboard/templates/audit-pane.html b/internal/admin/dashboard/templates/audit-pane.html index c881ece07..efcfe7220 100644 --- a/internal/admin/dashboard/templates/audit-pane.html +++ b/internal/admin/dashboard/templates/audit-pane.html @@ -34,6 +34,10 @@
Body
+ + + streaming +