Files
OmniRoute/src/lib/usage/usageHistory.ts
Markus Hartung 6ff7b26277 fix(dashboard): keep a request's pending-tracking id stable across combo target retries (#12650)
Validado em lote numa worktree combinada com os 3 PRs desta leva sobre o tip de `release/v3.8.51`: os três boardaram sem conflito, `typecheck:core` limpo e **22/22** nos arquivos de teste que trazem.

O crescimento de `src/sse/handlers/chat.ts` (2450 → 2454) é do #12641 e vai num PR de rebaseline próprio.

Obrigado, @hartmark.
2026-09-04 00:03:34 -03:00

1016 lines
36 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 { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
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 = sanitizeErrorMessage(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>;
pendingIdByCorrelation: Map<string, { id: string; touchedAt: number }>;
}
| 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>(),
pendingIdByCorrelation: new Map<string, { id: string; touchedAt: number }>(),
});
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;
// Live incident: a combo dispatch calls trackPendingRequest once PER TARGET
// ATTEMPT (open-sse/handlers/chatCore.ts's single "started" call site, hit
// again on every fallback), each generating its OWN fresh id. A dashboard tab
// polling /api/logs/<id> for the FIRST attempt goes stale the moment that
// attempt finalizes and the combo silently retries with a different target
// under a different id -- the tab has no way to discover the new id, and the
// request keeps streaming (successfully) with nobody watching it live. Since
// correlationId is already stable across every attempt of one client request
// (see the trackPendingRequest call site's `correlationId` metadata field),
// reusing the SAME pending id for every attempt sharing a correlationId keeps
// one dashboard tab's poll target valid across combo fallbacks. Bounded by
// PENDING_SWEEP_INTERVAL_MS's existing reaper cycle (see sweepStalePendingRequests)
// so this never grows unboundedly with one-shot correlation ids.
const pendingIdByCorrelation = pendingState.pendingIdByCorrelation;
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);
}
// pendingIdByCorrelation entries are correlation ids, never reused across
// separate client requests, so nothing else ever removes them — same
// age/cap sweep as pendingById above, or the map grows unboundedly.
for (const [correlationId, entry] of pendingIdByCorrelation) {
if (now - entry.touchedAt > maxAgeMs) pendingIdByCorrelation.delete(correlationId);
}
if (pendingIdByCorrelation.size > MAX_PENDING_DETAILS) {
const overflow = pendingIdByCorrelation.size - MAX_PENDING_DETAILS;
const oldest = [...pendingIdByCorrelation.entries()]
.sort((a, b) => a[1].touchedAt - b[1].touchedAt)
.slice(0, overflow);
for (const [correlationId] of oldest) pendingIdByCorrelation.delete(correlationId);
}
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();
// Reuse the same pending id across every target attempt of one client
// request (see pendingIdByCorrelation's module-level comment) so a
// dashboard tab's live poll survives a combo fallback to a different
// target instead of silently going stale. Concurrent speculative
// attempts (combo.ts's zeroLatencyOptimizationsEnabled hedging) can
// race two "started" calls for the same correlationId — the second
// simply overwrites the id-keyed view of the first's still-live entry,
// no worse than today's per-attempt id (which loses tracking entirely
// once any attempt finalizes) and self-corrects on the next attempt.
const reusableId = normalizedMetadata.correlationId
? pendingIdByCorrelation.get(normalizedMetadata.correlationId)?.id
: undefined;
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: reusableId ?? `${now}-${globalThis.crypto.randomUUID().slice(0, 6)}`,
model,
provider,
connectionId,
startedAt: now,
...normalizedMetadata,
};
pendingRequests.details[connectionId][modelKey].push(newDetail);
pendingById.set(newDetail.id, newDetail);
if (normalizedMetadata.correlationId) {
pendingIdByCorrelation.set(normalizedMetadata.correlationId, { id: newDetail.id, touchedAt: now });
}
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();
pendingIdByCorrelation.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 [];
}
}