mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
* feat(responses): virtualize previous_response_id continuation regardless of upstream support OmniRoute now exposes OpenAI-compatible previous_response_id/store continuation to clients unconditionally, even when the selected upstream provider has no native Responses-API state support. Reconstruction happens server-side in handleChatImplementation, before any downstream validation or provider translation: OmniRoute resolves the response id back to the full input/output it previously produced, prepends it to the client's delta, and forwards the full reconstructed history upstream exactly as it does today. Client<->OmniRoute traffic shrinks to the new delta only; OmniRoute<->provider traffic is unchanged. Storage reuses the existing call-log pipeline artifact (already gated by call_log_pipeline_enabled, already retained/cleaned up by the existing call-log lifecycle) instead of duplicating conversation content into a second store -- only a lightweight call_logs.response_id index is new. Every lookup is scoped by api_key_id so one client can never resolve another client's stored conversation, and any unresolvable/missing/ size-limit-omitted state fails closed with OpenAI's own previous_response_not_found contract. Stacked on feat/openai-responses-store-toggle (#10121). * feat(dashboard): agentic conversation tracking with live transcript view Every agentic chat request now gets a conversation id (X-ConversationId response header). OmniRoute detects when a follow-up request continues the same conversation via fingerprint + bounded prefix-hash matching, with a strict-growth invariant to prevent false merges between independent single-shot requests that happen to share identical opening content. Continuation detection excludes the system message from the identity anchor, since real coding-agent CLIs commonly regenerate it every request with live context (timestamp, cwd, git status) — without this, that volatility alone broke every continuation check against real traffic. - `/dashboard/logs`: new toggleable Conversation column. - `/dashboard/logs/timeline`: requests sharing a conversation id share a timeline lane, connected by an arrow, with a configurable lane-reuse window. - Request detail panel: new Full Conversation transcript above the raw SSE event stream — Markdown rendering, per-turn timestamps, turn-relative view, click-any-turn navigation, live auto-refresh building the transcript in real time from the in-flight SSE chunk buffer while a request is still streaming, auto-scroll-to-bottom as the live turn grows. - New `/dashboard/conversations` page listing conversations with 2+ turns, no-forking model (an edited/duplicated mid-history turn mints its own independent conversation instead of merging), pagination, duplicate- anchor fix. - Configurable auto-refresh intervals on both the timeline and conversations list pages. - Responses API tool-call gap fix: turnsFromOpenAiMessages only handled role-based Chat Completions messages, so bare {type:"function_call"} / {type:"function_call_output"} / {type:"reasoning"} items (real Responses API traffic) silently vanished from the Conversation Context panel. - truncateForLog now counts input[] (Responses API), not just messages[] (Chat Completions), so a truncated /v1/responses request still shows a placeholder instead of nothing. - RequestTimeline.tsx now reads the same debugEnabled/emailsVisible settings RequestLoggerV2.tsx already used, instead of hardcoding both false — the timeline view never showed SSE/stream-chunk events or respected email-masking, regardless of the actual setting. Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135 and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this uses the next free slot past upstream's current highest. Test plan: - npm run typecheck:core — clean - npm run lint — clean - node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions - 109 unit tests across the conversation-tracking, migration-renumber, and dashboard-wiring surface — 0 failures * refactor(dashboard): reuse call-log artifacts for conversation transcript content conversation_turn_nodes no longer stores turn text/tool-call content (text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/ content_hash), matching agentic_conversations' existing lightweight-index shape. Every node's originating request is already fully captured by the call-log pipeline artifact its last_correlation_id points at, so the /dashboard/conversations tree view resolves each node's actual display content on demand from there (open-sse/services/conversationTurnContent.ts), re-running the same extractCanonicalTurns/hashTurnContent the write path used and matching by content_hash, instead of duplicating conversation content into a second store under a separate retention/gating policy. This also drops the old 8000-char text_preview truncation entirely -- resolved content is always full and untruncated. The frontend contract is unchanged (tree API still returns {textPreview, blockKind, toolName} per node), so the dashboard UI itself (page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no changes. Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now collides with 147_api_keys_model_access_mode.sql, which landed on release/v3.8.50 after this work was originally built. Also includes a standalone, unrelated fix carried along from this rebase: close isProviderModelHidden's missing function-body brace in modelSelectModalHelpers.ts (separately landed as #10206). Stacked on feat/responses-previous-response-id-virtualization (#3), which is itself stacked on feat/openai-responses-store-toggle (#10121). * fix(dashboard): resync conversation list on open so the live-text poll starts immediately openConversation() seeded activeConversation (and therefore activeCallLogId, which gates the live-partial-text poll effect) from whatever row snapshot the list's own fixed-interval poll last produced. A conversation opened right after a reply started streaming -- after that tick, before the next -- had activeCallLogId still null, so the live-text poll never started; only a subsequent background list-poll resync (already existed) picked it up, which is why closing and reopening the same conversation "just worked". loadConversations() is now a shared callback so openConversation can force one immediately on open instead of waiting on pollSeconds. Live-verified against omniroute-dev: opening a conversation mid-stream now shows live reasoning on the first open. * style: prettier formatting for conversationTurnContent.test.ts * fix(db): close migration numbering gap left by decoupling from #3/#10262 153/154 (originally 154/155) were chosen back when this branch stacked on top of the previous_response_id migration (153_call_logs_response_id.sql). Decoupling removed that migration from this branch's history, leaving an unused 153 slot that check-migration-numbering.test.ts correctly flags as a gap. * refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap Both files exceeded check-file-size's new-file cap after this PR's own additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure non-component logic (types, constants, allocateLanes and its helpers) out of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two self-contained presentational sub-components (PayloadSection, ConversationContextSection + its private helper) out of RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior change; existing external imports (default exports, allocateLanes, TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the original file paths. * fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs The refresh-merge of release/v3.8.50 exposed that the feature's three migrations collided at slot 153 with the base's radar_local_model_state (153) and its own call_logs_response_id. Migration runner enforces unique numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and the DB-backed quality gates. Renumber the feature's pair to 155_agentic_conversations / 156_conversation_turn_nodes and move call_logs_response_id to 154 (keeps 153_radar base-owned, preserves agentic-before-turn_nodes ordering). Update SQL headers and the 154/156 references in feature code + tests. Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync the doc counts to clear the docs-accuracy gate. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline Knip 6.32 (baseline 415) flags the public re-export of CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no external consumer imports it through that re-export (it is imported and used directly from RequestTimeline.utils.ts inside the component). Removed the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415, back to the frozen baseline. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export - Wrap resolveConversationId() in try/catch in chat.ts, matching the defensive pattern used by every other best-effort side call nearby, so a DB hiccup in conversation tracking can't turn a working chat request into a hard failure. - Remove getConversationTurnTree: knip's project scope excludes tests/**, so an export used only by tests can never register as used there. Swap its 8 test call sites to the paginated getConversationTurnPage (already the dashboard's canonical query) with a generous limit, collapsing to one query path instead of keeping a second whole-chain export alive solely for test convenience. - Regenerate i18n llm.txt mirrors from root (pre-existing drift on this branch, unrelated to the above, caught by the docs-sync pre-commit gate). Addresses PR review feedback. * fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift - fr.json, vi.json were missing requestLogger.columns.conversation (added in the conversation-tracking feature), failing i18n-vi-completeness.test.ts. - docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an earlier rebase fixed the migration count but missed this companion number, failing check-docs-sync.mjs across all 42 locales. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red) .env.example and docs/reference/ENVIRONMENT.md were both missing the PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads (confirmed present at this branch's merge-base too, so this predates the conversation-tracking work and is unrelated to it) -- the entry was added on release/v3.8.50 after this branch's last sync and this branch never picked it up. That gap red-lines tests/unit/check-env-doc-sync.test.ts and tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests fast-path 2/4 in CI). Restore both entries verbatim from the current release/v3.8.50 tip -- no feature-code change. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: hartmark <hartmark@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
968 lines
33 KiB
TypeScript
968 lines
33 KiB
TypeScript
/**
|
|
* Usage History — extracted from usageDb.js (T-15)
|
|
*
|
|
* Usage tracking: saving, querying, and analytics shim for
|
|
* the usage_history SQLite table.
|
|
*
|
|
* @module lib/usage/usageHistory
|
|
*/
|
|
|
|
import { getDbInstance } from "../db/core";
|
|
import { protectPayloadForLog } from "../logPayloads";
|
|
import {
|
|
resolveOrphanedUsageAccountIdentity,
|
|
resolveUsageAccountIdentity,
|
|
} from "./accountIdentity";
|
|
import {
|
|
accumulateLatencySample,
|
|
asRecord,
|
|
buildLatencyStatsEntry,
|
|
createLatencyBucket,
|
|
normalizeServiceTier,
|
|
resolvePositiveOption,
|
|
toNumber,
|
|
toStringOrNull,
|
|
truncatePendingPreview,
|
|
} from "./usageHistory/helpers";
|
|
import type { ModelLatencyStatsEntry } from "./usageHistory/helpers";
|
|
import {
|
|
clearCompletedDetails,
|
|
maybeEnrichCompletedDetail,
|
|
scheduleCompletedDetailCleanup,
|
|
storeCompletedDetail,
|
|
} from "./completedRequestDetails";
|
|
import { shouldPersistToDisk } from "./migrations";
|
|
import { emitUsageRecorded } from "./usageEvents";
|
|
import {
|
|
getLoggedInputTokens,
|
|
getLoggedOutputTokens,
|
|
getPromptCacheCreationTokens,
|
|
getPromptCacheReadTokens,
|
|
getReasoningTokens,
|
|
} from "./tokenAccounting";
|
|
|
|
export type PendingRequestMetadata = {
|
|
clientEndpoint?: string | null;
|
|
clientRequest?: unknown;
|
|
providerRequest?: unknown;
|
|
providerUrl?: string | null;
|
|
providerResponse?: unknown;
|
|
clientResponse?: unknown;
|
|
status?: number | null;
|
|
error?: string | null;
|
|
errorCode?: string | null;
|
|
stage?: string | null;
|
|
stageUpdatedAt?: number | null;
|
|
correlationId?: string | null;
|
|
sessionTag?: string | null;
|
|
};
|
|
export type PendingRequestDetail = {
|
|
id: string;
|
|
model: string;
|
|
provider: string;
|
|
connectionId: string | null;
|
|
startedAt: number;
|
|
clientEndpoint?: string | null;
|
|
clientRequest?: unknown;
|
|
providerRequest?: unknown;
|
|
providerUrl?: string | null;
|
|
providerResponse?: unknown;
|
|
clientResponse?: unknown;
|
|
status?: number | null;
|
|
error?: string | null;
|
|
errorCode?: string | null;
|
|
completedAt?: number | null;
|
|
durationMs?: number | null;
|
|
stage?: string | null;
|
|
stageUpdatedAt?: number | null;
|
|
correlationId?: string | null;
|
|
sessionTag?: string | null;
|
|
streamChunks?: {
|
|
provider?: string[];
|
|
openai?: string[];
|
|
client?: string[];
|
|
} | null;
|
|
};
|
|
|
|
function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingRequestMetadata {
|
|
if (!metadata) return {};
|
|
|
|
const normalized: PendingRequestMetadata = {};
|
|
|
|
if (metadata.clientEndpoint !== undefined) {
|
|
normalized.clientEndpoint = toStringOrNull(metadata.clientEndpoint) || null;
|
|
}
|
|
if (metadata.providerUrl !== undefined) {
|
|
normalized.providerUrl = toStringOrNull(metadata.providerUrl) || null;
|
|
}
|
|
if (metadata.stage !== undefined) {
|
|
normalized.stage = toStringOrNull(metadata.stage) || null;
|
|
normalized.stageUpdatedAt = Date.now();
|
|
}
|
|
if (metadata.stageUpdatedAt !== undefined) {
|
|
normalized.stageUpdatedAt =
|
|
typeof metadata.stageUpdatedAt === "number" && Number.isFinite(metadata.stageUpdatedAt)
|
|
? metadata.stageUpdatedAt
|
|
: null;
|
|
}
|
|
if (metadata.clientRequest !== undefined) {
|
|
normalized.clientRequest = truncatePendingPreview(protectPayloadForLog(metadata.clientRequest));
|
|
}
|
|
if (metadata.providerRequest !== undefined) {
|
|
normalized.providerRequest = truncatePendingPreview(
|
|
protectPayloadForLog(metadata.providerRequest)
|
|
);
|
|
}
|
|
if (metadata.providerResponse !== undefined) {
|
|
normalized.providerResponse = truncatePendingPreview(
|
|
protectPayloadForLog(metadata.providerResponse)
|
|
);
|
|
}
|
|
if (metadata.clientResponse !== undefined) {
|
|
normalized.clientResponse = truncatePendingPreview(
|
|
protectPayloadForLog(metadata.clientResponse)
|
|
);
|
|
}
|
|
if (metadata.status !== undefined) {
|
|
const status = Number(metadata.status);
|
|
normalized.status = Number.isFinite(status) ? status : null;
|
|
}
|
|
if (metadata.error !== undefined) {
|
|
normalized.error = toStringOrNull(metadata.error) || null;
|
|
}
|
|
if (metadata.errorCode !== undefined) {
|
|
normalized.errorCode = toStringOrNull(metadata.errorCode) || null;
|
|
}
|
|
if (metadata.correlationId !== undefined) {
|
|
normalized.correlationId = toStringOrNull(metadata.correlationId) || null;
|
|
}
|
|
if (metadata.sessionTag !== undefined) {
|
|
normalized.sessionTag = toStringOrNull(metadata.sessionTag) || null;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
// ──────────────── Pending Requests (in-memory) ────────────────
|
|
|
|
declare global {
|
|
var __omnirouteUsageHistoryPendingState:
|
|
| {
|
|
pendingRequests: {
|
|
byModel: Record<string, number>;
|
|
byAccount: Record<string, Record<string, number>>;
|
|
details: Record<string, Record<string, PendingRequestDetail[]>>;
|
|
};
|
|
pendingById: Map<string, PendingRequestDetail>;
|
|
}
|
|
| undefined;
|
|
}
|
|
|
|
// Reuse the SAME object/Map across Next.js dev HMR module re-evaluations —
|
|
// same pattern (and reason) as src/lib/db/core.ts's `globalThis.__omnirouteDb`.
|
|
// Without this, an edit anywhere in this module's dependency graph resets
|
|
// in-flight request tracking to empty mid-stream, so a live poll against
|
|
// getPendingById() (RequestLoggerDetail.tsx's Conversation Context section)
|
|
// silently stops seeing partialAssistantText for a request that started
|
|
// before the reload — the request keeps streaming fine, but the *next*
|
|
// module instance's pendingById has never heard of it.
|
|
const pendingState = (globalThis.__omnirouteUsageHistoryPendingState ??= {
|
|
pendingRequests: {
|
|
byModel: Object.create(null) as Record<string, number>,
|
|
byAccount: Object.create(null) as Record<string, Record<string, number>>,
|
|
details: Object.create(null) as Record<string, Record<string, PendingRequestDetail[]>>,
|
|
},
|
|
pendingById: new Map<string, PendingRequestDetail>(),
|
|
});
|
|
|
|
const pendingRequests = pendingState.pendingRequests;
|
|
|
|
/**
|
|
* O(1) ID → PendingRequestDetail lookup map.
|
|
* Populated when a detail is created and cleaned up when it is removed/finalized.
|
|
*/
|
|
const pendingById = pendingState.pendingById;
|
|
|
|
const DEFAULT_MAX_PENDING_REQUEST_AGE_MS = 60 * 60 * 1000;
|
|
const MAX_PENDING_DETAILS = 5000;
|
|
const PENDING_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
let _pendingSweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
export function getMaxPendingRequestAgeMs(
|
|
rawValue: string | undefined = process.env.MAX_PENDING_REQUEST_AGE_MS
|
|
): number {
|
|
const parsed = Number.parseInt(rawValue ?? "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PENDING_REQUEST_AGE_MS;
|
|
}
|
|
|
|
function ensurePendingSweepTimer(): void {
|
|
if (_pendingSweepTimer || typeof setInterval !== "function") return;
|
|
_pendingSweepTimer = setInterval(() => {
|
|
try {
|
|
sweepStalePendingRequests();
|
|
} catch {
|
|
/* never let the reaper throw on the timer thread */
|
|
}
|
|
}, PENDING_SWEEP_INTERVAL_MS);
|
|
// Don't keep the process alive just for the reaper.
|
|
(_pendingSweepTimer as { unref?: () => void })?.unref?.();
|
|
}
|
|
|
|
/**
|
|
* Evicts orphaned pending-request details older than `maxAgeMs` and enforces a hard size
|
|
* cap. Mirrors the normal removal path (decrement counters + cleanup detail buckets) so the
|
|
* dashboard's pending counts self-heal. Exported for deterministic testing.
|
|
* @returns number of entries removed.
|
|
*/
|
|
export function sweepStalePendingRequests(
|
|
now: number = Date.now(),
|
|
maxAgeMs: number = getMaxPendingRequestAgeMs()
|
|
): number {
|
|
let removed = 0;
|
|
|
|
const remove = (detail: PendingRequestDetail): void => {
|
|
const modelKey = detail.provider ? `${detail.model} (${detail.provider})` : detail.model;
|
|
pendingById.delete(detail.id);
|
|
if (detail.connectionId && isSafeKey(modelKey)) {
|
|
const bucket = pendingRequests.details[detail.connectionId]?.[modelKey];
|
|
if (bucket) {
|
|
const index = bucket.findIndex((entry) => entry.id === detail.id);
|
|
if (index >= 0) bucket.splice(index, 1);
|
|
}
|
|
cleanupPendingDetails(detail.connectionId, modelKey);
|
|
decrementPendingCounters(modelKey, detail.connectionId);
|
|
}
|
|
removed++;
|
|
};
|
|
|
|
for (const detail of pendingById.values()) {
|
|
if (now - detail.startedAt > maxAgeMs) remove(detail);
|
|
}
|
|
|
|
// Hard backstop: if entries are still piling up faster than they age out, drop the oldest
|
|
// beyond the cap.
|
|
if (pendingById.size > MAX_PENDING_DETAILS) {
|
|
const overflow = pendingById.size - MAX_PENDING_DETAILS;
|
|
const oldest = [...pendingById.values()]
|
|
.sort((a, b) => a.startedAt - b.startedAt)
|
|
.slice(0, overflow);
|
|
for (const detail of oldest) remove(detail);
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
/** Prototype-pollution denylist — prevents crafted model/provider names from mutating Object.prototype. */
|
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
function isSafeKey(key: string): boolean {
|
|
return !UNSAFE_KEYS.has(key);
|
|
}
|
|
|
|
/**
|
|
* Track a pending request.
|
|
*/
|
|
export function trackPendingRequest(
|
|
model: string,
|
|
provider: string,
|
|
connectionId: string | null,
|
|
started: boolean,
|
|
metadata?: PendingRequestMetadata
|
|
) {
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
if (!isSafeKey(modelKey)) return;
|
|
const normalizedMetadata = normalizePendingMetadata(metadata);
|
|
|
|
// Ensure the orphaned-pending reaper is running once pending tracking is in use.
|
|
if (started) ensurePendingSweepTimer();
|
|
|
|
// Use hasOwnProperty guard to prevent prototype pollution via crafted keys
|
|
if (!Object.hasOwn(pendingRequests.byModel, modelKey)) {
|
|
pendingRequests.byModel[modelKey] = 0;
|
|
}
|
|
pendingRequests.byModel[modelKey] = Math.max(
|
|
0,
|
|
pendingRequests.byModel[modelKey] + (started ? 1 : -1)
|
|
);
|
|
|
|
if (connectionId) {
|
|
if (!Object.hasOwn(pendingRequests.byAccount, connectionId)) {
|
|
pendingRequests.byAccount[connectionId] = Object.create(null) as Record<string, number>;
|
|
}
|
|
if (!Object.hasOwn(pendingRequests.details, connectionId)) {
|
|
pendingRequests.details[connectionId] = Object.create(null) as Record<
|
|
string,
|
|
PendingRequestDetail[]
|
|
>;
|
|
}
|
|
if (!Object.hasOwn(pendingRequests.byAccount[connectionId], modelKey)) {
|
|
pendingRequests.byAccount[connectionId][modelKey] = 0;
|
|
}
|
|
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
|
0,
|
|
pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)
|
|
);
|
|
|
|
const nextCount = pendingRequests.byAccount[connectionId][modelKey];
|
|
if (started && nextCount > 0) {
|
|
if (!pendingRequests.details[connectionId][modelKey]) {
|
|
pendingRequests.details[connectionId][modelKey] = [];
|
|
}
|
|
const now = Date.now();
|
|
const newDetail = {
|
|
// crypto RNG (not Math.random) to satisfy CodeQL js/insecure-randomness —
|
|
// this pending-request id flows into attempt logging; it's a correlation
|
|
// id, not a security secret.
|
|
id: `${now}-${globalThis.crypto.randomUUID().slice(0, 6)}`,
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
startedAt: now,
|
|
...normalizedMetadata,
|
|
};
|
|
pendingRequests.details[connectionId][modelKey].push(newDetail);
|
|
pendingById.set(newDetail.id, newDetail);
|
|
return newDetail.id;
|
|
} else if (!started && nextCount >= 0) {
|
|
if (pendingRequests.details[connectionId]?.[modelKey]?.length) {
|
|
const removed = pendingRequests.details[connectionId][modelKey].shift();
|
|
if (removed) pendingById.delete(removed.id);
|
|
}
|
|
if (!pendingRequests.details[connectionId]?.[modelKey]?.length) {
|
|
delete pendingRequests.details[connectionId]?.[modelKey];
|
|
if (Object.keys(pendingRequests.details[connectionId] || {}).length === 0) {
|
|
delete pendingRequests.details[connectionId];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updatePendingRequest(
|
|
model: string,
|
|
provider: string,
|
|
connectionId: string | null,
|
|
metadata: PendingRequestMetadata
|
|
) {
|
|
if (!connectionId) return;
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
if (!isSafeKey(modelKey)) return;
|
|
const details = pendingRequests.details[connectionId]?.[modelKey];
|
|
if (!details?.length) return;
|
|
const lastIdx = details.length - 1;
|
|
Object.assign(details[lastIdx], normalizePendingMetadata(metadata));
|
|
}
|
|
|
|
export function updatePendingRequestById(id: string | null, metadata: PendingRequestMetadata) {
|
|
const detail = id ? pendingById.get(id) : null;
|
|
if (!detail) return false;
|
|
Object.assign(detail, normalizePendingMetadata(metadata));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Update the first (oldest) pending request detail and then remove it.
|
|
* Unlike updatePendingRequest which targets the last entry, this is designed
|
|
* for the non-streaming completion path where the oldest entry must be finalized
|
|
* before trackPendingRequest(false) removes it from the FIFO queue.
|
|
*/
|
|
function decrementPendingCounters(modelKey: string, connectionId: string) {
|
|
if (Object.hasOwn(pendingRequests.byModel, modelKey)) {
|
|
pendingRequests.byModel[modelKey] = Math.max(0, pendingRequests.byModel[modelKey] - 1);
|
|
if (pendingRequests.byModel[modelKey] === 0) delete pendingRequests.byModel[modelKey];
|
|
}
|
|
if (Object.hasOwn(pendingRequests.byAccount, connectionId)) {
|
|
if (Object.hasOwn(pendingRequests.byAccount[connectionId], modelKey)) {
|
|
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
|
0,
|
|
pendingRequests.byAccount[connectionId][modelKey] - 1
|
|
);
|
|
if (pendingRequests.byAccount[connectionId][modelKey] === 0) {
|
|
delete pendingRequests.byAccount[connectionId][modelKey];
|
|
}
|
|
}
|
|
if (
|
|
!pendingRequests.byAccount[connectionId] ||
|
|
Object.keys(pendingRequests.byAccount[connectionId]).length === 0
|
|
) {
|
|
delete pendingRequests.byAccount[connectionId];
|
|
}
|
|
}
|
|
}
|
|
|
|
function cleanupPendingDetails(connectionId: string, modelKey: string) {
|
|
if (!pendingRequests.details[connectionId]?.[modelKey]?.length) {
|
|
delete pendingRequests.details[connectionId]?.[modelKey];
|
|
}
|
|
if (
|
|
!pendingRequests.details[connectionId] ||
|
|
Object.keys(pendingRequests.details[connectionId]).length === 0
|
|
) {
|
|
delete pendingRequests.details[connectionId];
|
|
}
|
|
}
|
|
|
|
function finalizePendingDetailAt(
|
|
connectionId: string,
|
|
modelKey: string,
|
|
index: number,
|
|
metadata: PendingRequestMetadata
|
|
): string | null {
|
|
if (!isSafeKey(modelKey)) return null;
|
|
const details = pendingRequests.details[connectionId]?.[modelKey];
|
|
if (!details?.length || index < 0 || index >= details.length) return null;
|
|
|
|
const completedAt = Date.now();
|
|
const updated = {
|
|
...details[index],
|
|
...normalizePendingMetadata(metadata),
|
|
completedAt,
|
|
durationMs: Math.max(0, completedAt - details[index].startedAt),
|
|
};
|
|
storeCompletedDetail(updated);
|
|
maybeEnrichCompletedDetail(updated, connectionId);
|
|
scheduleCompletedDetailCleanup(updated.id);
|
|
|
|
details.splice(index, 1);
|
|
pendingById.delete(updated.id);
|
|
cleanupPendingDetails(connectionId, modelKey);
|
|
decrementPendingCounters(modelKey, connectionId);
|
|
return updated.id;
|
|
}
|
|
|
|
export function finalizePendingRequest(
|
|
model: string,
|
|
provider: string,
|
|
connectionId: string | null,
|
|
metadata: PendingRequestMetadata
|
|
) {
|
|
if (!connectionId) return;
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
finalizePendingDetailAt(connectionId, modelKey, 0, metadata);
|
|
}
|
|
|
|
export function finalizePendingRequestById(
|
|
id: string | null | undefined,
|
|
metadata: PendingRequestMetadata
|
|
): boolean {
|
|
if (!id) return false;
|
|
const detail = pendingById.get(id);
|
|
if (!detail?.connectionId) return false;
|
|
const modelKey = detail.provider ? `${detail.model} (${detail.provider})` : detail.model;
|
|
if (!isSafeKey(modelKey)) return false;
|
|
const details = pendingRequests.details[detail.connectionId]?.[modelKey];
|
|
const index = details?.findIndex((entry) => entry.id === id) ?? -1;
|
|
return finalizePendingDetailAt(detail.connectionId, modelKey, index, metadata) !== null;
|
|
}
|
|
|
|
/**
|
|
* Finalize the most recent (last) pending request for the given model/provider/connection.
|
|
* This remains as a compatibility fallback for callers that do not have a request id.
|
|
*/
|
|
export function finalizeMostRecentPendingRequest(
|
|
model: string,
|
|
provider: string,
|
|
connectionId: string | null,
|
|
metadata: PendingRequestMetadata
|
|
) {
|
|
if (!connectionId) return;
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
if (!isSafeKey(modelKey)) return;
|
|
const details = pendingRequests.details[connectionId]?.[modelKey];
|
|
if (!details?.length) return;
|
|
finalizePendingDetailAt(connectionId, modelKey, details.length - 1, metadata);
|
|
}
|
|
|
|
export { getCompletedDetails } from "./completedRequestDetails";
|
|
|
|
export function updatePendingRequestStreamChunks(
|
|
model: string,
|
|
provider: string,
|
|
connectionId: string | null,
|
|
streamChunks: {
|
|
provider?: string[];
|
|
openai?: string[];
|
|
client?: string[];
|
|
} | null
|
|
) {
|
|
if (!connectionId) return;
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
if (!isSafeKey(modelKey)) return;
|
|
const details = pendingRequests.details[connectionId]?.[modelKey];
|
|
if (!details?.length) return;
|
|
details[0].streamChunks = streamChunks;
|
|
}
|
|
|
|
/**
|
|
* Get the pending requests state (for usageStats).
|
|
* @returns {{ byModel: Record<string, number>, byAccount: Record<string, Record<string, number>> }}
|
|
*/
|
|
export function getPendingRequests(): {
|
|
byModel: Record<string, number>;
|
|
byAccount: Record<string, Record<string, number>>;
|
|
} {
|
|
return pendingRequests;
|
|
}
|
|
|
|
export function getPendingById(): Map<string, PendingRequestDetail> {
|
|
return pendingById;
|
|
}
|
|
|
|
/**
|
|
* Clear all pending request counts.
|
|
* Used for admin reset when counts leak due to uncaught timeouts or process-level errors.
|
|
*/
|
|
export function clearPendingRequests() {
|
|
pendingRequests.byModel = Object.create(null) as Record<string, number>;
|
|
pendingRequests.byAccount = Object.create(null) as Record<string, Record<string, number>>;
|
|
pendingRequests.details = Object.create(null) as Record<
|
|
string,
|
|
Record<string, PendingRequestDetail[]>
|
|
>;
|
|
pendingById.clear();
|
|
clearCompletedDetails();
|
|
}
|
|
|
|
// ──────────────── getUsageDb Shim (backward compat) ────────────────
|
|
|
|
const MAX_ROWS = 10000;
|
|
|
|
/**
|
|
* Returns an object compatible with the old LowDB interface.
|
|
* Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`.
|
|
*
|
|
* @param sinceIso - ISO timestamp to filter from (inclusive)
|
|
* @param limit - Max rows to return (default 10,000)
|
|
* @param cursor - Timestamp cursor for pagination (exclusive, for next page)
|
|
*/
|
|
export async function getUsageDb(sinceIso?: string | null, limit?: number, cursor?: string | null) {
|
|
const db = getDbInstance();
|
|
const maxRows = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Number(limit) : MAX_ROWS;
|
|
|
|
let rows;
|
|
if (cursor) {
|
|
// Cursor-based pagination (next page after cursor)
|
|
// Use > cursor to get rows after the last timestamp of previous page (ASC order)
|
|
rows = sinceIso
|
|
? db
|
|
.prepare(
|
|
`SELECT * FROM usage_history WHERE timestamp >= ? AND timestamp > ? ORDER BY timestamp ASC LIMIT ?`
|
|
)
|
|
.all(sinceIso, cursor, maxRows)
|
|
: db
|
|
.prepare(`SELECT * FROM usage_history WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?`)
|
|
.all(cursor, maxRows);
|
|
} else if (sinceIso) {
|
|
// Initial query with date filter
|
|
rows = db
|
|
.prepare(`SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC LIMIT ?`)
|
|
.all(sinceIso, maxRows);
|
|
} else {
|
|
// No filter - get all (with limit)
|
|
rows = db.prepare(`SELECT * FROM usage_history ORDER BY timestamp ASC LIMIT ?`).all(maxRows);
|
|
}
|
|
|
|
const history = rows.map((row) => {
|
|
const r = asRecord(row);
|
|
return {
|
|
provider: toStringOrNull(r.provider),
|
|
model: toStringOrNull(r.model),
|
|
connectionId: toStringOrNull(r.connection_id),
|
|
apiKeyId: toStringOrNull(r.api_key_id),
|
|
apiKeyName: toStringOrNull(r.api_key_name),
|
|
serviceTier: normalizeServiceTier(r.service_tier),
|
|
tokens: {
|
|
input: toNumber(r.tokens_input),
|
|
output: toNumber(r.tokens_output),
|
|
cacheRead: toNumber(r.tokens_cache_read),
|
|
cacheCreation: toNumber(r.tokens_cache_creation),
|
|
reasoning: toNumber(r.tokens_reasoning),
|
|
},
|
|
status: toStringOrNull(r.status),
|
|
success: toNumber(r.success) === 1,
|
|
latencyMs: toNumber(r.latency_ms),
|
|
timeToFirstTokenMs: toNumber(r.ttft_ms),
|
|
errorCode: toStringOrNull(r.error_code),
|
|
timestamp: toStringOrNull(r.timestamp),
|
|
};
|
|
});
|
|
|
|
// Provide next cursor if we hit the limit (more rows exist)
|
|
const nextCursor =
|
|
rows.length === maxRows ? toStringOrNull(asRecord(rows[rows.length - 1]).timestamp) : null;
|
|
|
|
return { data: { history, nextCursor } };
|
|
}
|
|
|
|
// ──────────────── Save Request Usage ────────────────
|
|
|
|
/**
|
|
* DB-entity-mapped shape accepted by {@link saveRequestUsage}, mirroring the
|
|
* `usage_history` table columns 1:1 (see `src/lib/db/migrations/`). Convention
|
|
* (#3512): every `usage_history` writer should type its entry against this
|
|
* interface instead of an inline anonymous object or `any` — call sites are
|
|
* intentionally permissive (fields optional/nullable) because rows are built
|
|
* incrementally across several extraction points (chatCore success/failure
|
|
* paths, rejected-request accounting, the Codex Responses WS bridge).
|
|
*
|
|
* `tokens` stays `unknown` on purpose: callers pass either the raw
|
|
* provider-shaped usage object (OpenAI `prompt_tokens`/`completion_tokens`,
|
|
* Anthropic `input_tokens`/`cache_read_input_tokens`, …) or the already
|
|
* normalized `{ input, output, cacheRead, cacheCreation, reasoning }` shape —
|
|
* `getLoggedInputTokens`/`getLoggedOutputTokens`/`getPromptCache*Tokens` in
|
|
* `./tokenAccounting` accept both and extract the right fields.
|
|
*/
|
|
export interface UsageEntry {
|
|
provider?: string | null;
|
|
model?: string | null;
|
|
/** Raw or normalized token usage — see the interface doc above. */
|
|
tokens?: unknown;
|
|
status?: string | null;
|
|
success?: boolean;
|
|
latencyMs?: number;
|
|
timeToFirstTokenMs?: number;
|
|
errorCode?: string | null;
|
|
/** ISO timestamp; defaults to `new Date().toISOString()` when omitted. */
|
|
timestamp?: string;
|
|
connectionId?: string | null;
|
|
apiKeyId?: string | null;
|
|
apiKeyName?: string | null;
|
|
serviceTier?: string | null;
|
|
/** @deprecated legacy snake_case fallback, read only if `serviceTier` is unset. */
|
|
service_tier?: string | null;
|
|
comboStrategy?: string | null;
|
|
/** @deprecated legacy snake_case fallback, read only if `comboStrategy` is unset. */
|
|
combo_strategy?: string | null;
|
|
endpoint?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Save request usage entry to SQLite.
|
|
*/
|
|
export async function saveRequestUsage(entry: UsageEntry) {
|
|
if (!shouldPersistToDisk) return;
|
|
|
|
try {
|
|
const db = getDbInstance();
|
|
const timestamp = entry.timestamp || new Date().toISOString();
|
|
const serviceTier = normalizeServiceTier(entry.serviceTier ?? entry.service_tier);
|
|
|
|
const tokensInput = getLoggedInputTokens(entry.tokens);
|
|
const tokensOutput = getLoggedOutputTokens(entry.tokens);
|
|
const connection = entry.connectionId
|
|
? (db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(entry.connectionId) as
|
|
Record<string, unknown> | undefined)
|
|
: undefined;
|
|
const accountIdentity = connection
|
|
? resolveUsageAccountIdentity(connection)
|
|
: resolveOrphanedUsageAccountIdentity(entry.provider, entry.connectionId);
|
|
|
|
// Dedup guard: skip INSERT when an identical row already exists in the same
|
|
// second. This prevents double-counting when onRequestSuccess fires more
|
|
// than once (e.g. combo routing calling the callback from both the
|
|
// streaming and non-streaming paths for the same underlying request).
|
|
// Keyed on the natural identity of a request: timestamp + provider + model
|
|
// + connectionId + apiKeyId + token counts. If only the endpoint is missing
|
|
// on the existing row, fill it in rather than inserting a duplicate.
|
|
let inserted = false;
|
|
|
|
db.transaction(() => {
|
|
const existing = db
|
|
.prepare(
|
|
`SELECT id, endpoint FROM usage_history
|
|
WHERE timestamp = ?
|
|
AND COALESCE(provider, '') = COALESCE(?, '')
|
|
AND COALESCE(model, '') = COALESCE(?, '')
|
|
AND COALESCE(connection_id, '') = COALESCE(?, '')
|
|
AND COALESCE(api_key_id, '') = COALESCE(?, '')
|
|
AND tokens_input = ?
|
|
AND tokens_output = ?
|
|
ORDER BY id DESC LIMIT 1`
|
|
)
|
|
.get(
|
|
timestamp,
|
|
entry.provider || null,
|
|
entry.model || null,
|
|
entry.connectionId || null,
|
|
entry.apiKeyId || null,
|
|
tokensInput,
|
|
tokensOutput
|
|
) as { id: number; endpoint: string | null } | undefined;
|
|
|
|
if (existing) {
|
|
// Back-fill endpoint if the original row missed it.
|
|
if (!existing.endpoint && entry.endpoint) {
|
|
db.prepare(`UPDATE usage_history SET endpoint = ? WHERE id = ?`).run(
|
|
entry.endpoint,
|
|
existing.id
|
|
);
|
|
}
|
|
return; // duplicate — do not insert
|
|
}
|
|
|
|
db.prepare(
|
|
`
|
|
INSERT INTO usage_history (provider, model, connection_id, account_key, account_label,
|
|
account_label_priority, api_key_id, api_key_name, tokens_input, tokens_output,
|
|
tokens_cache_read, tokens_cache_creation, tokens_reasoning, service_tier, status, success,
|
|
latency_ms, ttft_ms, error_code, combo_strategy, endpoint, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
).run(
|
|
entry.provider || null,
|
|
entry.model || null,
|
|
entry.connectionId || null,
|
|
accountIdentity.accountKey,
|
|
accountIdentity.accountLabel,
|
|
accountIdentity.accountLabelPriority,
|
|
entry.apiKeyId || null,
|
|
entry.apiKeyName || null,
|
|
tokensInput,
|
|
tokensOutput,
|
|
getPromptCacheReadTokens(entry.tokens),
|
|
getPromptCacheCreationTokens(entry.tokens),
|
|
getReasoningTokens(entry.tokens),
|
|
serviceTier,
|
|
entry.status || null,
|
|
entry.success === false ? 0 : 1,
|
|
Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0,
|
|
Number.isFinite(Number(entry.timeToFirstTokenMs))
|
|
? Number(entry.timeToFirstTokenMs)
|
|
: Number.isFinite(Number(entry.latencyMs))
|
|
? Number(entry.latencyMs)
|
|
: 0,
|
|
entry.errorCode || null,
|
|
entry.comboStrategy || entry.combo_strategy || null,
|
|
entry.endpoint || null,
|
|
timestamp
|
|
);
|
|
|
|
inserted = true;
|
|
})();
|
|
|
|
// Decoupled via the event bus so usageHistory never imports providerLimits
|
|
// (which would pull the executors/translator graph into the type-check surface).
|
|
// Only emit when a row was actually inserted — not on dedup no-ops.
|
|
if (inserted) {
|
|
emitUsageRecorded(entry.provider, entry.connectionId);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to save usage stats:", error);
|
|
}
|
|
}
|
|
|
|
// ──────────────── Get Usage History ────────────────
|
|
|
|
export interface UsageHistoryFilter {
|
|
provider?: string;
|
|
model?: string;
|
|
startDate?: string | number | Date;
|
|
endDate?: string | number | Date;
|
|
}
|
|
|
|
/**
|
|
* Get usage history with optional filters.
|
|
*/
|
|
export async function getUsageHistory(filter: UsageHistoryFilter = {}) {
|
|
const db = getDbInstance();
|
|
let sql = "SELECT * FROM usage_history";
|
|
const conditions: string[] = [];
|
|
const params: Record<string, unknown> = {};
|
|
|
|
if (filter.provider) {
|
|
conditions.push("provider = @provider");
|
|
params.provider = filter.provider;
|
|
}
|
|
if (filter.model) {
|
|
conditions.push("model = @model");
|
|
params.model = filter.model;
|
|
}
|
|
if (filter.startDate) {
|
|
conditions.push("timestamp >= @startDate");
|
|
params.startDate = new Date(filter.startDate).toISOString();
|
|
}
|
|
if (filter.endDate) {
|
|
conditions.push("timestamp <= @endDate");
|
|
params.endDate = new Date(filter.endDate).toISOString();
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
sql += " WHERE " + conditions.join(" AND ");
|
|
}
|
|
sql += " ORDER BY timestamp ASC";
|
|
|
|
const rows = db.prepare(sql).all(params);
|
|
return rows.map((row) => {
|
|
const r = asRecord(row);
|
|
return {
|
|
provider: toStringOrNull(r.provider),
|
|
model: toStringOrNull(r.model),
|
|
connectionId: toStringOrNull(r.connection_id),
|
|
apiKeyId: toStringOrNull(r.api_key_id),
|
|
apiKeyName: toStringOrNull(r.api_key_name),
|
|
serviceTier: normalizeServiceTier(r.service_tier),
|
|
tokens: {
|
|
input: toNumber(r.tokens_input),
|
|
output: toNumber(r.tokens_output),
|
|
cacheRead: toNumber(r.tokens_cache_read),
|
|
cacheCreation: toNumber(r.tokens_cache_creation),
|
|
reasoning: toNumber(r.tokens_reasoning),
|
|
},
|
|
status: toStringOrNull(r.status),
|
|
success: toNumber(r.success) === 1,
|
|
latencyMs: toNumber(r.latency_ms),
|
|
timeToFirstTokenMs: toNumber(r.ttft_ms),
|
|
errorCode: toStringOrNull(r.error_code),
|
|
timestamp: toStringOrNull(r.timestamp),
|
|
};
|
|
});
|
|
}
|
|
|
|
export type { ModelLatencyStatsEntry } from "./usageHistory/helpers";
|
|
|
|
/**
|
|
* Aggregate rolling latency stats per provider/model from usage_history.
|
|
* Used by auto-combo routing to incorporate real-world latency and reliability.
|
|
* Also computes avgTtftMs/avgE2ELatencyMs/avgTokensPerSecond (#6875) via the
|
|
* accumulateLatencySample/buildLatencyStatsEntry helpers.
|
|
*/
|
|
export async function getModelLatencyStats(
|
|
options: {
|
|
windowHours?: number;
|
|
minSamples?: number;
|
|
maxRows?: number;
|
|
provider?: string;
|
|
model?: string;
|
|
} = {}
|
|
): Promise<Record<string, ModelLatencyStatsEntry>> {
|
|
const windowHours = resolvePositiveOption(options.windowHours, 24);
|
|
const minSamples = resolvePositiveOption(options.minSamples, 1);
|
|
const maxRows = resolvePositiveOption(options.maxRows, 10000);
|
|
|
|
const db = getDbInstance();
|
|
const sinceIso = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
|
|
|
type LatencyRow = {
|
|
provider: string | null;
|
|
model: string | null;
|
|
success: number | null;
|
|
latency_ms: number | null;
|
|
ttft_ms: number | null;
|
|
tokens_output: number | null;
|
|
};
|
|
|
|
const conditions = ["timestamp >= @sinceIso", "provider IS NOT NULL", "model IS NOT NULL"];
|
|
const queryParams: Record<string, unknown> = { sinceIso, maxRows };
|
|
if (options.provider) {
|
|
conditions.push("provider = @provider");
|
|
queryParams.provider = options.provider;
|
|
}
|
|
if (options.model) {
|
|
conditions.push("model = @model");
|
|
queryParams.model = options.model;
|
|
}
|
|
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT provider, model, success, latency_ms, ttft_ms, tokens_output
|
|
FROM usage_history
|
|
WHERE ${conditions.join(" AND ")}
|
|
ORDER BY timestamp DESC
|
|
LIMIT @maxRows
|
|
`
|
|
)
|
|
.all(queryParams) as LatencyRow[];
|
|
|
|
const grouped = new Map<string, ReturnType<typeof createLatencyBucket>>();
|
|
|
|
for (const row of rows) {
|
|
const provider = toStringOrNull(row.provider);
|
|
const model = toStringOrNull(row.model);
|
|
if (!provider || !model) continue;
|
|
|
|
const key = `${provider}/${model}`;
|
|
if (!grouped.has(key)) grouped.set(key, createLatencyBucket(provider, model));
|
|
const bucket = grouped.get(key);
|
|
if (!bucket) continue;
|
|
|
|
bucket.totalRequests += 1;
|
|
const isSuccess = toNumber(row.success) !== 0;
|
|
if (isSuccess) bucket.successfulRequests += 1;
|
|
|
|
accumulateLatencySample(
|
|
bucket,
|
|
toNumber(row.latency_ms),
|
|
toNumber(row.ttft_ms),
|
|
toNumber(row.tokens_output),
|
|
isSuccess
|
|
);
|
|
}
|
|
|
|
const stats: Record<string, ModelLatencyStatsEntry> = {};
|
|
for (const [key, bucket] of grouped.entries()) {
|
|
const entry = buildLatencyStatsEntry(key, bucket, minSamples, windowHours);
|
|
if (entry) stats[key] = entry;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
// ──────────────── Request Log Compatibility Shim ────────────────
|
|
|
|
/**
|
|
* Legacy compatibility shim.
|
|
* Request summary lines are no longer written to data/log.txt.
|
|
*/
|
|
export async function appendRequestLog({
|
|
model: _model,
|
|
provider: _provider,
|
|
connectionId: _connectionId,
|
|
tokens: _tokens,
|
|
status: _status,
|
|
}: {
|
|
model?: string;
|
|
provider?: string;
|
|
connectionId?: string;
|
|
tokens?: unknown;
|
|
status?: string | number;
|
|
}) {
|
|
// Deprecated: request summaries now come from SQLite call_logs.
|
|
}
|
|
|
|
/**
|
|
* Return recent request summaries generated from SQLite call_logs rows.
|
|
*/
|
|
export async function getRecentLogs(limit = 200) {
|
|
try {
|
|
const db = getDbInstance();
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT timestamp, model, provider, account, tokens_in, tokens_out, status
|
|
FROM call_logs
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
`
|
|
)
|
|
.all(limit) as Array<Record<string, unknown>>;
|
|
|
|
return rows.map((row) => {
|
|
const timestamp =
|
|
typeof row.timestamp === "string" ? row.timestamp : new Date().toISOString();
|
|
const provider = typeof row.provider === "string" ? row.provider.toUpperCase() : "-";
|
|
const model = typeof row.model === "string" ? row.model : "-";
|
|
const account = typeof row.account === "string" ? row.account : "-";
|
|
const tokensIn = toNumber(row.tokens_in);
|
|
const tokensOut = toNumber(row.tokens_out);
|
|
const status = typeof row.status === "number" ? row.status : String(row.status || "-");
|
|
return `${timestamp} | ${model} | ${provider} | ${account} | ${tokensIn} | ${tokensOut} | ${status}`;
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[usageDb] Failed to read recent call logs:",
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
return [];
|
|
}
|
|
}
|