diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index aed651921b..37c9219ae1 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1187,6 +1187,11 @@ "count": 1 } }, + "tests/unit/db-migration-runner-account-identity.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, "tests/unit/db-migration-runner.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 12 diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index f30e48e3c4..648b3fdbac 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -673,7 +673,7 @@ export async function GET(request: Request) { const accountCostByAccount = new Map(); for (const row of accountCostRows) { - const account = toStringValue(row.account, "unknown"); + const accountKey = toStringValue(row.accountKey, "unknown"); const cost = computeUsageRowCost( row, pricingByProvider, @@ -681,7 +681,7 @@ export async function GET(request: Request) { normalizeModelName, computeCostFromPricing ); - accountCostByAccount.set(account, (accountCostByAccount.get(account) || 0) + cost); + accountCostByAccount.set(accountKey, (accountCostByAccount.get(accountKey) || 0) + cost); } const byAccount = accountRows.map((row) => ({ @@ -692,7 +692,7 @@ export async function GET(request: Request) { totalTokens: Number(row.totalTokens), avgLatencyMs: Math.round(Number(row.avgLatencyMs)), lastUsed: row.lastUsed, - cost: roundCost(accountCostByAccount.get(toStringValue(row.account, "unknown")) || 0), + cost: roundCost(accountCostByAccount.get(toStringValue(row.accountKey, "unknown")) || 0), })); const apiKeyMap = new Map< diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index f798e8d455..39e5397752 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -41,6 +41,7 @@ import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping"; import { ensureProviderConnectionsColumns, + ensureUsageHistoryAccountIndex, ensureUsageHistoryColumns, ensureCallLogsColumns, hasTable, @@ -302,6 +303,9 @@ const SCHEMA_SQL = ` provider TEXT, model TEXT, connection_id TEXT, + account_key TEXT, + account_label TEXT, + account_label_priority INTEGER DEFAULT 0, api_key_id TEXT, api_key_name TEXT, tokens_input INTEGER DEFAULT 0, @@ -958,6 +962,7 @@ export function getDbInstance(): SqliteDatabase { memoryDb.pragma("journal_mode = WAL"); memoryDb.exec(SCHEMA_SQL); ensureUsageHistoryColumns(memoryDb); + ensureUsageHistoryAccountIndex(memoryDb); ensureCallLogsColumns(memoryDb); ensureProviderConnectionsColumns(memoryDb); setDb(memoryDb); diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index c5755b2991..92d14c6c25 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -13,6 +13,11 @@ import type { SqliteAdapter } from "./adapters/types"; import { normalizeRoutingStrategy } from "@/shared/constants/routingStrategies"; +import { + resolveImportedUsageAccountIdentity, + resolveOrphanedUsageAccountIdentity, + resolveUsageAccountIdentity, +} from "@/lib/usage/accountIdentity"; type SqliteDatabase = SqliteAdapter; @@ -223,23 +228,36 @@ export function runJsonMigration( } // 7. Usage History if (data.usageHistory && data.usageHistory.length > 0) { + const importedConnections = new Map( + (data.providerConnections ?? []).map((connection) => [connection.id, connection]) + ); const insertUsageHistory = db.prepare(` INSERT OR REPLACE INTO usage_history ( - id, provider, model, connection_id, api_key_id, api_key_name, - tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, - tokens_reasoning, status, success, latency_ms, ttft_ms, error_code, combo_strategy, timestamp + id, 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, status, success, latency_ms, ttft_ms, + error_code, combo_strategy, timestamp ) VALUES ( - @id, @provider, @model, @connection_id, @api_key_id, @api_key_name, - @tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation, - @tokens_reasoning, @status, @success, @latency_ms, @ttft_ms, @error_code, @combo_strategy, @timestamp + @id, @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, @status, @success, + @latency_ms, @ttft_ms, @error_code, @combo_strategy, @timestamp ) `); for (const row of data.usageHistory) { + const connection = importedConnections.get(row.connection_id); + const fallbackIdentity = connection + ? resolveUsageAccountIdentity(connection) + : resolveOrphanedUsageAccountIdentity(row.provider, row.connection_id); + const identity = resolveImportedUsageAccountIdentity(row, fallbackIdentity); insertUsageHistory.run({ id: row.id, provider: row.provider ?? null, model: row.model ?? null, connection_id: row.connection_id ?? null, + account_key: identity.accountKey, + account_label: identity.accountLabel, + account_label_priority: identity.accountLabelPriority, api_key_id: row.api_key_id ?? null, api_key_name: row.api_key_name ?? null, tokens_input: row.tokens_input ?? 0, diff --git a/src/lib/db/migrations/127_usage_history_account_identity.sql b/src/lib/db/migrations/127_usage_history_account_identity.sql new file mode 100644 index 0000000000..c456c9260d --- /dev/null +++ b/src/lib/db/migrations/127_usage_history_account_identity.sql @@ -0,0 +1,153 @@ +-- Migration 127: Snapshot stable account identity on usage events. +-- Startup schema ensure adds the columns first so an interrupted pre-marker +-- upgrade can rerun this atomic backfill and index creation. + +UPDATE usage_history +SET + account_key = COALESCE( + ( + SELECT CASE + WHEN c.auth_type = 'oauth' + AND c.provider = 'codex' + AND json_valid(COALESCE(c.provider_specific_data, '')) + AND json_type(c.provider_specific_data, '$.workspaceId') = 'text' + AND json_extract(c.provider_specific_data, '$.workspaceId') <> '' + AND c.email <> '' + THEN json_array( + 'oauth', + c.provider, + 'workspace', + json_extract(c.provider_specific_data, '$.workspaceId'), + 'email', + c.email + ) + WHEN c.auth_type = 'oauth' + AND c.provider = 'codex' + AND json_valid(COALESCE(c.provider_specific_data, '')) + AND json_type(c.provider_specific_data, '$.chatgptUserId') = 'text' + AND json_extract(c.provider_specific_data, '$.chatgptUserId') <> '' + AND c.email <> '' + THEN json_array( + 'oauth', + c.provider, + 'user', + json_extract(c.provider_specific_data, '$.chatgptUserId'), + 'email', + c.email + ) + WHEN c.auth_type = 'oauth' + AND c.provider <> 'codex' + AND c.email <> '' + AND json_valid(COALESCE(c.provider_specific_data, '')) + AND json_type(c.provider_specific_data, '$.username') = 'text' + AND json_extract(c.provider_specific_data, '$.username') <> '' + THEN json_array( + 'oauth', + CASE WHEN typeof(c.provider) = 'text' AND c.provider <> '' THEN c.provider ELSE 'unknown' END, + 'email', + c.email, + 'username', + json_extract(c.provider_specific_data, '$.username') + ) + WHEN c.auth_type = 'oauth' + AND c.provider <> 'codex' + AND c.email <> '' + THEN json_array( + 'oauth', + CASE WHEN typeof(c.provider) = 'text' AND c.provider <> '' THEN c.provider ELSE 'unknown' END, + 'email', + c.email + ) + ELSE json_array( + 'connection', + CASE WHEN typeof(c.provider) = 'text' AND c.provider <> '' THEN c.provider ELSE 'unknown' END, + CASE + WHEN typeof(c.id) = 'text' AND c.id <> '' THEN c.id + WHEN typeof(usage_history.connection_id) = 'text' AND usage_history.connection_id <> '' + THEN usage_history.connection_id + ELSE 'unknown' + END + ) + END + FROM provider_connections c + WHERE c.id = usage_history.connection_id + ), + json_array( + 'connection', + CASE + WHEN typeof(usage_history.provider) = 'text' AND usage_history.provider <> '' + THEN usage_history.provider + ELSE 'unknown' + END, + CASE + WHEN typeof(usage_history.connection_id) = 'text' AND usage_history.connection_id <> '' + THEN usage_history.connection_id + ELSE 'unknown' + END + ) + ), + account_label = COALESCE( + ( + SELECT COALESCE( + NULLIF(TRIM(c.display_name), ''), + NULLIF(TRIM(c.email), ''), + NULLIF(TRIM(c.name), ''), + NULLIF(TRIM(c.id), '') + ) + FROM provider_connections c + WHERE c.id = usage_history.connection_id + ), + NULLIF(TRIM(usage_history.connection_id), ''), + 'unknown' + ), + account_label_priority = COALESCE( + ( + SELECT CASE + WHEN NULLIF(TRIM(c.display_name), '') IS NOT NULL THEN 4 + WHEN NULLIF(TRIM(c.email), '') IS NOT NULL THEN 3 + WHEN NULLIF(TRIM(c.name), '') IS NOT NULL THEN 2 + WHEN NULLIF(TRIM(c.id), '') IS NOT NULL THEN 1 + ELSE 0 + END + FROM provider_connections c + WHERE c.id = usage_history.connection_id + ), + CASE WHEN NULLIF(TRIM(usage_history.connection_id), '') IS NOT NULL THEN 1 ELSE 0 END + ) +WHERE account_key IS NULL OR account_key = ''; + +UPDATE usage_history +SET + account_label = COALESCE( + ( + SELECT COALESCE( + NULLIF(TRIM(c.display_name), ''), + NULLIF(TRIM(c.email), ''), + NULLIF(TRIM(c.name), ''), + NULLIF(TRIM(c.id), '') + ) + FROM provider_connections c + WHERE c.id = usage_history.connection_id + ), + NULLIF(TRIM(usage_history.connection_id), ''), + 'unknown' + ), + account_label_priority = COALESCE( + ( + SELECT CASE + WHEN NULLIF(TRIM(c.display_name), '') IS NOT NULL THEN 4 + WHEN NULLIF(TRIM(c.email), '') IS NOT NULL THEN 3 + WHEN NULLIF(TRIM(c.name), '') IS NOT NULL THEN 2 + WHEN NULLIF(TRIM(c.id), '') IS NOT NULL THEN 1 + ELSE 0 + END + FROM provider_connections c + WHERE c.id = usage_history.connection_id + ), + CASE WHEN NULLIF(TRIM(usage_history.connection_id), '') IS NOT NULL THEN 1 ELSE 0 END + ) +WHERE account_key IS NOT NULL + AND account_key <> '' + AND (account_label IS NULL OR account_label = ''); + +CREATE INDEX IF NOT EXISTS idx_uh_account_key ON usage_history(account_key); diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 708b1c304b..2c8876da3d 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -16,6 +16,7 @@ import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; import { bumpProxyConfigGeneration } from "./settings"; import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup"; +import { resolveUsageAccountIdentity } from "@/lib/usage/accountIdentity"; import { withNullableMaxConcurrent, withNullableQuotaWindowThresholds, @@ -39,6 +40,7 @@ interface StatementLike { interface DbLike { prepare: (sql: string) => StatementLike; + transaction: (fn: () => T) => () => T; } // Real column set for provider_connections (must match the CREATE TABLE in @@ -244,6 +246,7 @@ export async function createProviderConnection(data: JsonRecord) { // For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal) // We need to check for workspace uniqueness, not just email let existing: JsonRecord | null = null; + let legacyCodexWorkspaceMatch = false; if (data.authType === "oauth" && data.email) { // For Codex, check for existing connection with same workspace @@ -269,6 +272,7 @@ export async function createProviderConnection(data: JsonRecord) { "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')" ) .get(data.provider, workspaceId) as JsonRecord | undefined) || null; + legacyCodexWorkspaceMatch = existing !== null; } // For Codex with workspaceId, don't fall back to email-only check // This allows creating new connections for different workspaces @@ -371,7 +375,34 @@ export async function createProviderConnection(data: JsonRecord) { toStringOrNull(merged.provider), merged.providerSpecificData ); - _updateConnectionRow(db, existingId, merged); + db.transaction(() => { + if (legacyCodexWorkspaceMatch) { + const oldIdentity = resolveUsageAccountIdentity(existing); + const newIdentity = resolveUsageAccountIdentity(merged); + db.prepare( + `UPDATE usage_history + SET account_key = @newAccountKey, + account_label = CASE + WHEN @newLabelPriority > COALESCE(account_label_priority, 0) + THEN @newLabel + ELSE account_label + END, + account_label_priority = MAX( + COALESCE(account_label_priority, 0), + @newLabelPriority + ) + WHERE connection_id = @connectionId + AND account_key = @oldAccountKey` + ).run({ + connectionId: existingId, + oldAccountKey: oldIdentity.accountKey, + newAccountKey: newIdentity.accountKey, + newLabel: newIdentity.accountLabel, + newLabelPriority: newIdentity.accountLabelPriority, + }); + } + _updateConnectionRow(db, existingId, merged); + })(); backupDbFile("pre-write"); return withNullableRateLimitOverrides( withNullableQuotaWindowThresholds( diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index 3381b7e4b3..04898d5d4f 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -18,6 +18,18 @@ export function ensureProviderConnectionsColumns(db: SqliteDatabase) { name?: string; }>; const columnNames = new Set(columns.map((column) => String(column.name ?? ""))); + for (const [column, type] of [ + ["auth_type", "TEXT"], + ["name", "TEXT"], + ["email", "TEXT"], + ["display_name", "TEXT"], + ["provider_specific_data", "TEXT"], + ]) { + if (!columnNames.has(column)) { + db.exec(`ALTER TABLE provider_connections ADD COLUMN ${column} ${type}`); + console.log(`[DB] Added provider_connections.${column} column`); + } + } if (!columnNames.has("rate_limit_protection")) { db.exec( "ALTER TABLE provider_connections ADD COLUMN rate_limit_protection INTEGER DEFAULT 0" @@ -81,6 +93,15 @@ export function ensureProviderConnectionsColumns(db: SqliteDatabase) { } } +export function ensureUsageHistoryAccountIndex(db: SqliteDatabase) { + try { + db.exec("CREATE INDEX IF NOT EXISTS idx_uh_account_key ON usage_history(account_key)"); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[DB] Failed to verify usage_history account index:", message); + } +} + export function ensureUsageHistoryColumns(db: SqliteDatabase) { try { const columns = db.prepare("PRAGMA table_info(usage_history)").all() as Array<{ @@ -114,6 +135,18 @@ export function ensureUsageHistoryColumns(db: SqliteDatabase) { console.log("[DB] Added usage_history.combo_strategy column"); } db.exec("CREATE INDEX IF NOT EXISTS idx_uh_combo_strategy ON usage_history(combo_strategy)"); + if (!columnNames.has("account_key")) { + db.exec("ALTER TABLE usage_history ADD COLUMN account_key TEXT"); + console.log("[DB] Added usage_history.account_key column"); + } + if (!columnNames.has("account_label")) { + db.exec("ALTER TABLE usage_history ADD COLUMN account_label TEXT"); + console.log("[DB] Added usage_history.account_label column"); + } + if (!columnNames.has("account_label_priority")) { + db.exec("ALTER TABLE usage_history ADD COLUMN account_label_priority INTEGER DEFAULT 0"); + console.log("[DB] Added usage_history.account_label_priority column"); + } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify usage_history schema:", message); diff --git a/src/lib/db/usageAnalytics.ts b/src/lib/db/usageAnalytics.ts index e37fc5875c..5bd7847215 100644 --- a/src/lib/db/usageAnalytics.ts +++ b/src/lib/db/usageAnalytics.ts @@ -54,7 +54,7 @@ export function getUsageSummary(unifiedSource: string, params: AnalyticsParams): COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COUNT(DISTINCT model) as uniqueModels, - COUNT(DISTINCT connection_id) as uniqueAccounts, + COUNT(DISTINCT COALESCE(NULLIF(account_key, ''), NULLIF(connection_id, ''))) as uniqueAccounts, COUNT(DISTINCT COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''))) as uniqueApiKeys, COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, COALESCE(AVG(latency_ms), 0) as avgLatencyMs, @@ -315,7 +315,7 @@ export function getProviderUsageRows( // --------------------------------------------------------------------------- export interface AccountCostRow { - account: string; + accountKey: string; provider: string; model: string; serviceTier: string; @@ -327,8 +327,7 @@ export interface AccountCostRow { } /** - * Per-account cost breakdown joined with provider_connections for display names. - * Uses `usage_history` directly (JOIN requires real table, not a subquery alias). + * Per-account cost breakdown grouped by the identity snapshot stored on each usage event. * * @param whereClause - SQL WHERE clause (may be empty string); column refs already * prefixed with `usage_history.` by the caller. @@ -339,20 +338,35 @@ export function getAccountCostRows(whereClause: string, params: AnalyticsParams) return db .prepare( ` + WITH account_events AS ( + SELECT + COALESCE( + NULLIF(usage_history.account_key, ''), + 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(usage_history.connection_id, 'unknown') + ) as resolved_account_key, + usage_history.provider, + usage_history.model, + usage_history.service_tier, + usage_history.tokens_input, + usage_history.tokens_output, + usage_history.tokens_cache_read, + usage_history.tokens_cache_creation, + usage_history.tokens_reasoning + FROM usage_history + ${whereClause} + ) SELECT - COALESCE(NULLIF(c.display_name, ''), NULLIF(c.email, ''), NULLIF(c.name, ''), usage_history.connection_id, 'unknown') as account, - LOWER(usage_history.provider) as provider, - LOWER(usage_history.model) as model, - COALESCE(NULLIF(usage_history.service_tier, ''), 'standard') as serviceTier, - COALESCE(SUM(usage_history.tokens_input), 0) as promptTokens, - COALESCE(SUM(usage_history.tokens_output), 0) as completionTokens, - COALESCE(SUM(usage_history.tokens_cache_read), 0) as cacheReadTokens, - COALESCE(SUM(usage_history.tokens_cache_creation), 0) as cacheCreationTokens, - COALESCE(SUM(usage_history.tokens_reasoning), 0) as reasoningTokens - FROM usage_history - LEFT JOIN provider_connections c ON c.id = usage_history.connection_id - ${whereClause} - GROUP BY account, LOWER(usage_history.provider), LOWER(usage_history.model), serviceTier + account_events.resolved_account_key as accountKey, + LOWER(account_events.provider) as provider, + LOWER(account_events.model) as model, + COALESCE(NULLIF(account_events.service_tier, ''), 'standard') as serviceTier, + COALESCE(SUM(account_events.tokens_input), 0) as promptTokens, + COALESCE(SUM(account_events.tokens_output), 0) as completionTokens, + COALESCE(SUM(account_events.tokens_cache_read), 0) as cacheReadTokens, + COALESCE(SUM(account_events.tokens_cache_creation), 0) as cacheCreationTokens, + COALESCE(SUM(account_events.tokens_reasoning), 0) as reasoningTokens + FROM account_events + GROUP BY accountKey, LOWER(account_events.provider), LOWER(account_events.model), serviceTier ` ) .all(params) as AccountCostRow[]; @@ -361,6 +375,7 @@ export function getAccountCostRows(whereClause: string, params: AnalyticsParams) // --------------------------------------------------------------------------- export interface AccountUsageRow { + accountKey: string; account: string; requests: number; promptTokens: number; @@ -371,7 +386,7 @@ export interface AccountUsageRow { } /** - * Per-account usage aggregates joined with provider_connections for display names. + * Per-account usage aggregates grouped by the identity snapshot stored on each usage event. * * @param whereClause - SQL WHERE clause (may be empty string); column refs already * prefixed with `usage_history.` by the caller. @@ -385,18 +400,69 @@ export function getAccountUsageRows( return db .prepare( ` + WITH account_events AS ( + SELECT + usage_history.*, + COALESCE(NULLIF(usage_history.account_key, ''), 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(usage_history.connection_id, 'unknown')) as resolved_account_key + FROM usage_history + ${whereClause} + ), + stable_account_keys AS ( + SELECT DISTINCT account_key + FROM account_events + WHERE account_key > '' + ), + stable_labels AS ( + SELECT + stable_account_keys.account_key, + ( + SELECT usage_history.account_label + FROM usage_history + WHERE usage_history.account_key = stable_account_keys.account_key + AND NULLIF(usage_history.account_label, '') IS NOT NULL + ORDER BY COALESCE(usage_history.account_label_priority, 0) DESC, + usage_history.timestamp DESC, + usage_history.id DESC + LIMIT 1 + ) as account_label + FROM stable_account_keys + ), + legacy_labels AS ( + SELECT account_key, account_label + FROM ( + SELECT + account_events.resolved_account_key as account_key, + account_events.account_label, + ROW_NUMBER() OVER ( + PARTITION BY account_events.resolved_account_key + ORDER BY COALESCE(account_events.account_label_priority, 0) DESC, + account_events.timestamp DESC, + account_events.id DESC + ) as label_rank + FROM account_events + WHERE (account_events.account_key IS NULL OR account_events.account_key = '') + AND NULLIF(account_events.account_label, '') IS NOT NULL + ) + WHERE label_rank = 1 + ), + selected_labels AS ( + SELECT account_key, account_label FROM stable_labels + UNION ALL + SELECT account_key, account_label FROM legacy_labels + ) SELECT - COALESCE(NULLIF(c.display_name, ''), NULLIF(c.email, ''), NULLIF(c.name, ''), usage_history.connection_id, 'unknown') as account, - COUNT(usage_history.id) as requests, - COALESCE(SUM(usage_history.tokens_input), 0) as promptTokens, - COALESCE(SUM(usage_history.tokens_output), 0) as completionTokens, - COALESCE(SUM(usage_history.tokens_input + usage_history.tokens_output), 0) as totalTokens, - COALESCE(AVG(usage_history.latency_ms), 0) as avgLatencyMs, - COALESCE(MAX(usage_history.timestamp), '') as lastUsed - FROM usage_history - LEFT JOIN provider_connections c ON c.id = usage_history.connection_id - ${whereClause} - GROUP BY account + account_events.resolved_account_key as accountKey, + COALESCE(selected_labels.account_label, account_events.connection_id, 'unknown') as account, + COUNT(account_events.id) as requests, + COALESCE(SUM(account_events.tokens_input), 0) as promptTokens, + COALESCE(SUM(account_events.tokens_output), 0) as completionTokens, + COALESCE(SUM(account_events.tokens_input + account_events.tokens_output), 0) as totalTokens, + COALESCE(AVG(account_events.latency_ms), 0) as avgLatencyMs, + COALESCE(MAX(account_events.timestamp), '') as lastUsed + FROM account_events + LEFT JOIN selected_labels + ON selected_labels.account_key = account_events.resolved_account_key + GROUP BY accountKey ORDER BY requests DESC LIMIT 50 ` diff --git a/src/lib/db/usageAnalytics/sources.ts b/src/lib/db/usageAnalytics/sources.ts index ed3a3c240f..d3a16da688 100644 --- a/src/lib/db/usageAnalytics/sources.ts +++ b/src/lib/db/usageAnalytics/sources.ts @@ -104,6 +104,7 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour success, latency_ms, connection_id, + account_key, api_key_id, api_key_name FROM usage_history @@ -122,6 +123,7 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour 1 as success, 0 as latency_ms, NULL as connection_id, + NULL as account_key, NULL as api_key_id, NULL as api_key_name FROM daily_usage_summary @@ -132,7 +134,7 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, service_tier, success, latency_ms, - connection_id, api_key_id, api_key_name + connection_id, account_key, api_key_id, api_key_name FROM usage_history ${rawWhere} )`; diff --git a/src/lib/usage/accountIdentity.ts b/src/lib/usage/accountIdentity.ts new file mode 100644 index 0000000000..79f8d8d900 --- /dev/null +++ b/src/lib/usage/accountIdentity.ts @@ -0,0 +1,112 @@ +type UsageAccountConnection = { + id?: unknown; + provider?: unknown; + authType?: unknown; + auth_type?: unknown; + displayName?: unknown; + display_name?: unknown; + email?: unknown; + name?: unknown; + providerSpecificData?: unknown; + provider_specific_data?: unknown; +}; + +export type UsageAccountIdentity = { + accountKey: string; + accountLabel: string; + accountLabelPriority: number; +}; + +function identityString(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function displayString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function sanePriority(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 4 + ? value + : null; +} + +function toProviderSpecificData(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string" || !value) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** + * Resolve the stable, non-secret account identity stored with a usage event. + * The identity deliberately mirrors provider-connection OAuth dedup boundaries. + */ +export function resolveUsageAccountIdentity( + connection: UsageAccountConnection | null | undefined +): UsageAccountIdentity { + const provider = identityString(connection?.provider) || "unknown"; + const connectionId = identityString(connection?.id) || "unknown"; + const authType = identityString(connection?.authType ?? connection?.auth_type); + const email = identityString(connection?.email); + const providerSpecificData = toProviderSpecificData( + connection?.providerSpecificData ?? connection?.provider_specific_data + ); + const workspaceId = identityString(providerSpecificData.workspaceId); + const chatgptUserId = identityString(providerSpecificData.chatgptUserId); + const username = identityString(providerSpecificData.username); + + let identityParts: string[]; + if (authType === "oauth" && provider === "codex" && workspaceId && email) { + identityParts = ["oauth", provider, "workspace", workspaceId, "email", email]; + } else if (authType === "oauth" && provider === "codex" && chatgptUserId && email) { + identityParts = ["oauth", provider, "user", chatgptUserId, "email", email]; + } else if (authType === "oauth" && provider !== "codex" && email) { + identityParts = username + ? ["oauth", provider, "email", email, "username", username] + : ["oauth", provider, "email", email]; + } else { + identityParts = ["connection", provider, connectionId]; + } + + const displayName = displayString(connection?.displayName ?? connection?.display_name); + const displayEmail = displayString(connection?.email); + const name = displayString(connection?.name); + const id = displayString(connection?.id); + + return { + accountKey: JSON.stringify(identityParts), + accountLabel: displayName || displayEmail || name || id || "unknown", + accountLabelPriority: displayName ? 4 : displayEmail ? 3 : name ? 2 : id ? 1 : 0, + }; +} + +export function resolveOrphanedUsageAccountIdentity( + provider: unknown, + connectionId: unknown +): UsageAccountIdentity { + return resolveUsageAccountIdentity({ provider, id: connectionId }); +} + +export function resolveImportedUsageAccountIdentity( + row: Record, + fallback: UsageAccountIdentity +): UsageAccountIdentity { + return { + accountKey: identityString(row.account_key ?? row.accountKey) || fallback.accountKey, + accountLabel: displayString(row.account_label ?? row.accountLabel) || fallback.accountLabel, + accountLabelPriority: + sanePriority(row.account_label_priority ?? row.accountLabelPriority) ?? + fallback.accountLabelPriority, + }; +} diff --git a/src/lib/usage/migrations.ts b/src/lib/usage/migrations.ts index df41bb2be5..9fd9e1b053 100644 --- a/src/lib/usage/migrations.ts +++ b/src/lib/usage/migrations.ts @@ -17,6 +17,11 @@ import { getAppLogFilePath } from "../logEnv"; import { protectPayloadForLog } from "../logPayloads"; import { sanitizePII } from "../piiSanitizer"; import { writeCallArtifact, type CallLogArtifact } from "./callLogArtifacts"; +import { + resolveImportedUsageAccountIdentity, + resolveOrphanedUsageAccountIdentity, + resolveUsageAccountIdentity, +} from "./accountIdentity"; export const shouldPersistToDisk = !isCloud && !isBuildPhase; @@ -308,20 +313,32 @@ export function migrateUsageJsonToSqlite() { console.log(`[usageDb] Migrating ${history.length} usage entries from JSON → SQLite...`); const insert = db.prepare(` - INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, - tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, - status, success, latency_ms, ttft_ms, error_code, combo_strategy, timestamp) - VALUES (@provider, @model, @connectionId, @apiKeyId, @apiKeyName, - @tokensInput, @tokensOutput, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, - @status, @success, @latencyMs, @ttftMs, @errorCode, @comboStrategy, @timestamp) + 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, status, success, latency_ms, + ttft_ms, error_code, combo_strategy, timestamp) + VALUES (@provider, @model, @connectionId, @accountKey, @accountLabel, + @accountLabelPriority, @apiKeyId, @apiKeyName, @tokensInput, @tokensOutput, + @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, @status, @success, + @latencyMs, @ttftMs, @errorCode, @comboStrategy, @timestamp) `); + const findConnection = db.prepare("SELECT * FROM provider_connections WHERE id = ?"); const tx = db.transaction(() => { for (const entry of history) { + const connectionId = entry.connectionId || entry.connection_id || null; + const connection = connectionId ? findConnection.get(connectionId) : null; + const fallbackIdentity = connection + ? resolveUsageAccountIdentity(connection) + : resolveOrphanedUsageAccountIdentity(entry.provider, connectionId); + const identity = resolveImportedUsageAccountIdentity(entry, fallbackIdentity); insert.run({ provider: entry.provider || null, model: entry.model || null, - connectionId: entry.connectionId || null, + connectionId, + accountKey: identity.accountKey, + accountLabel: identity.accountLabel, + accountLabelPriority: identity.accountLabelPriority, apiKeyId: entry.apiKeyId || null, apiKeyName: entry.apiKeyName || null, tokensInput: diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 1d5d2ebe46..6e2a9569a3 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -9,6 +9,10 @@ import { getDbInstance } from "../db/core"; import { protectPayloadForLog } from "../logPayloads"; +import { + resolveOrphanedUsageAccountIdentity, + resolveUsageAccountIdentity, +} from "./accountIdentity"; import { accumulateLatencySample, asRecord, @@ -617,6 +621,13 @@ export async function saveRequestUsage(entry: UsageEntry) { 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 | 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 @@ -663,15 +674,19 @@ export async function saveRequestUsage(entry: UsageEntry) { db.prepare( ` - INSERT INTO usage_history (provider, model, connection_id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, diff --git a/tests/unit/db-migration-runner-account-identity.test.ts b/tests/unit/db-migration-runner-account-identity.test.ts new file mode 100644 index 0000000000..7540dc1fc8 --- /dev/null +++ b/tests/unit/db-migration-runner-account-identity.test.ts @@ -0,0 +1,214 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import Database from "better-sqlite3"; + +async function importFresh(modulePath) { + const url = pathToFileURL(path.resolve(modulePath)).href; + return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +function withMockedMigrationFs(files, fn) { + const originalExistsSync = fs.existsSync; + const originalReaddirSync = fs.readdirSync; + const originalReadFileSync = fs.readFileSync; + + const isMigrationDir = (target) => + String(target).replaceAll("\\", "/").endsWith("/src/lib/db/migrations") || + String(target).replaceAll("\\", "/").endsWith("/migrations"); + + fs.existsSync = (target) => { + if (files === null && isMigrationDir(target)) return false; + if (files && isMigrationDir(target)) return true; + + const fileName = path.basename(String(target)); + if (files && Object.hasOwn(files, fileName)) return true; + + return originalExistsSync(target); + }; + + fs.readdirSync = ((target: string, options?: any) => { + if (files && isMigrationDir(target)) { + return Object.keys(files); + } + + return originalReaddirSync(target, options); + }) as any; + + fs.readFileSync = (target, options) => { + const fileName = path.basename(String(target)); + if (files && Object.hasOwn(files, fileName)) { + return files[fileName]; + } + + return originalReadFileSync(target, options); + }; + + try { + return fn(); + } finally { + fs.existsSync = originalExistsSync; + fs.readdirSync = originalReaddirSync; + fs.readFileSync = originalReadFileSync; + } +} + +function createDb() { + return new Database(":memory:"); +} + +test("migration 123 backfills account snapshots and creates its index", async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + const migrationSql = fs.readFileSync( + "src/lib/db/migrations/127_usage_history_account_identity.sql", + "utf8" + ); + + try { + db.exec(` + CREATE TABLE provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT, + auth_type TEXT, + name TEXT, + email TEXT, + display_name TEXT, + provider_specific_data TEXT + ); + CREATE TABLE usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + connection_id TEXT, + account_key TEXT, + account_label TEXT, + account_label_priority INTEGER DEFAULT 0 + ); + INSERT INTO provider_connections + (id, provider, auth_type, email, provider_specific_data) + VALUES + ('empty-provider', '', 'oauth', 'member@example.com', '{"username":"member"}'); + INSERT INTO usage_history (provider, connection_id) + VALUES ('codex', 'orphan-id'), ('', 'empty-provider'); + `); + + const count = withMockedMigrationFs( + { "127_usage_history_account_identity.sql": migrationSql }, + () => runner.runMigrations(db) + ); + const rows = db + .prepare("SELECT account_key, account_label FROM usage_history ORDER BY id") + .all(); + const index = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_uh_account_key'" + ) + .get(); + + assert.equal(count, 1); + assert.deepEqual(rows, [ + { + account_key: '["connection","codex","orphan-id"]', + account_label: "orphan-id", + }, + { + account_key: '["oauth","unknown","email","member@example.com","username","member"]', + account_label: "member@example.com", + }, + ]); + assert.deepEqual(index, { name: "idx_uh_account_key" }); + } finally { + db.close(); + } +}); + +test("migration 123 preserves exact dedup identity and ignores non-string JSON scalars", async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + const sql = fs.readFileSync( + "src/lib/db/migrations/127_usage_history_account_identity.sql", + "utf8" + ); + + try { + db.exec(` + CREATE TABLE provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT, + auth_type TEXT, + name TEXT, + email TEXT, + display_name TEXT, + provider_specific_data TEXT + ); + CREATE TABLE usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + connection_id TEXT, + account_key TEXT, + account_label TEXT, + account_label_priority INTEGER DEFAULT 0 + ); + INSERT INTO provider_connections + (id, provider, auth_type, email, provider_specific_data) + VALUES + ('generic', 'openai', 'oauth', ' Member@example.com ', '{"username":" Member "}'), + ('workspace', 'codex', 'oauth', ' Member@example.com ', '{"workspaceId":" Workspace "}'), + ('numeric-workspace', 'codex', 'oauth', 'member@example.com', '{"workspaceId":42}'), + ('object-user', 'codex', 'oauth', 'member@example.com', '{"chatgptUserId":{"id":"user-a"}}'), + ('array-username', 'openai', 'oauth', 'member@example.com', '{"username":["member"]}'), + ('malformed-json', 'openai', 'oauth', 'member@example.com', '{bad json'); + INSERT INTO usage_history (provider, connection_id) VALUES + ('openai', 'generic'), + ('codex', 'workspace'), + ('codex', 'numeric-workspace'), + ('codex', 'object-user'), + ('openai', 'array-username'), + ('openai', 'malformed-json'), + ('', ''); + `); + + const count = withMockedMigrationFs({ "127_usage_history_account_identity.sql": sql }, () => + runner.runMigrations(db) + ); + const rows = db + .prepare("SELECT connection_id, account_key FROM usage_history ORDER BY id") + .all(); + + assert.equal(count, 1); + assert.deepEqual(rows, [ + { + connection_id: "generic", + account_key: '["oauth","openai","email"," Member@example.com ","username"," Member "]', + }, + { + connection_id: "workspace", + account_key: '["oauth","codex","workspace"," Workspace ","email"," Member@example.com "]', + }, + { + connection_id: "numeric-workspace", + account_key: '["connection","codex","numeric-workspace"]', + }, + { + connection_id: "object-user", + account_key: '["connection","codex","object-user"]', + }, + { + connection_id: "array-username", + account_key: '["oauth","openai","email","member@example.com"]', + }, + { + connection_id: "malformed-json", + account_key: '["oauth","openai","email","member@example.com"]', + }, + { + connection_id: "", + account_key: '["connection","unknown","unknown"]', + }, + ]); + } finally { + db.close(); + } +}); diff --git a/tests/unit/json-migration-combos.test.ts b/tests/unit/json-migration-combos.test.ts index b31d938068..988706b373 100644 --- a/tests/unit/json-migration-combos.test.ts +++ b/tests/unit/json-migration-combos.test.ts @@ -18,6 +18,39 @@ test.after(() => { else process.env.DATA_DIR = ORIGINAL_DATA_DIR; }); +test("runJsonMigration preserves exported account snapshots after a connection was deleted", () => { + const db = core.getDbInstance(); + + runJsonMigration(db, { + usageHistory: [ + { + id: 1, + provider: "codex", + model: "gpt-5.5", + connection_id: "deleted-codex-uuid", + account_key: '["oauth","codex","user","user-a","email","member@example.com"]', + account_label: "Production Codex", + account_label_priority: 4, + tokens_input: 10, + tokens_output: 5, + timestamp: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const row = db + .prepare( + "SELECT account_key, account_label, account_label_priority FROM usage_history WHERE id = 1" + ) + .get(); + + assert.deepEqual(row, { + account_key: '["oauth","codex","user","user-a","email","member@example.com"]', + account_label: "Production Codex", + account_label_priority: 4, + }); +}); + test("runJsonMigration normalizes legacy combo strategy names at the import boundary", () => { const db = core.getDbInstance(); diff --git a/tests/unit/usage-account-identity.test.ts b/tests/unit/usage-account-identity.test.ts new file mode 100644 index 0000000000..c8af0f7a1d --- /dev/null +++ b/tests/unit/usage-account-identity.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { resolveUsageAccountIdentity } = await import("../../src/lib/usage/accountIdentity.ts"); + +test("Codex chatgptUserId identity is stable across UUID and label changes", () => { + const first = resolveUsageAccountIdentity({ + id: "old-uuid", + provider: "codex", + authType: "oauth", + email: "member@example.com", + displayName: "Production Codex", + providerSpecificData: { chatgptUserId: "user-production" }, + }); + const recreated = resolveUsageAccountIdentity({ + id: "new-uuid", + provider: "codex", + authType: "oauth", + email: "member@example.com", + name: "member@example.com", + providerSpecificData: { chatgptUserId: "user-production" }, + }); + + assert.equal(first.accountKey, recreated.accountKey); + assert.equal(first.accountLabel, "Production Codex"); + assert.equal(recreated.accountLabel, "member@example.com"); +}); diff --git a/tests/unit/usage-analytics-route-extra.test.ts b/tests/unit/usage-analytics-route-extra.test.ts new file mode 100644 index 0000000000..66613642f9 --- /dev/null +++ b/tests/unit/usage-analytics-route-extra.test.ts @@ -0,0 +1,558 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-usage-analytics-route-extra-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +process.env.API_KEY_SECRET = "test-usage-analytics-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); + +const clearPendingRequests = usageHistory.clearPendingRequests; +const EXPECTED_TOTAL_COST = 0.020925; + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + clearPendingRequests(); +} + +async function seedAnalyticsData() { + const db = core.getDbInstance(); + const now = new Date(); + for (let i = 0; i < 20; i++) { + const timestamp = new Date(now.getTime() - i * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + i % 2 === 0 ? "openai" : "anthropic", + i % 2 === 0 ? "gpt-4o" : "claude-sonnet", + "test-conn", + "test-key", + "Primary Key", + 100 + i, + 50 + i, + 1, + 200 + i * 10, + timestamp + ); + } + db.prepare( + `INSERT INTO call_logs (provider, model, requested_model, connection_id, timestamp) + VALUES (?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "gpt-4o-mini", "test-conn", new Date().toISOString()); +} + +function makeRequest(url: string) { + return new Request(url, { method: "GET" }); +} + +function assertClose(actual: number, expected: number, epsilon = 0.000001) { + assert.ok( + Math.abs(actual - expected) <= epsilon, + `expected ${actual} to be within ${epsilon} of ${expected}` + ); +} + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updatePricing({ + openai: { "gpt-4o": { input: 2.5, output: 10 } }, + anthropic: { "claude-sonnet": { input: 3, output: 15 } }, + }); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } +}); +test("GET /api/usage/analytics joins legacy workspace usage after Codex re-login", async () => { + await localDb.updatePricing({ + codex: { "gpt-5.5": { input: 1, output: 2 } }, + }); + + const db = core.getDbInstance(); + const connectionId = "legacy-workspace-connection"; + db.prepare( + `INSERT INTO provider_connections + (id, provider, auth_type, email, display_name, provider_specific_data, created_at, updated_at) + VALUES (?, 'codex', 'oauth', NULL, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + ).run(connectionId, "Production Codex", JSON.stringify({ workspaceId: "workspace-production" })); + + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + + const relogged = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + accessToken: "new-secret", + providerSpecificData: { workspaceId: "workspace-production" }, + }); + assert.equal(relogged.id, connectionId); + const repaired = db + .prepare( + "SELECT account_key, account_label, account_label_priority FROM usage_history WHERE connection_id = ?" + ) + .get(connectionId); + assert.deepEqual(repaired, { + account_key: + '["oauth","codex","workspace","workspace-production","email","member@example.com"]', + account_label: "Production Codex", + account_label_priority: 4, + }); + + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-01-02T00:00:00.000Z", + }); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byAccount.length, 1); + assert.equal(body.byAccount[0].account, "Production Codex"); + assert.equal(body.byAccount[0].requests, 2); + assertClose(body.byAccount[0].cost, 0.004); +}); + +test("GET /api/usage/analytics preserves a Codex account across deletion and re-login", async () => { + await localDb.updatePricing({ + codex: { "gpt-5.5": { input: 1, output: 2 } }, + }); + + const original = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + displayName: "Production Codex", + accessToken: "old-secret", + providerSpecificData: { workspaceId: "workspace-production" }, + }); + + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: original.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + await providersDb.deleteProviderConnection(original.id as string); + + const recreated = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + accessToken: "new-secret", + providerSpecificData: { workspaceId: "workspace-production" }, + }); + assert.notEqual(recreated.id, original.id); + + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: recreated.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-01-02T00:00:00.000Z", + }); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byAccount.length, 1); + assert.equal(body.byAccount[0].account, "Production Codex"); + assert.equal("accountKey" in body.byAccount[0], false); + assert.equal(body.byAccount[0].requests, 2); + assertClose(body.byAccount[0].cost, 0.004); +}); + +test("GET /api/usage/analytics keeps provider and Codex workspace account identities distinct", async () => { + const accountSpecs = [ + { provider: "codex", workspaceId: "workspace-a" }, + { provider: "codex", workspaceId: "workspace-b" }, + { provider: "openai", workspaceId: undefined }, + ]; + + for (const [index, spec] of accountSpecs.entries()) { + const connection = await providersDb.createProviderConnection({ + provider: spec.provider, + authType: "oauth", + email: "shared@example.com", + accessToken: `secret-${index}`, + providerSpecificData: spec.workspaceId ? { workspaceId: spec.workspaceId } : {}, + }); + await usageHistory.saveRequestUsage({ + provider: spec.provider, + model: spec.provider === "codex" ? "gpt-5.5" : "gpt-4o", + connectionId: connection.id as string, + tokens: { input: 100 + index, output: 50 + index }, + timestamp: `2026-01-0${index + 1}T00:00:00.000Z`, + }); + } + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byAccount.length, 3); + assert.ok(body.byAccount.every((row) => !("accountKey" in row))); + assert.deepEqual(body.byAccount.map((row) => row.account).sort(), [ + "shared@example.com", + "shared@example.com", + "shared@example.com", + ]); +}); + +test("GET /api/usage/analytics keeps an honest UUID label for an orphaned legacy account", async () => { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("codex", "gpt-5.5", "deleted-legacy-uuid", 10, 5, 1, 25, "2026-01-01T00:00:00.000Z"); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byAccount.length, 1); + assert.equal(body.byAccount[0].account, "deleted-legacy-uuid"); + assert.equal("accountKey" in body.byAccount[0], false); +}); + +test("GET /api/usage/analytics uses the newest equal-priority label for one account", async () => { + const db = core.getDbInstance(); + const accountKey = '["oauth","codex","user","user-a","email","member@example.com"]'; + const now = Date.now(); + const insert = db.prepare( + `INSERT INTO usage_history + (provider, model, connection_id, account_key, account_label, account_label_priority, + tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insert.run( + "codex", + "gpt-5.5", + "old-uuid", + accountKey, + "Zulu before rename", + 4, + 10, + 5, + 1, + 20, + new Date(now - 60_000).toISOString() + ); + insert.run( + "codex", + "gpt-5.5", + "new-uuid", + accountKey, + "Alpha after rename", + 4, + 20, + 10, + 1, + 30, + new Date(now).toISOString() + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.uniqueAccounts, 1); + assert.equal(body.byAccount.length, 1); + assert.equal(body.byAccount[0].account, "Alpha after rename"); + assert.equal(body.byAccount[0].requests, 2); + assert.equal("accountKey" in body.byAccount[0], false); +}); + +test("GET /api/usage/analytics includes cost by API key", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.byApiKey)); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, "test-key"); + assert.equal(body.byApiKey[0].apiKeyName, "Primary Key"); + assertClose(body.byApiKey[0].cost, body.summary.totalCost); +}); + +test("GET /api/usage/analytics does not double-count raw and aggregated rows", async () => { + const db = core.getDbInstance(); + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 30); + const olderDate = new Date(cutoffDate); + olderDate.setDate(olderDate.getDate() - 1); + const olderDateStr = olderDate.toISOString().split("T")[0]; + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "raw-current", 100, 50, 1, 200, today.toISOString()); + + const insertSummary = db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + insertSummary.run("openai", "gpt-4o", todayStr, 99, 9900, 9900, 0); + insertSummary.run("openai", "gpt-4o", olderDateStr, 1, 25, 10, 0); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=all") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 2); + assert.equal(body.summary.totalTokens, 185); + assert.equal(body.summary.uniqueAccounts, 1); +}); + +test("GET /api/usage/analytics omits global aggregates when filtering by API key", async () => { + const apiKey = await apiKeysDb.createApiKey("Scoped Key", "machine1234567890"); + const db = core.getDbInstance(); + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "openai", + "gpt-4o", + "scoped-conn", + apiKey.id, + "Scoped Key", + 100, + 50, + 1, + 200, + new Date().toISOString() + ); + + db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "2024-01-01", 99, 9900, 9900, 0); + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=all&apiKeyIds=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); + assert.equal(body.summary.totalTokens, 150); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); +}); + +test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => { + const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890"); + await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" }); + + const db = core.getDbInstance(); + const now = Date.now(); + const insertUsage = db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Averyanov", + 100, + 50, + 1, + 200, + new Date(now - 60_000).toISOString() + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Desktop", + 200, + 100, + 1, + 250, + new Date(now).toISOString() + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.uniqueApiKeys, 1); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); + assert.equal(body.byApiKey[0].apiKeyName, "Alexander Averyanov"); + assert.deepEqual(body.byApiKey[0].historicalApiKeyNames.sort(), ["Averyanov", "Desktop"]); + assert.equal(body.byApiKey[0].requests, 2); + assert.equal(body.byApiKey[0].promptTokens, 300); + assert.equal(body.byApiKey[0].completionTokens, 150); + + const filteredResponse = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?apiKeyIds=${apiKey.id}`) + ); + const filteredBody = await filteredResponse.json(); + + assert.equal(filteredResponse.status, 200); + assert.equal(filteredBody.summary.totalRequests, 2); + assert.equal(filteredBody.byApiKey.length, 1); + assert.equal(filteredBody.byApiKey[0].apiKeyId, apiKey.id); +}); + +test("GET /api/usage/analytics does not persist guessed API key attribution", async () => { + await localDb.updatePricing({ + openai: { "gpt-4o": { input: 2.5, output: 10 } }, + }); + await apiKeysDb.createApiKey("Unrestricted Key", "machine1234567890"); + + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "legacy-conn", null, null, 100, 50, 1, 200, new Date().toISOString()); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byApiKey.length, 0); + + const row = db + .prepare("SELECT api_key_id, api_key_name FROM usage_history WHERE connection_id = ?") + .get("legacy-conn") as { api_key_id: string | null; api_key_name: string | null }; + assert.equal(row.api_key_id, null); + assert.equal(row.api_key_name, null); +}); + +test("GET /api/usage/analytics returns weeklyPattern for the costs dashboard", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.weeklyPattern)); + assert.equal(body.weeklyPattern.length, 7); + assert.deepEqual( + body.weeklyPattern.map((row) => row.day), + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + ); + assert.ok(body.weeklyPattern.some((row) => row.totalTokens > 0 && row.avgTokens > 0)); +}); + +test("GET /api/usage/analytics includes activityMap for heatmap", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(typeof body.activityMap === "object"); + assert.ok(Object.keys(body.activityMap).length > 0); +}); + +test("GET /api/usage/analytics returns 500 on database errors", async () => { + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(body.summary.totalRequests === 0); +}); + +test("GET /api/usage/analytics does not throw Unknown named parameter on short range (needsAggregated=false)", async () => { + // Regression: shared params object leaked agg-only bindings (@sinceDate, @rawCutoffDate) + // into queries that don't reference them, causing better-sqlite3 to throw. + // A short range (1h) triggers needsAggregated=false because the entire window + // falls within the raw-data-only period. + const db = core.getDbInstance(); + const now = new Date(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", 100, 50, 1, 200, now.toISOString()); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=1h") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); +}); + +test("GET /api/usage/analytics does not throw Unknown named parameter with apiKey filter on long range", async () => { + // Regression: Object.assign(presetParams, params) leaked all main-query bindings + // into preset queries that only reference preset-prefixed placeholders. + const apiKey = await apiKeysDb.createApiKey("Preset Key", "machine-preset1234"); + const db = core.getDbInstance(); + const now = new Date(); + + // Seed data old enough to trigger aggregated + preset path + for (let i = 0; i < 5; i++) { + const ts = new Date(now.getTime() - (35 + i) * 24 * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", apiKey.id, apiKey.name, 100, 50, 1, 200, ts); + } + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=60d&apiKeyId=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + // Core regression check: no "Unknown named parameter" error. + // The exact count depends on raw-vs-aggregated boundary; we only need to + // confirm the endpoint returns 200 without throwing. + assert.ok(typeof body.summary.totalRequests === "number"); +}); diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 414fd9cd19..016947e483 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -12,6 +12,7 @@ process.env.API_KEY_SECRET = "test-usage-analytics-secret"; const core = await import("../../src/lib/db/core.ts"); const localDb = await import("../../src/lib/localDb.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); @@ -324,258 +325,11 @@ test("GET /api/usage/analytics includes byAccount array with cost data", async ( assert.equal(response.status, 200); assert.ok(Array.isArray(body.byAccount)); assert.ok(body.byAccount.length > 0); - assert.equal(body.byAccount[0].account, "test-conn"); - assert.equal(typeof body.byAccount[0].cost, "number"); - assertClose(body.byAccount[0].cost, body.summary.totalCost); + assert.ok(body.byAccount.every((row) => row.account === "test-conn")); + assert.ok(body.byAccount.every((row) => typeof row.cost === "number")); + assertClose( + body.byAccount.reduce((sum, row) => sum + row.cost, 0), + body.summary.totalCost + ); }); -test("GET /api/usage/analytics includes cost by API key", async () => { - await seedAnalyticsData(); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.ok(Array.isArray(body.byApiKey)); - assert.equal(body.byApiKey.length, 1); - assert.equal(body.byApiKey[0].apiKeyId, "test-key"); - assert.equal(body.byApiKey[0].apiKeyName, "Primary Key"); - assertClose(body.byApiKey[0].cost, body.summary.totalCost); -}); - -test("GET /api/usage/analytics does not double-count raw and aggregated rows", async () => { - const db = core.getDbInstance(); - const today = new Date(); - const todayStr = today.toISOString().split("T")[0]; - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - 30); - const olderDate = new Date(cutoffDate); - olderDate.setDate(olderDate.getDate() - 1); - const olderDateStr = olderDate.toISOString().split("T")[0]; - - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("openai", "gpt-4o", "raw-current", 100, 50, 1, 200, today.toISOString()); - - const insertSummary = db.prepare( - `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ); - insertSummary.run("openai", "gpt-4o", todayStr, 99, 9900, 9900, 0); - insertSummary.run("openai", "gpt-4o", olderDateStr, 1, 25, 10, 0); - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?range=all") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.summary.totalRequests, 2); - assert.equal(body.summary.totalTokens, 185); -}); - -test("GET /api/usage/analytics omits global aggregates when filtering by API key", async () => { - const apiKey = await apiKeysDb.createApiKey("Scoped Key", "machine1234567890"); - const db = core.getDbInstance(); - - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - "openai", - "gpt-4o", - "scoped-conn", - apiKey.id, - "Scoped Key", - 100, - 50, - 1, - 200, - new Date().toISOString() - ); - - db.prepare( - `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run("openai", "gpt-4o", "2024-01-01", 99, 9900, 9900, 0); - - const response = await analyticsRoute.GET( - makeRequest(`http://localhost/api/usage/analytics?range=all&apiKeyIds=${apiKey.id}`) - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.summary.totalRequests, 1); - assert.equal(body.summary.totalTokens, 150); - assert.equal(body.byApiKey.length, 1); - assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); -}); - -test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => { - const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890"); - await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" }); - - const db = core.getDbInstance(); - const now = Date.now(); - const insertUsage = db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ); - insertUsage.run( - "openai", - "gpt-4o", - "test-conn", - apiKey.id, - "Averyanov", - 100, - 50, - 1, - 200, - new Date(now - 60_000).toISOString() - ); - insertUsage.run( - "openai", - "gpt-4o", - "test-conn", - apiKey.id, - "Desktop", - 200, - 100, - 1, - 250, - new Date(now).toISOString() - ); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.summary.uniqueApiKeys, 1); - assert.equal(body.byApiKey.length, 1); - assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); - assert.equal(body.byApiKey[0].apiKeyName, "Alexander Averyanov"); - assert.deepEqual(body.byApiKey[0].historicalApiKeyNames.sort(), ["Averyanov", "Desktop"]); - assert.equal(body.byApiKey[0].requests, 2); - assert.equal(body.byApiKey[0].promptTokens, 300); - assert.equal(body.byApiKey[0].completionTokens, 150); - - const filteredResponse = await analyticsRoute.GET( - makeRequest(`http://localhost/api/usage/analytics?apiKeyIds=${apiKey.id}`) - ); - const filteredBody = await filteredResponse.json(); - - assert.equal(filteredResponse.status, 200); - assert.equal(filteredBody.summary.totalRequests, 2); - assert.equal(filteredBody.byApiKey.length, 1); - assert.equal(filteredBody.byApiKey[0].apiKeyId, apiKey.id); -}); - -test("GET /api/usage/analytics does not persist guessed API key attribution", async () => { - await localDb.updatePricing({ - openai: { "gpt-4o": { input: 2.5, output: 10 } }, - }); - await apiKeysDb.createApiKey("Unrestricted Key", "machine1234567890"); - - const db = core.getDbInstance(); - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run("openai", "gpt-4o", "legacy-conn", null, null, 100, 50, 1, 200, new Date().toISOString()); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.byApiKey.length, 0); - - const row = db - .prepare("SELECT api_key_id, api_key_name FROM usage_history WHERE connection_id = ?") - .get("legacy-conn") as { api_key_id: string | null; api_key_name: string | null }; - assert.equal(row.api_key_id, null); - assert.equal(row.api_key_name, null); -}); - -test("GET /api/usage/analytics returns weeklyPattern for the costs dashboard", async () => { - await seedAnalyticsData(); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.ok(Array.isArray(body.weeklyPattern)); - assert.equal(body.weeklyPattern.length, 7); - assert.deepEqual( - body.weeklyPattern.map((row) => row.day), - ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - ); - assert.ok(body.weeklyPattern.some((row) => row.totalTokens > 0 && row.avgTokens > 0)); -}); - -test("GET /api/usage/analytics includes activityMap for heatmap", async () => { - await seedAnalyticsData(); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.ok(typeof body.activityMap === "object"); - assert.ok(Object.keys(body.activityMap).length > 0); -}); - -test("GET /api/usage/analytics returns 500 on database errors", async () => { - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.ok(body.summary.totalRequests === 0); -}); - -test("GET /api/usage/analytics does not throw Unknown named parameter on short range (needsAggregated=false)", async () => { - // Regression: shared params object leaked agg-only bindings (@sinceDate, @rawCutoffDate) - // into queries that don't reference them, causing better-sqlite3 to throw. - // A short range (1h) triggers needsAggregated=false because the entire window - // falls within the raw-data-only period. - const db = core.getDbInstance(); - const now = new Date(); - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("openai", "gpt-4o", "test-conn", 100, 50, 1, 200, now.toISOString()); - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?range=1h") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.summary.totalRequests, 1); -}); - -test("GET /api/usage/analytics does not throw Unknown named parameter with apiKey filter on long range", async () => { - // Regression: Object.assign(presetParams, params) leaked all main-query bindings - // into preset queries that only reference preset-prefixed placeholders. - const apiKey = await apiKeysDb.createApiKey("Preset Key", "machine-preset1234"); - const db = core.getDbInstance(); - const now = new Date(); - - // Seed data old enough to trigger aggregated + preset path - for (let i = 0; i < 5; i++) { - const ts = new Date(now.getTime() - (35 + i) * 24 * 60 * 60 * 1000).toISOString(); - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run("openai", "gpt-4o", "test-conn", apiKey.id, apiKey.name, 100, 50, 1, 200, ts); - } - - const response = await analyticsRoute.GET( - makeRequest(`http://localhost/api/usage/analytics?range=60d&apiKeyId=${apiKey.id}`) - ); - const body = await response.json(); - - assert.equal(response.status, 200); - // Core regression check: no "Unknown named parameter" error. - // The exact count depends on raw-vs-aggregated boundary; we only need to - // confirm the endpoint returns 200 without throwing. - assert.ok(typeof body.summary.totalRequests === "number"); -}); diff --git a/tests/unit/usage-migrations.test.ts b/tests/unit/usage-migrations.test.ts index eabcc3c147..0ac0e55fcf 100644 --- a/tests/unit/usage-migrations.test.ts +++ b/tests/unit/usage-migrations.test.ts @@ -45,6 +45,7 @@ function resetDbTables() { const db = getDbInstance(); db.prepare("DELETE FROM usage_history").run(); db.prepare("DELETE FROM call_logs").run(); + db.prepare("DELETE FROM provider_connections").run(); } function readJson(filePath) { @@ -139,7 +140,22 @@ test("migrateLegacyUsageFiles copies legacy JSON files once and does not overwri assert.deepEqual(readJson(CALL_LOGS_JSON_FILE), { logs: [{ id: "current-call" }] }); }); -test("migrateUsageJsonToSqlite migrates usage history aliases and TTFT fallbacks", () => { +test("migrateUsageJsonToSqlite migrates usage history aliases, TTFT, and account snapshots", () => { + const db = getDbInstance(); + db.prepare( + `INSERT INTO provider_connections + (id, provider, auth_type, email, provider_specific_data, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + "conn-openai", + "openai", + "oauth", + "member@example.com", + JSON.stringify({ username: "member" }), + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z" + ); + writeJson(USAGE_JSON_FILE, { history: [ { @@ -182,13 +198,13 @@ test("migrateUsageJsonToSqlite migrates usage history aliases and TTFT fallbacks assert.equal(fs.existsSync(`${USAGE_JSON_FILE}.migrated`), true); - const db = getDbInstance(); const rows = db .prepare( ` - SELECT provider, model, connection_id, api_key_id, api_key_name, - tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, - tokens_reasoning, status, success, latency_ms, ttft_ms, error_code + SELECT 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, status, success, + latency_ms, ttft_ms, error_code FROM usage_history ORDER BY timestamp ASC ` @@ -201,6 +217,9 @@ test("migrateUsageJsonToSqlite migrates usage history aliases and TTFT fallbacks provider: "openai", model: "gpt-4o-mini", connection_id: "conn-openai", + account_key: '["oauth","openai","email","member@example.com","username","member"]', + account_label: "member@example.com", + account_label_priority: 3, api_key_id: "key-1", api_key_name: "Primary Key", tokens_input: 11, @@ -218,6 +237,9 @@ test("migrateUsageJsonToSqlite migrates usage history aliases and TTFT fallbacks provider: "gemini", model: "gemini-2.5-flash", connection_id: null, + account_key: '["connection","gemini","unknown"]', + account_label: "unknown", + account_label_priority: 0, api_key_id: null, api_key_name: null, tokens_input: 9,