/** * Request statistics collector with debounced JSON persistence. */ import { readFileSync, existsSync } from 'fs'; import { AsyncLocalStorage } from 'node:async_hooks'; import { writeJsonAtomic } from '../fs-atomic.js'; import { join } from 'path'; import { config } from '../config.js'; import { getModelInfo, getCloudSelectorPricing, resolveModel } from '../models.js'; import { resolveConnectSelector } from '../devin-connect-models.js'; import { accountRef } from '../log-safety.js'; const STATS_FILE = join(config.dataDir, 'stats.json'); // v2.0.9x — cardinality bound for per-model stats. modelCounts is keyed by // the client-controlled model string; without a cap, case-permuted names or // free-tier passthrough names grow RAM + stats.json + the per-poll sort // unbounded. Cap the number of *real* model keys (LRU-evict the // least-recently-updated one into a shared '(other)' bucket so totals still // reconcile). Secure default is bounded; set STATS_MAX_MODELS=0 to opt back // into the old unbounded behavior. const OTHER_MODEL_KEY = '(other)'; const MAX_MODELS = (() => { const raw = parseInt(process.env.STATS_MAX_MODELS ?? '', 10); return Number.isFinite(raw) && raw >= 0 ? raw : 500; })(); // Monotonic recency counter for LRU eviction. Wall-clock time ties within a // single millisecond (many requests can land there), which would make // eviction fall back to insertion order and starve hot-but-old keys; a // strictly-increasing seq gives an unambiguous least-recently-updated pick. let _touchSeq = 0; // audit S8: cache of the current-hour hourlyBuckets entry so recordRequest // avoids an O(n) .find per request. Reset (null) means "re-resolve on next // record" — safe after restart or hour rollover. let _curBucket = null; // Every caller-keyed map is stored on a null prototype so a model literally // named "__proto__" is an ordinary own key instead of Object.prototype. The // public getStats()/exportStats() views convert these back to plain objects so // JSON consumers keep the historical shape. const MAP_KEYS = ['modelCounts', 'accountCounts', 'creditsByHour', 'creditsByDay', 'creditsByModel', 'tokensByModel']; const UNSAFE_MODEL_KEYS = new Set(['__proto__', 'constructor', 'toString', 'prototype']); /** A dictionary with no inherited keys. */ function emptyMap() { return Object.create(null); } /** * True for model names that would collide with a prototype/function property. * `Object.hasOwn(Object.prototype, key)` catches the wider inherited surface * (`valueOf`, `hasOwnProperty`, …) while the explicit set documents the three * names this fix is contracted to reject. */ function isUnsafeModelKey(key) { return typeof key !== 'string' || key === '' || UNSAFE_MODEL_KEYS.has(key) || Object.hasOwn(Object.prototype, key); } /** Shallow plain-object copy of an internal null-prototype map. */ function plainMap(source) { return Object.fromEntries(Object.entries(source && typeof source === 'object' ? source : {})); } /** Ensure a state map is null-prototype; migrate a plain object in place. */ function ensureNullProtoMap(name) { const current = _state[name]; if (!current || typeof current !== 'object' || Array.isArray(current) || Object.getPrototypeOf(current) !== null) { if (name === 'modelCounts') _state[name] = adoptModelCounts(current); else if (name === 'tokensByModel') _state[name] = adoptTokensByModel(current); else if (name === 'creditsByModel') _state[name] = adoptCreditsByModel(current); else _state[name] = adoptMap(current); } return _state[name]; } /** Count of tracked model keys excluding the shared overflow bucket. */ function realModelKeyCount(modelCounts = _state.modelCounts) { let n = 0; for (const k of Object.keys(modelCounts)) { if (k !== OTHER_MODEL_KEY) n++; } return n; } /** Fold an evicted model's counts into the shared '(other)' bucket. */ function foldIntoOther(src, modelCounts = _state.modelCounts) { let dst = Object.hasOwn(modelCounts, OTHER_MODEL_KEY) ? modelCounts[OTHER_MODEL_KEY] : null; if (!dst) { dst = { requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0 }; modelCounts[OTHER_MODEL_KEY] = dst; } dst.requests += src.requests || 0; dst.success += src.success || 0; dst.errors += src.errors || 0; dst.totalMs += src.totalMs || 0; if (!dst.recentMs) dst.recentMs = []; if (Array.isArray(src.recentMs) && src.recentMs.length) { for (const v of src.recentMs) dst.recentMs.push(v); if (dst.recentMs.length > 200) dst.recentMs = dst.recentMs.slice(-200); } dst.lastTs = modelCounts === _state.modelCounts ? ++_touchSeq : Math.max(dst.lastTs || 0, src.lastTs || 0); } /** * The modelCounts key a request belongs to, applying the cardinality cap. * * Shared by recordRequest and recordTokenUsage so the two maps cannot disagree * about which bucket a model landed in — a token row under a key the request * table never created would render as a model with tokens and zero requests. */ function boundedModelKey(model) { let key = typeof model === 'string' && model ? model : OTHER_MODEL_KEY; // Reject prototype-shaped names before any map lookup: "__proto__" would // otherwise resolve to Object.prototype, and "constructor"/"toString" to a // shared function object. They cannot be real model ids, so account for the // request in the shared overflow bucket instead. if (key !== OTHER_MODEL_KEY && isUnsafeModelKey(key)) key = OTHER_MODEL_KEY; if (key !== OTHER_MODEL_KEY && !Object.hasOwn(_state.modelCounts, key)) { enforceModelCap(); if (MAX_MODELS > 0 && realModelKeyCount() >= MAX_MODELS) key = OTHER_MODEL_KEY; } return key; } /** * Ensure there is room for one more real model key, LRU-evicting the * least-recently-updated real model into '(other)' while over the cap. * No-op when MAX_MODELS is 0 (unbounded). */ function enforceModelCap(reserveSlot = true, modelCounts = _state.modelCounts) { const evicted = []; if (MAX_MODELS <= 0) return evicted; while (realModelKeyCount(modelCounts) > MAX_MODELS - (reserveSlot ? 1 : 0)) { let coldestKey = null; let coldestTs = Infinity; for (const [k, s] of Object.entries(modelCounts)) { if (k === OTHER_MODEL_KEY) continue; const ts = s.lastTs || 0; if (ts < coldestTs) { coldestTs = ts; coldestKey = k; } } if (coldestKey == null) break; foldIntoOther(modelCounts[coldestKey], modelCounts); delete modelCounts[coldestKey]; evicted.push(coldestKey); } // Only the live state owns the live token map, and only the keys THIS call // evicted may be folded: a token row can legitimately exist before its request // row (recordTokenUsage runs first on several chat paths), so sweeping every // key absent from modelCounts would misfile a live model under '(other)'. if (modelCounts === _state.modelCounts && evicted.length) { pruneTokensToModelCounts(evicted, modelCounts); } return evicted; } /** * Fold the given models' token rows into '(other)'. * * Used for keys the request table actually lost (LRU eviction, or an imported * snapshot whose cap dropped them). The dashboard joins the two maps by key, and * an orphan would render a model that "moved 2M tokens" while the request table * says it never ran. */ function pruneTokensToModelCounts(keys, modelCounts = _state.modelCounts, tokens = _state.tokensByModel) { if (!tokens || typeof tokens !== 'object') return; for (const key of keys || []) { if (key === OTHER_MODEL_KEY) continue; if (Object.hasOwn(modelCounts, key)) continue; if (!Object.hasOwn(tokens, key)) continue; const dst = tokens[OTHER_MODEL_KEY] || { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0, }; tokens[OTHER_MODEL_KEY] = dst; for (const field of Object.keys(dst)) dst[field] += tokens[key][field] || 0; delete tokens[key]; } } const STATS_DEFAULTS = { startedAt: 0, totalRequests: 0, successCount: 0, errorCount: 0, modelCounts: {}, // { "gpt-4o-mini": { requests, success, errors, totalMs } } accountCounts: {}, // { "acct-<8 hex>": { requests, success, errors } } — opaque ref, never a key/email hourlyBuckets: [], // [{ hour: "2026-04-09T07:00:00Z", requests, errors }] // v2.0.69 (#118 wnfilm) — bucket-level token totals so the dashboard // can show fresh_input / cache_read / cache_write / output without // having to recompute from the per-request usage stream. Keyed by // bucket so summing across the proxy lifetime is just `totals[k]`. tokenTotals: { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0, }, // v2.0.91 — track upstream rejection/cooldown events policyBlockedCount: 0, // v3.4.x — content-policy-block observability ring. The upstream content // policy is non-deterministic (same prompt blocks then passes), so we // capture a small system-prompt-only sample at block time for later A/B. // Bounded ring, newest last; cap via POLICY_BLOCK_RING (default 50). recentPolicyBlocks: [], rateLimitedCount: 0, // v2.0.148 — Credits spend dimension. creditsByHour/Day are keyed maps // { "": creditsFloat } so the dashboard can chart spend over // time. Cost per request = MODELS[model].credit (rate card), summed here so we // never have to thread cost through the 10 recordRequest call sites. Only // successful attempts are charged; failed attempts keep their request row // with credit 0, so the log and this card share one number. creditsTotal: 0, creditsByHour: {}, // { "2026-07-09T01:00:00Z": 12.5 } creditsByDay: {}, // { "2026-07-09": 340.0 } creditsByModel: {}, // { "claude-opus-4-8-medium": 88.0 } // v3.10.x — per-model token totals. tokenTotals answers "how many tokens did // this deployment move"; this answers "which model moved them", which is the // question a bill asks. Keyed by the same bounded model key modelCounts uses, // so a client-permuted name cannot grow this map without bound. tokensByModel: {}, // { "swe-2-medium": { fresh_input, cache_read, cache_write, output, total, requests_with_usage } } // v2.0.148 — per-request detail ring buffer (bounded, persisted) so long runs // keep a rolling window of recent calls for audit/export instead of losing them. recentRequests: [], // [{ ts, model, success, ms, account, credit }] newest last, cap 500; account is an opaque acct- ref }; function createStatsState() { const state = structuredClone(STATS_DEFAULTS); for (const key of MAP_KEYS) state[key] = emptyMap(); return { ...state, startedAt: Date.now() }; } const _state = createStatsState(); const RECENT_REQ_CAP = 500; const ACCOUNT_REF_RE = /^acct-[0-9a-f]{8}$/; /** * Canonical account reference for stats persistence and display. * * The request log used to key this dimension by `String(accountId).slice(0, 8)`. * Chat handlers passed the credential the attempt presented, and every * `devin-session-token$…` key starts with `devin-se`, so all connect accounts * collapsed into one bucket and stats.json stored a credential prefix. Callers * now pass the immutable account id; this boundary turns any value into an * opaque `acct-<8 hex>` ref (the same shape the DevIn panel uses). A value that * already looks like a ref passes through, so re-reading a persisted row or * importing an exported snapshot is idempotent. */ function normalizeAccountRef(value) { if (value == null) return null; const raw = typeof value === 'string' ? value : String(value); if (!raw) return null; return ACCOUNT_REF_RE.test(raw) ? raw : accountRef(raw); } /** Re-key a persisted account map by normalized ref, merging old duplicates. */ function normalizeAccountCounts(map) { const out = emptyMap(); const source = map && typeof map === 'object' && !Array.isArray(map) ? map : {}; for (const [key, counts] of Object.entries(source)) { const ref = normalizeAccountRef(key); if (!ref) continue; const current = out[ref] || { requests: 0, success: 0, errors: 0 }; current.requests += Number(counts?.requests) || 0; current.success += Number(counts?.success) || 0; current.errors += Number(counts?.errors) || 0; out[ref] = current; } return out; } /** Scrub the credential-derived `account` values older versions persisted. */ function normalizeRecentRequests(rows) { if (!Array.isArray(rows)) return []; for (const row of rows) { if (row && typeof row === 'object') row.account = normalizeAccountRef(row.account); } return rows; } /** * Copy a persisted/generic map onto a null-prototype dictionary. Only own * enumerable keys are read, and the three prototype-shaped names are dropped. * Model-keyed maps use the adopt* helpers below, which fold unsafe names into * the shared '(other)' bucket instead of silently losing their totals. */ function adoptMap(source) { const out = emptyMap(); if (!source || typeof source !== 'object' || Array.isArray(source)) return out; for (const [key, value] of Object.entries(source)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; out[key] = value; } return out; } /** Adopt persisted model rows, folding prototype-shaped keys into '(other)'. */ function adoptModelCounts(source) { const out = emptyMap(); if (!source || typeof source !== 'object' || Array.isArray(source)) return out; for (const [key, value] of Object.entries(source)) { if (!isUnsafeModelKey(key)) { out[key] = value; continue; } if (!value || typeof value !== 'object') continue; const dst = out[OTHER_MODEL_KEY] || (out[OTHER_MODEL_KEY] = { requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0, }); for (const field of ['requests', 'success', 'errors', 'totalMs']) { dst[field] = (Number(dst[field]) || 0) + (Number(value[field]) || 0); } if (!Array.isArray(dst.recentMs)) dst.recentMs = []; if (Array.isArray(value.recentMs)) { for (const sample of value.recentMs) { if (Number.isFinite(sample)) dst.recentMs.push(sample); } if (dst.recentMs.length > 200) dst.recentMs = dst.recentMs.slice(-200); } dst.lastTs = Math.max(Number(dst.lastTs) || 0, Number(value.lastTs) || 0); } return out; } const TOKEN_FIELDS = ['fresh_input', 'cache_read', 'cache_write', 'output', 'total', 'requests_with_usage']; /** Adopt persisted token rows, folding prototype-shaped keys into '(other)'. */ function adoptTokensByModel(source) { const out = emptyMap(); if (!source || typeof source !== 'object' || Array.isArray(source)) return out; for (const [key, value] of Object.entries(source)) { if (!isUnsafeModelKey(key)) { out[key] = value; continue; } if (!value || typeof value !== 'object') continue; let dst = out[OTHER_MODEL_KEY]; if (!dst) { dst = emptyMap(); out[OTHER_MODEL_KEY] = dst; } for (const field of TOKEN_FIELDS) { dst[field] = (Number(dst[field]) || 0) + (Number(value[field]) || 0); } } return out; } /** Adopt persisted per-model credits, folding prototype-shaped keys into '(other)'. */ function adoptCreditsByModel(source) { const out = emptyMap(); if (!source || typeof source !== 'object' || Array.isArray(source)) return out; for (const [key, value] of Object.entries(source)) { if (typeof value !== 'number' || !Number.isFinite(value)) continue; const target = isUnsafeModelKey(key) ? OTHER_MODEL_KEY : key; out[target] = (Object.hasOwn(out, target) ? out[target] : 0) + value; } return out; } // ─── Per-HTTP-request accounting scope ───────────────────────────────── // recordRequest (the attempt row) and recordTokenUsage (the usage block) are // called from different depths of a chat handler and, depending on the route, // in either order. A module-level "last row" pointer would attribute tokens to // whichever concurrent request happened to finish last, so the two calls are // joined through the async context the HTTP server establishes per request. // Outside such a scope (unit tests, direct handler calls) both functions keep // their pre-existing aggregate-only behaviour and the log row gets the // success-derived status. const _requestScope = new AsyncLocalStorage(); /** * Run `fn` inside a per-request accounting scope. Returns fn's own result. * Re-entrant: an existing scope is reused, so wrapping a handler twice never * splits one request across two scopes. */ export function withStatsScope(fn) { const existing = _requestScope.getStore(); if (existing) return fn(existing); const scope = { rows: [], pendingUsage: null, status: null }; return _requestScope.run(scope, () => fn(scope)); } /** * Close a scope. The last attempt row is the one the client actually received, * so it gets the real HTTP status; earlier failover attempts keep the * success-derived 200/500 they were recorded with. Rows are the same objects * held in recentRequests, so this patch persists with the next scheduled save. */ export function finalizeStatsScope(scope) { if (!scope) return; const row = scope.rows[scope.rows.length - 1]; if (row && Number.isFinite(scope.status)) row.status = scope.status; } /** Non-negative finite number or 0 — rows come from persisted JSON too. */ function nonNeg(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0; } /** * Normalize an OpenAI-shaped usage block into the four counters the request * log stores. `tokensIn` is the full billable input (fresh + cache read + * cache write), matching what tokenTotals.total counts, so a row's * tokensIn + tokensOut always reconciles with the lifetime token card. */ function usageTokenCounts(usage) { if (!usage || typeof usage !== 'object') return null; const bd = usage.cascade_breakdown || null; const prompt = nonNeg(usage.prompt_tokens); const cacheRead = nonNeg(bd?.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens); const cacheWrite = nonNeg(bd?.cache_write_tokens ?? usage.cache_creation_input_tokens); // Connect usage carries the fresh bucket explicitly because its prompt_tokens // includes cache_write (O2); `prompt - cached` would count the write as fresh. const fresh = nonNeg(bd?.fresh_input_tokens ?? usage.fresh_input_tokens ?? Math.max(0, prompt - cacheRead)); const tokensOut = nonNeg(bd?.output_tokens ?? usage.completion_tokens ?? usage.output_tokens); if (!fresh && !cacheRead && !cacheWrite && !tokensOut) return null; return { tokensIn: fresh + cacheRead + cacheWrite, tokensOut, cacheRead, cacheWrite }; } function applyUsageToRow(row, usage) { const counts = usageTokenCounts(usage); if (!counts) return; row.tokensIn = counts.tokensIn; row.tokensOut = counts.tokensOut; row.cacheRead = counts.cacheRead; row.cacheWrite = counts.cacheWrite; } // Load persisted stats. The reviver drops prototype-shaped keys at every // depth (JSON.parse creates an own "__proto__" property, and Object.assign // would otherwise invoke its setter on _state). Each keyed map is then rebuilt // on a null prototype so a legacy stats.json written by the vulnerable version // cannot reintroduce a prototype key. try { if (existsSync(STATS_FILE)) { const saved = JSON.parse(readFileSync(STATS_FILE, 'utf-8'), (key, value) => key === '__proto__' || key === 'constructor' || key === 'prototype' ? undefined : value); Object.assign(_state, saved); _state.modelCounts = adoptModelCounts(_state.modelCounts); _state.tokensByModel = adoptTokensByModel(_state.tokensByModel); _state.creditsByModel = adoptCreditsByModel(_state.creditsByModel); _state.creditsByHour = adoptMap(_state.creditsByHour); _state.creditsByDay = adoptMap(_state.creditsByDay); // Older versions persisted the credential prefix (or a raw id) as the // account key/value. Normalize on load so stats.json and the dashboard stop // displaying it without waiting for the 500-row ring to rotate. _state.accountCounts = normalizeAccountCounts(_state.accountCounts); _state.recentRequests = normalizeRecentRequests(_state.recentRequests); // Reseed the recency counter above any persisted lastTs so LRU ordering // survives a restart without an early wrap-collision. for (const s of Object.values(_state.modelCounts || {})) { if (s && s.lastTs > _touchSeq) _touchSeq = s.lastTs; } } } catch {} // Debounced save let _saveTimer = null; function scheduleSave() { clearTimeout(_saveTimer); _saveTimer = setTimeout(() => { try { writeJsonAtomic(STATS_FILE, _state); } catch {} }, 5000); } function getHourKey() { const d = new Date(); d.setMinutes(0, 0, 0); return d.toISOString(); } /** * Credit cost of one request, from live billing data when the sync knows it. * * The static rate card is a snapshot of one moment and knows nothing about * promotions: swe-2-medium's card says 6 credits while the upstream marks it * FREE until 2026-11-01. Metering the card would report spend the account never * incurred, and the free badge on the model row would then contradict the very * number next to it. Precedence: live free → 0; live credit rate → that rate; * nothing live → the static card (the pre-existing behaviour). */ function requestCreditCost(model) { if (!model) return 0; const key = resolveModel(model) || model; const resolution = resolveConnectSelector(key, { warnOnFallback: false }); if (resolution?.mapped) { const live = getCloudSelectorPricing(resolution.selector); if (live.freeNow === true) return 0; if (typeof live.rate === 'number') return Math.max(0, live.rate); } const card = Number(getModelInfo(key)?.credit); return Number.isFinite(card) && card > 0 ? card : 0; } /** * Record a completed request. * * `extra` is optional and additive: `{ status, usage }`. `status` is the real * HTTP status when the caller knows it; otherwise the row carries the * success-derived 200/500. `usage` lets a caller that already holds the usage * block attach it directly; normally recordTokenUsage joins it via the scope. */ export function recordRequest(model, success, durationMs, accountId, extra = {}) { _state.totalRequests++; if (success) _state.successCount++; else _state.errorCount++; // A cache-served turn is real client traffic but ZERO upstream consumption: // no request reached the backend, so it must not charge the model's credit // card. Before this flag the Connect replay arms called recordRequest like any // completed turn, so every cache hit re-billed the answer it replayed. The row // keeps the marker so the log can tell the two apart. const cacheServed = extra?.cacheServed === true; // Per-model stats (includes a small ring buffer for p50/p95 latency). // Cap real-model cardinality: a brand-new key that would exceed the limit // gets folded into the shared '(other)' bucket instead (LRU eviction of the // coldest existing key). Known keys and '(other)' itself always update. // The map is null-prototype and every read is an own-key check, so a model // string can never resolve to an inherited object/function. const modelCounts = ensureNullProtoMap('modelCounts'); const key = boundedModelKey(model); if (!Object.hasOwn(modelCounts, key)) { modelCounts[key] = { requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0 }; } const mc = modelCounts[key]; mc.requests++; if (success) mc.success++; else mc.errors++; mc.totalMs += durationMs; mc.lastTs = ++_touchSeq; if (!mc.recentMs) mc.recentMs = []; if (durationMs > 0) { mc.recentMs.push(durationMs); if (mc.recentMs.length > 200) mc.recentMs.shift(); } // Per-account stats, keyed by the same opaque ref the log row carries. const account = normalizeAccountRef(accountId); if (account) { const accountCounts = ensureNullProtoMap('accountCounts'); if (!Object.hasOwn(accountCounts, account)) { accountCounts[account] = { requests: 0, success: 0, errors: 0 }; } const ac = accountCounts[account]; ac.requests++; if (success) ac.success++; else ac.errors++; } // Hourly bucket. audit S8: the current-hour bucket is (almost) always the // last element, so a per-request O(n) linear .find over up to 720 buckets was // wasted work. Cache the current hour's bucket ref and only re-resolve when // the hour rolls over (or after a restart, where _curBucket starts null and // we fall back to the tail/find once). const hourKey = getHourKey(); let bucket = _curBucket && _curBucket.hour === hourKey ? _curBucket : null; if (!bucket) { const tail = _state.hourlyBuckets[_state.hourlyBuckets.length - 1]; bucket = tail && tail.hour === hourKey ? tail : _state.hourlyBuckets.find(b => b.hour === hourKey); } if (!bucket) { bucket = { hour: hourKey, requests: 0, errors: 0 }; _state.hourlyBuckets.push(bucket); // Keep last 30 days of hourly data (720 buckets) if (_state.hourlyBuckets.length > 720) _state.hourlyBuckets.shift(); } _curBucket = bucket; bucket.requests++; if (!success) bucket.errors++; // v2.0.148 — Credits spend + per-request detail. Cost comes from the live // billing sync when it knows the model (free promo → 0, catalog/rate-table // multiplier otherwise) and only falls back to the static rate card when the // upstream has said nothing. Persisted maps stay JSON-safe. // // A failed attempt is not spend: the credits card has always charged // successes only, while the row used to carry the model's price anyway, so // the log's cost column/summary over-reported by every failover, 429 and 500. // Store the charged amount (0 on failure); `success` is the explicit failed // flag, so the row and the card share one number. let credit = 0; if (success && !cacheServed) { // Never let a prototype-shaped model name reach the catalog lookup either; // it cannot name a real price card. const costModel = typeof model === 'string' && !isUnsafeModelKey(model) ? model : null; try { credit = requestCreditCost(costModel); } catch { credit = 0; } } if (credit > 0) { const dayKey = hourKey.slice(0, 10); const creditsByHour = ensureNullProtoMap('creditsByHour'); const creditsByDay = ensureNullProtoMap('creditsByDay'); const creditsByModel = ensureNullProtoMap('creditsByModel'); _state.creditsTotal = (_state.creditsTotal || 0) + credit; creditsByHour[hourKey] = (Object.hasOwn(creditsByHour, hourKey) ? creditsByHour[hourKey] : 0) + credit; creditsByDay[dayKey] = (Object.hasOwn(creditsByDay, dayKey) ? creditsByDay[dayKey] : 0) + credit; // Key the credit row by the same bounded key as modelCounts so the // dashboard join stays intact and a dangerous model name is normalized. creditsByModel[key] = (Object.hasOwn(creditsByModel, key) ? creditsByModel[key] : 0) + credit; // Prune credit hour map to ~30 days (720 keys), day map to ~90 days. pruneKeyed(creditsByHour, 720); pruneKeyed(creditsByDay, 90); } if (!Array.isArray(_state.recentRequests)) _state.recentRequests = []; const scope = _requestScope.getStore(); const row = { ts: Date.now(), model, success: !!success, ms: durationMs || 0, account, credit, status: Number.isFinite(extra?.status) ? extra.status : (success ? 200 : 500), tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, ...(cacheServed ? { cacheServed: true } : {}), }; // Usage can legitimately arrive before its row (the DEVIN_CONNECT non-stream // path bills inside the attempt, then finalizes the account). The scope holds // it until the row exists; a usage block handed in directly wins over it. const usage = extra.usage || scope?.pendingUsage || null; if (usage) applyUsageToRow(row, usage); if (scope) { scope.rows.push(row); scope.pendingUsage = null; } _state.recentRequests.push(row); if (_state.recentRequests.length > RECENT_REQ_CAP) { _state.recentRequests.splice(0, _state.recentRequests.length - RECENT_REQ_CAP); } scheduleSave(); } // Keep a keyed map bounded to the most recent `max` keys (lexicographic ISO // order == chronological). Drops the oldest keys when over cap. function pruneKeyed(map, max) { const keys = Object.keys(map); if (keys.length <= max) return; keys.sort(); for (const k of keys.slice(0, keys.length - max)) delete map[k]; } function percentile(sortedArr, p) { if (!sortedArr.length) return 0; const idx = Math.min(sortedArr.length - 1, Math.floor(sortedArr.length * p)); return sortedArr[idx]; } /** * Split the lifetime request total into rows that carry an account id and rows * that cannot be attributed to any account. Global/unauthenticated paths call * recordRequest with `accountId == null`, so they increment totalRequests but * have no accountCounts key — the account dimension silently sums short of the * page total. Derived at read time so pre-fix persisted snapshots reconcile * without a migration. */ function accountAttribution() { let attributed = 0; for (const s of Object.values(_state.accountCounts || {})) { const n = Number(s?.requests); if (Number.isFinite(n) && n > 0) attributed += n; } const total = Number(_state.totalRequests) || 0; return { total, attributed, unattributed: Math.max(0, total - attributed) }; } /** Get all stats, with computed latency percentiles per model. */ export function getStats() { const out = { ..._state }; out.accountAttribution = accountAttribution(); // Rebuild every keyed map as a plain object: the public response keeps its // historical Object.prototype shape, while Object.fromEntries creates own // data properties (never the __proto__ setter) for every key. out.modelCounts = Object.fromEntries(Object.entries(_state.modelCounts).map(([m, s]) => { const sorted = (s.recentMs || []).slice().sort((a, b) => a - b); return [m, { requests: s.requests, success: s.success, errors: s.errors, totalMs: s.totalMs, avgMs: s.requests > 0 ? Math.round(s.totalMs / s.requests) : 0, p50Ms: Math.round(percentile(sorted, 0.5)), p95Ms: Math.round(percentile(sorted, 0.95)), }]; })); out.accountCounts = plainMap(_state.accountCounts); out.creditsByHour = plainMap(_state.creditsByHour); out.creditsByDay = plainMap(_state.creditsByDay); out.creditsByModel = plainMap(_state.creditsByModel); out.tokensByModel = plainMap(_state.tokensByModel); return out; } // v2.0.148 — Export the full stats state as a JSON-serializable snapshot (for // backup / migration / offline analysis). Includes credits + recentRequests. export function exportStats() { const out = { ..._state }; for (const key of MAP_KEYS) out[key] = plainMap(_state[key]); return { _exportedAt: new Date().toISOString(), _schema: 'windsurfapi-stats-v2', ...out, }; } function normalizeStatsSnapshot(src) { const object = value => value && typeof value === 'object' && !Array.isArray(value); const number = (value = 0) => { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error('invalid number'); return value; }; const counts = (value, keys) => { if (!object(value)) throw new Error('invalid counters'); // Own-key reads only: a polluted Object.prototype must never supply a // counter that the imported snapshot did not actually contain. return Object.fromEntries(keys.map(key => [key, number(Object.hasOwn(value, key) ? value[key] : 0)])); }; const out = createStatsState(); if (!object(src) || !Object.keys(out).some(key => Object.hasOwn(src, key))) throw new Error('empty snapshot'); if (src._schema != null && !['windsurfapi-stats-v1', 'windsurfapi-stats-v2'].includes(src._schema)) { throw new Error('unsupported schema'); } for (const [key, value] of Object.entries(out)) { if (typeof value === 'number' && Object.hasOwn(src, key)) out[key] = number(src[key]); } for (const key of ['modelCounts', 'accountCounts', 'creditsByHour', 'creditsByDay', 'creditsByModel']) { if (!Object.hasOwn(src, key)) continue; if (!object(src[key])) throw new Error('invalid map'); for (const [name, value] of Object.entries(src[key])) { if (key.startsWith('credits')) { out[key][name] = number(value); continue; } const entry = counts(value, key === 'modelCounts' ? ['requests', 'success', 'errors', 'totalMs', 'lastTs'] : ['requests', 'success', 'errors']); if (key === 'modelCounts') { if (value.recentMs !== undefined && !Array.isArray(value.recentMs)) throw new Error('invalid latency samples'); entry.recentMs = (value.recentMs || []).map(v => number(v)).slice(-200); } out[key][name] = entry; } } // Snapshots written before the id fix carry credential prefixes in both the // account map keys and the request rows; scrub them on import too. out.accountCounts = normalizeAccountCounts(out.accountCounts); if (Object.hasOwn(src, 'tokenTotals')) out.tokenTotals = counts(src.tokenTotals, Object.keys(out.tokenTotals)); if (Object.hasOwn(src, 'tokensByModel')) { if (!object(src.tokensByModel)) throw new Error('invalid token map'); for (const [name, value] of Object.entries(src.tokensByModel)) { out.tokensByModel[name] = counts(value, ['fresh_input', 'cache_read', 'cache_write', 'output', 'total', 'requests_with_usage']); } } for (const key of ['hourlyBuckets', 'recentRequests', 'recentPolicyBlocks']) { if (!Object.hasOwn(src, key)) continue; if (!Array.isArray(src[key])) throw new Error('invalid history'); out[key] = src[key].map(entry => { if (!object(entry)) throw new Error('invalid history entry'); if (key === 'hourlyBuckets') { if (typeof entry.hour !== 'string' || !Number.isFinite(Date.parse(entry.hour))) throw new Error('invalid hour'); return { hour: new Date(entry.hour).toISOString(), ...counts(entry, ['requests', 'errors']) }; } number(entry.ts); if (!Object.hasOwn(entry, 'ts')) throw new Error('missing timestamp'); if (key === 'recentRequests') { if (typeof entry.model !== 'string') throw new Error('invalid model'); if (entry.success !== undefined && typeof entry.success !== 'boolean') throw new Error('invalid success flag'); if (entry.account != null && typeof entry.account !== 'string') throw new Error('invalid account'); for (const field of ['ms', 'credit']) if (Object.hasOwn(entry, field)) number(entry[field]); } else if (typeof entry.promptHash !== 'string') throw new Error('invalid prompt hash'); return entry; }); } out.recentRequests = normalizeRecentRequests(out.recentRequests); return out; } function mergeStatsHistory(existing, incoming, identity, cap) { const entries = new Map(existing.map(entry => [identity(entry), entry])); for (const entry of incoming) { const key = identity(entry); if (!entries.has(key)) entries.set(key, entry); } return [...entries.values()].sort((a, b) => a.ts - b.ts).slice(-cap); } // v2.0.148 — Import a previously exported snapshot. MERGE mode (default) adds // counts onto current; REPLACE overwrites. Numeric fields are summed, keyed // maps merged, recentRequests concatenated + de-duped by ts+model then capped. export function importStats(snap, { mode = 'merge' } = {}) { if (!['merge', 'replace'].includes(mode)) return { ok: false, error: 'invalid mode' }; let next; try { // Clone and validate before touching live state; prototype keys are stripped at every depth. const src = normalizeStatsSnapshot(JSON.parse(JSON.stringify(snap), (key, value) => { if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined; return value; })); next = mode === 'replace' ? src : normalizeStatsSnapshot(JSON.parse(JSON.stringify(_state))); if (mode === 'merge') { for (const [key, value] of Object.entries(src)) { if (typeof value === 'number' && key !== 'startedAt') next[key] += value; } for (const key of ['creditsByHour', 'creditsByDay', 'creditsByModel', 'tokenTotals']) { for (const [name, value] of Object.entries(src[key])) { next[key][name] = (Object.hasOwn(next[key], name) ? next[key][name] : 0) + value; } } for (const [name, incoming] of Object.entries(src.tokensByModel)) { const current = Object.hasOwn(next.tokensByModel, name) ? next.tokensByModel[name] : null; if (!current) { next.tokensByModel[name] = { ...incoming }; continue; } for (const field of Object.keys(current)) current[field] += incoming[field] || 0; } for (const key of ['modelCounts', 'accountCounts']) { for (const [name, incoming] of Object.entries(src[key])) { const current = Object.hasOwn(next[key], name) ? next[key][name] : null; if (!current) { next[key][name] = incoming; continue; } for (const field of ['requests', 'success', 'errors']) current[field] += incoming[field]; if (key === 'modelCounts') { current.totalMs += incoming.totalMs; current.lastTs = Math.max(current.lastTs, incoming.lastTs); current.recentMs = [...current.recentMs, ...incoming.recentMs].slice(-200); } } } } const hours = new Map(); for (const bucket of [...(mode === 'merge' ? src.hourlyBuckets : []), ...next.hourlyBuckets]) { const current = hours.get(bucket.hour); if (current) { current.requests += bucket.requests; current.errors += bucket.errors; } else hours.set(bucket.hour, { ...bucket }); } next.hourlyBuckets = [...hours.values()].sort((a, b) => a.hour.localeCompare(b.hour)).slice(-720); next.recentRequests = mergeStatsHistory(next.recentRequests, mode === 'merge' ? src.recentRequests : [], r => `${r.ts}|${r.model}`, RECENT_REQ_CAP); next.recentPolicyBlocks = mergeStatsHistory(next.recentPolicyBlocks, mode === 'merge' ? src.recentPolicyBlocks : [], r => `${r.ts}|${r.promptHash}`, Number(process.env.POLICY_BLOCK_RING) || 50); pruneKeyed(next.creditsByHour, 720); pruneKeyed(next.creditsByDay, 90); enforceModelCap(false, next.modelCounts); pruneTokensToModelCounts(Object.keys(next.tokensByModel || {}), next.modelCounts, next.tokensByModel); next = normalizeStatsSnapshot(next); // Also reject numeric overflow from merging. } catch { return { ok: false, error: 'invalid snapshot' }; } Object.assign(_state, next); for (const entry of Object.values(_state.modelCounts)) _touchSeq = Math.max(_touchSeq, entry.lastTs); _curBucket = null; scheduleSave(); return { ok: true, mode }; } /** Reset all stats. */ export function resetStats() { _state.totalRequests = 0; _state.successCount = 0; _state.errorCount = 0; _state.modelCounts = emptyMap(); _state.accountCounts = emptyMap(); _state.hourlyBuckets = []; _curBucket = null; // invalidate cached bucket ref (S8) — buckets array replaced _state.tokenTotals = { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0, }; _state.creditsTotal = 0; _state.creditsByHour = emptyMap(); _state.creditsByDay = emptyMap(); _state.creditsByModel = emptyMap(); _state.tokensByModel = emptyMap(); _state.recentRequests = []; _state.recentPolicyBlocks = []; _state.startedAt = Date.now(); scheduleSave(); } /** * v2.0.69 (#118): record per-request token bucket totals so the dashboard * can show real fresh-input vs cache-read vs cache-write breakdown * instead of the conflated prompt_tokens number. * * Accepts the OpenAI-shaped usage object that buildUsageBody returns — * cascade_breakdown is the authoritative source when present, otherwise * fall back to standard fields. Connect usage (O2) has no cascade_breakdown but * carries `fresh_input_tokens` at the top level, because there prompt_tokens * includes cache_write and `prompt - cached` would book the write as fresh. */ export function recordTokenUsage(usage, model = null) { if (!usage || typeof usage !== 'object') return; const bd = usage.cascade_breakdown || null; const fresh = bd?.fresh_input_tokens ?? usage.fresh_input_tokens ?? Math.max(0, (usage.prompt_tokens || 0) - (usage.prompt_tokens_details?.cached_tokens || usage.cache_read_input_tokens || 0)); const cacheR = bd?.cache_read_tokens ?? (usage.prompt_tokens_details?.cached_tokens || usage.cache_read_input_tokens || 0); const cacheW = bd?.cache_write_tokens ?? (usage.cache_creation_input_tokens || 0); const output = bd?.output_tokens ?? (usage.completion_tokens || usage.output_tokens || 0); if (!fresh && !cacheR && !cacheW && !output) return; if (!_state.tokenTotals) { _state.tokenTotals = { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0 }; } _state.tokenTotals.fresh_input += fresh; _state.tokenTotals.cache_read += cacheR; _state.tokenTotals.cache_write += cacheW; _state.tokenTotals.output += output; _state.tokenTotals.total += fresh + cacheR + cacheW + output; _state.tokenTotals.requests_with_usage += 1; // Per-model attribution. Only when the caller KNOWS the model: filing an // unknown request under '(other)' would invent a model row that no client // ever named, and the global totals above already count it. Prototype-shaped // names are unknown by the same rule — rejecting them here also prevents // `tokensByModel['__proto__']` from resolving to Object.prototype. const requested = typeof model === 'string' && model.trim() ? model : null; const key = requested && !isUnsafeModelKey(requested) ? boundedModelKey(requested) : null; if (key) { const tokensByModel = ensureNullProtoMap('tokensByModel'); if (!Object.hasOwn(tokensByModel, key)) { tokensByModel[key] = { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0, }; } const tm = tokensByModel[key]; tm.fresh_input += fresh; tm.cache_read += cacheR; tm.cache_write += cacheW; tm.output += output; tm.total += fresh + cacheR + cacheW + output; tm.requests_with_usage += 1; } // Per-request attribution. The scope is the only safe join: a concurrent // request for the same model must never inherit these counts. When the usage // arrives before the attempt row (connect non-stream), hold it until // recordRequest creates that row. const scope = _requestScope.getStore(); if (scope) { const row = scope.rows[scope.rows.length - 1]; if (row) applyUsageToRow(row, usage); else scope.pendingUsage = usage; } scheduleSave(); } export function recordPolicyBlocked(sample = null) { _state.policyBlockedCount = (_state.policyBlockedCount || 0) + 1; if (sample) { if (!Array.isArray(_state.recentPolicyBlocks)) _state.recentPolicyBlocks = []; _state.recentPolicyBlocks.push(sample); const CAP = Number(process.env.POLICY_BLOCK_RING) || 50; if (_state.recentPolicyBlocks.length > CAP) { _state.recentPolicyBlocks.splice(0, _state.recentPolicyBlocks.length - CAP); } } scheduleSave(); } export function recordRateLimited() { _state.rateLimitedCount = (_state.rateLimitedCount || 0) + 1; scheduleSave(); } // ─── Request log query ───────────────────────────────────────────────── // Read-only view over the persisted per-request ring (recentRequests). Every // number is computed from the rows that pass the filters, so the table and the // aggregates below it always share one denominator. Retention is bounded and // stated in the response (retention.stored/capacity); the panel labels the // window instead of implying full history. const LOG_PAGE_MAX = 200; function logRowView(row) { const ts = Number(row?.ts); const rawStatus = row?.status; const derived = row?.success ? 200 : 500; const status = rawStatus == null || rawStatus === '' ? derived : (Number.isFinite(Number(rawStatus)) ? Number(rawStatus) : derived); return { ts: Number.isFinite(ts) ? ts : 0, model: typeof row?.model === 'string' ? row.model : '', account: normalizeAccountRef(row?.account), status, success: !!row?.success, ms: nonNeg(row?.ms), tokensIn: nonNeg(row?.tokensIn), tokensOut: nonNeg(row?.tokensOut), cacheRead: nonNeg(row?.cacheRead), cacheWrite: nonNeg(row?.cacheWrite), credit: nonNeg(row?.credit), }; } function emptyAggregate(extra) { return { ...extra, requests: 0, success: 0, errors: 0, tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, credits: 0, totalMs: 0, _latencies: [], }; } function addToAggregate(agg, row) { agg.requests += 1; if (row.success) agg.success += 1; else agg.errors += 1; agg.tokensIn += row.tokensIn; agg.tokensOut += row.tokensOut; agg.cacheRead += row.cacheRead; agg.cacheWrite += row.cacheWrite; agg.credits += row.credit; agg.totalMs += row.ms; if (row.ms > 0) agg._latencies.push(row.ms); } function finishAggregate(agg) { const latencies = agg._latencies || []; agg.avgMs = agg.requests > 0 ? Math.round(agg.totalMs / agg.requests) : 0; agg.p50Ms = percentileOf(latencies, 0.5); agg.p95Ms = percentileOf(latencies, 0.95); delete agg.totalMs; delete agg._latencies; return agg; } /** Nearest-rank percentile over an unsorted sample; 0 when empty. */ function percentileOf(values, p) { if (!values.length) return 0; const sorted = values.slice().sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]; } /** * Newest-first page of request-log rows plus aggregates over the whole * filtered window (not just the page). Filters: `from`/`to` epoch ms, exact * `model`/`account`, numeric `status`. */ export function getRequestLog({ from = null, to = null, model = null, account = null, status = null, limit = 50, offset = 0, } = {}) { const rows = Array.isArray(_state.recentRequests) ? _state.recentRequests : []; const fromNum = from == null || from === '' ? null : Number(from); const toNum = to == null || to === '' ? null : Number(to); const fromMs = Number.isFinite(fromNum) ? fromNum : null; const toMs = Number.isFinite(toNum) ? toNum : null; const statusNum = status == null || status === '' ? null : Number(status); const modelFilter = typeof model === 'string' && model ? model : null; const accountFilter = typeof account === 'string' && account ? account : null; const filtered = []; for (const raw of rows) { if (!raw || typeof raw !== 'object') continue; const row = logRowView(raw); if (fromMs != null && row.ts < fromMs) continue; if (toMs != null && row.ts > toMs) continue; if (modelFilter && row.model !== modelFilter) continue; if (accountFilter && row.account !== accountFilter) continue; if (statusNum != null && (!Number.isFinite(statusNum) || row.status !== statusNum)) continue; filtered.push(row); } const n = Math.max(1, Math.min(LOG_PAGE_MAX, Number(limit) || 50)); const off = Math.max(0, Math.floor(Number(offset) || 0)); const newestFirst = filtered.slice().reverse(); const items = newestFirst.slice(off, off + n); const summary = emptyAggregate({}); const byModel = new Map(); const byAccount = new Map(); const byDay = new Map(); const unattributed = emptyAggregate({ account: null }); for (const row of filtered) { addToAggregate(summary, row); if (row.model) { const agg = byModel.get(row.model) || emptyAggregate({ model: row.model }); addToAggregate(agg, row); byModel.set(row.model, agg); } if (row.account) { const agg = byAccount.get(row.account) || emptyAggregate({ account: row.account }); addToAggregate(agg, row); byAccount.set(row.account, agg); } else { addToAggregate(unattributed, row); } const day = new Date(row.ts).toISOString().slice(0, 10); const agg = byDay.get(day) || emptyAggregate({ day }); addToAggregate(agg, row); byDay.set(day, agg); } finishAggregate(summary); finishAggregate(unattributed); const models = [...byModel.values()] .map(finishAggregate) .sort((a, b) => b.requests - a.requests || a.model.localeCompare(b.model)); const accounts = [...byAccount.values()] .map(finishAggregate) .sort((a, b) => b.requests - a.requests || String(a.account).localeCompare(String(b.account))); const days = [...byDay.values()] .map(finishAggregate) .sort((a, b) => a.day.localeCompare(b.day)); const all = rows.map(logRowView); return { items, total: filtered.length, limit: n, offset: off, hasMore: off + items.length < filtered.length, retention: { stored: all.length, capacity: RECENT_REQ_CAP }, filters: { models: [...new Set(all.map(r => r.model).filter(Boolean))].sort(), accounts: [...new Set(all.map(r => r.account).filter(Boolean))].sort(), statuses: [...new Set(all.map(r => r.status).filter(Number.isFinite))].sort((a, b) => a - b), }, summary, byModel: models, byAccount: accounts, // Rows with no account id: byAccount + unattributed = summary, so the // account tab can state the remainder instead of silently summing short. unattributed, byDay: days, }; }