fix(usage): harden account identity reconciliation (#7843)

* fix(usage): harden account identity reconciliation

* fix(db): renumber codex strong-identity migration 128 -> 129

release/v3.8.49 tip took slot 128 via #7839 (auto_candidate_overrides) after
this branch forked; renumber the new migration and its test references.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Xiangzhe
2026-07-21 02:56:47 +08:00
committed by GitHub
parent b2efa35982
commit 74204911c7
15 changed files with 1452 additions and 389 deletions

View File

@@ -1184,6 +1184,9 @@ export function getDbInstance(): SqliteDatabase {
`);
runMigrations(db, { isNewDb });
// Fresh installs need the same post-migration index guarantee as upgraded
// databases, including recovery from an interrupted migration 127 attempt.
ensureUsageHistoryAccountIndex(db);
applyStoredDatabaseOptimizationSettings(db);

View File

@@ -245,16 +245,17 @@ export function runJsonMigration(
)
`);
for (const row of data.usageHistory) {
const connection = importedConnections.get(row.connection_id);
const connectionId = row.connection_id ?? row.connectionId ?? null;
const connection = connectionId ? importedConnections.get(connectionId) : undefined;
const fallbackIdentity = connection
? resolveUsageAccountIdentity(connection)
: resolveOrphanedUsageAccountIdentity(row.provider, row.connection_id);
: resolveOrphanedUsageAccountIdentity(row.provider, connectionId);
const identity = resolveImportedUsageAccountIdentity(row, fallbackIdentity);
insertUsageHistory.run({
id: row.id,
provider: row.provider ?? null,
model: row.model ?? null,
connection_id: row.connection_id ?? null,
connection_id: connectionId,
account_key: identity.accountKey,
account_label: identity.accountLabel,
account_label_priority: identity.accountLabelPriority,

View File

@@ -0,0 +1,62 @@
-- Migration 128: Promote only provable Codex user/email snapshots to the
-- strong user identity written by current runtime code.
--
-- Migration 127 has already shipped, so this follow-up must repair existing
-- databases rather than editing the old migration. A weak historical snapshot
-- may be promoted only when the account_key itself recorded the same
-- chatgptUserId that the still-present OAuth Codex connection reports today:
-- the embedded historical user ID is proof the snapshot belongs to that user
-- only when the current connection has no nonblank workspaceId. A current
-- workspace may differ from the historical workspace, so migration 128 must
-- never derive a workspace-qualified key from that current value.
-- Email equality is deliberately NOT required (email can change). Snapshots
-- with a current nonblank workspace, workspace/email snapshots, mismatched
-- users, and other unproven identities stay unchanged until runtime observes
-- the matching account again.
--
-- Snapshots that are already strong, deleted/exported orphans, non-Codex,
-- non-OAuth, or malformed keep their historical identity. The statement is a
-- no-op when the strong key already matches, so reruns are idempotent.
UPDATE usage_history
SET account_key = (
SELECT json_array(
'oauth',
'codex',
'user',
json_extract(c.provider_specific_data, '$.chatgptUserId')
)
FROM provider_connections c
WHERE c.id = usage_history.connection_id
)
WHERE EXISTS (
SELECT 1
FROM provider_connections c
WHERE c.id = usage_history.connection_id
AND c.provider = 'codex'
AND c.auth_type = 'oauth'
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 (
json_type(c.provider_specific_data, '$.workspaceId') IS NULL
OR json_type(c.provider_specific_data, '$.workspaceId') <> 'text'
OR trim(json_extract(c.provider_specific_data, '$.workspaceId')) = ''
)
AND json_valid(COALESCE(usage_history.account_key, ''))
AND json_type(usage_history.account_key) = 'array'
AND json_array_length(usage_history.account_key) = 6
AND json_type(usage_history.account_key, '$[0]') = 'text'
AND json_extract(usage_history.account_key, '$[0]') = 'oauth'
AND json_type(usage_history.account_key, '$[1]') = 'text'
AND json_extract(usage_history.account_key, '$[1]') = 'codex'
AND json_type(usage_history.account_key, '$[2]') = 'text'
AND json_extract(usage_history.account_key, '$[2]') = 'user'
AND json_type(usage_history.account_key, '$[3]') = 'text'
AND json_extract(usage_history.account_key, '$[3]') =
json_extract(c.provider_specific_data, '$.chatgptUserId')
AND json_type(usage_history.account_key, '$[4]') = 'text'
AND json_extract(usage_history.account_key, '$[4]') = 'email'
AND json_type(usage_history.account_key, '$[5]') = 'text'
AND json_extract(usage_history.account_key, '$[5]') <> ''
);

View File

@@ -20,7 +20,8 @@ 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 { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
import {
withNullableMaxConcurrent,
withNullableQuotaWindowThresholds,
@@ -36,6 +37,8 @@ import {
type JsonRecord = Record<string, unknown>;
const CONNECTION_CREDENTIAL_FIELDS = ["apiKey", "accessToken", "refreshToken", "idToken"] as const;
interface StatementLike<TRow = unknown> {
all: (...params: unknown[]) => TRow[];
get: (...params: unknown[]) => TRow | undefined;
@@ -197,7 +200,7 @@ export async function getRawProviderConnections(
camelRow
);
});
}
}
export function getProviderConnectionsCount(filter: JsonRecord = {}): number {
const db = getDbInstance() as unknown as DbLike;
@@ -287,59 +290,53 @@ export async function createProviderConnection(data: JsonRecord) {
data.providerSpecificData
);
// Upsert check
// 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;
let promotedCodexIdentity = false;
if (data.authType === "oauth" && data.email) {
// For Codex, check for existing connection with same workspace
const providerSpecificData = toRecord(data.providerSpecificData);
const workspaceId = toStringOrNull(providerSpecificData.workspaceId);
if (data.provider === "codex" && workspaceId) {
// For Codex, check for existing connection with same workspace AND email
// A single workspace can have multiple users (Team/Business plans)
// We need both workspace + email uniqueness to allow multiple accounts
existing =
(db
.prepare(
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND email = ?"
)
.get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null;
const providerSpecificData = toRecord(data.providerSpecificData);
const workspaceId = toStringOrNull(providerSpecificData.workspaceId);
const chatgptUserId = toStringOrNull(providerSpecificData.chatgptUserId);
// If no match with workspace+email, also check workspace-only for backward compat
// (old connections without email should still be updated, not duplicated)
if (!existing) {
if (data.authType === "oauth" && data.provider === "codex" && chatgptUserId) {
const strongSql = workspaceId
? "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND json_extract(provider_specific_data, '$.chatgptUserId') = ?"
: "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND (json_extract(provider_specific_data, '$.workspaceId') IS NULL OR json_extract(provider_specific_data, '$.workspaceId') = '') AND json_extract(provider_specific_data, '$.chatgptUserId') = ?";
existing =
((workspaceId
? db.prepare(strongSql).get(data.provider, workspaceId, chatgptUserId)
: db.prepare(strongSql).get(data.provider, chatgptUserId)) as JsonRecord | undefined) ||
null;
if (!existing && workspaceId) {
const workspaceMatches = db
.prepare(
`SELECT * FROM provider_connections
WHERE provider = ? AND auth_type = 'oauth'
AND json_extract(provider_specific_data, '$.workspaceId') = ?
ORDER BY created_at`
)
.all(data.provider, workspaceId) as JsonRecord[];
existing = pickCodexConnectionForUser(
workspaceMatches,
chatgptUserId,
toStringOrNull(data.email)
);
promotedCodexIdentity = existing !== null;
}
} else if (data.authType === "oauth" && data.email) {
if (data.provider === "codex") {
if (workspaceId) {
existing =
(db
.prepare(
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')"
`SELECT * FROM provider_connections
WHERE provider = ? AND auth_type = 'oauth'
AND json_extract(provider_specific_data, '$.workspaceId') = ?
AND email = ?
LIMIT 1`
)
.get(data.provider, workspaceId) as JsonRecord | undefined) || null;
legacyCodexWorkspaceMatch = existing !== null;
.get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null;
}
// For Codex with workspaceId, don't fall back to email-only check
// This allows creating new connections for different workspaces
} else if (data.provider === "codex") {
// Codex without a workspaceId — do NOT fall through to the generic
// bare-email dedup below. Codex never sets providerSpecificData.username,
// so that path's disambiguation is a no-op and two distinct Codex logins
// sharing an email (but missing a verifiable workspace/account id) would
// silently collapse into one row, overwriting the first login's token
// pair. Require a matching chatgptUserId (a stable per-account id from
// the JWT) before merging; otherwise treat this as a new connection.
const chatgptUserId = toStringOrNull(providerSpecificData.chatgptUserId);
if (chatgptUserId) {
existing =
(db
.prepare(
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.chatgptUserId') = ? AND email = ?"
)
.get(data.provider, chatgptUserId, data.email) as JsonRecord | undefined) || null;
}
// No chatgptUserId on the incoming row (or no existing match) — leave
// `existing` null so a new connection row is inserted.
} else {
// For other providers (or Codex without workspaceId), match on email —
// disambiguated by providerSpecificData.username when present on both
@@ -415,38 +412,29 @@ export async function createProviderConnection(data: JsonRecord) {
if (existing) {
const existingId = toStringOrNull(existing.id);
if (!existingId) return null;
const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now };
const rawExisting = toRecord(rowToCamel(existing));
const decryptedExisting = decryptConnectionFields({ ...rawExisting });
const merged: JsonRecord = { ...decryptedExisting, ...data, updatedAt: now };
merged.providerSpecificData = normalizeProviderSpecificData(
toStringOrNull(merged.provider),
merged.providerSpecificData
);
const persistence: JsonRecord = { ...merged };
for (const field of CONNECTION_CREDENTIAL_FIELDS) {
if (!Object.hasOwn(data, field)) {
persistence[field] = rawExisting[field];
}
}
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({
if (promotedCodexIdentity) {
reconcileCodexUsageHistory(db, {
connectionId: existingId,
oldAccountKey: oldIdentity.accountKey,
newAccountKey: newIdentity.accountKey,
newLabel: newIdentity.accountLabel,
newLabelPriority: newIdentity.accountLabelPriority,
existing,
merged,
matchedExistingCodexByWorkspace: true,
});
}
_updateConnectionRow(db, existingId, merged);
_updateConnectionRow(db, existingId, encryptConnectionFields(persistence));
})();
backupDbFile("pre-write");
return withNullableRateLimitOverrides(
@@ -763,7 +751,16 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
if ("rateLimitOverrides" in merged) {
merged.rateLimitOverrides = sanitizeRateLimitOverrides(merged.rateLimitOverrides);
}
_updateConnectionRow(db, id, encryptConnectionFields({ ...merged }));
const existingRecord = toRecord(existing);
db.transaction(() => {
reconcileCodexUsageHistory(db, {
connectionId: id,
existing: existingRecord,
merged,
});
_updateConnectionRow(db, id, encryptConnectionFields({ ...merged }));
})();
backupDbFile("pre-write");
invalidateDbCache("connections"); // Bust connections read cache
bumpProxyConfigGeneration();

View File

@@ -0,0 +1,87 @@
import { resolveUsageAccountIdentity } from "@/lib/usage/accountIdentity";
import { parseProviderSpecificData } from "../webSessionDedup";
import { toStringOrNull } from "./columns";
type JsonRecord = Record<string, unknown>;
interface StatementLike {
run: (...params: unknown[]) => { changes?: number };
}
interface DbLike {
prepare: <TRow = unknown>(sql: string) => StatementLike;
}
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function isSafeCodexIdentityEnrichment(existing: JsonRecord, merged: JsonRecord): boolean {
const oldProviderData = parseProviderSpecificData(existing.provider_specific_data) || {};
const newProviderData = parseProviderSpecificData(merged.providerSpecificData) || {};
const oldUserId = nonEmptyString(oldProviderData.chatgptUserId);
const newUserId = nonEmptyString(newProviderData.chatgptUserId);
const oldWorkspaceId = nonEmptyString(oldProviderData.workspaceId);
const newWorkspaceId = nonEmptyString(newProviderData.workspaceId);
const oldEmail = nonEmptyString(existing.email);
const newEmail = nonEmptyString(merged.email);
return (
toStringOrNull(existing.provider) === "codex" &&
toStringOrNull(existing.auth_type) === "oauth" &&
toStringOrNull(merged.provider) === "codex" &&
toStringOrNull(merged.authType) === "oauth" &&
oldUserId === null &&
newUserId !== null &&
(oldWorkspaceId === null || oldWorkspaceId === newWorkspaceId) &&
(oldEmail === null || oldEmail === newEmail)
);
}
/**
* Reconcile usage-history identity after a Codex connection gains its stable
* user identity. The caller invokes this inside the same transaction as the
* provider row update, so history and connection identity cannot diverge.
*/
export function reconcileCodexUsageHistory(
db: DbLike,
input: {
connectionId: string;
existing: JsonRecord;
merged: JsonRecord;
matchedExistingCodexByWorkspace?: boolean;
}
): void {
const oldIdentity = resolveUsageAccountIdentity(input.existing);
const newIdentity = resolveUsageAccountIdentity(input.merged);
const identityChanged = oldIdentity.accountKey !== newIdentity.accountKey;
const permitted =
input.matchedExistingCodexByWorkspace === true ||
isSafeCodexIdentityEnrichment(input.existing, input.merged);
if (!identityChanged || !permitted) return;
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: input.connectionId,
oldAccountKey: oldIdentity.accountKey,
newAccountKey: newIdentity.accountKey,
newLabel: newIdentity.accountLabel,
newLabelPriority: newIdentity.accountLabelPriority,
});
}

View File

@@ -342,7 +342,7 @@ export function getAccountCostRows(whereClause: string, params: AnalyticsParams)
SELECT
COALESCE(
NULLIF(usage_history.account_key, ''),
'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(usage_history.connection_id, 'unknown')
'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(NULLIF(TRIM(usage_history.connection_id), ''), 'unknown')
) as resolved_account_key,
usage_history.provider,
usage_history.model,
@@ -403,7 +403,7 @@ export function getAccountUsageRows(
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
COALESCE(NULLIF(usage_history.account_key, ''), 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(NULLIF(TRIM(usage_history.connection_id), ''), 'unknown')) as resolved_account_key
FROM usage_history
${whereClause}
),
@@ -416,10 +416,10 @@ export function getAccountUsageRows(
SELECT
stable_account_keys.account_key,
(
SELECT usage_history.account_label
SELECT TRIM(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
AND NULLIF(TRIM(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
@@ -432,7 +432,7 @@ export function getAccountUsageRows(
FROM (
SELECT
account_events.resolved_account_key as account_key,
account_events.account_label,
TRIM(account_events.account_label) as account_label,
ROW_NUMBER() OVER (
PARTITION BY account_events.resolved_account_key
ORDER BY COALESCE(account_events.account_label_priority, 0) DESC,
@@ -441,7 +441,7 @@ export function getAccountUsageRows(
) 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
AND NULLIF(TRIM(account_events.account_label), '') IS NOT NULL
)
WHERE label_rank = 1
),
@@ -452,7 +452,7 @@ export function getAccountUsageRows(
)
SELECT
account_events.resolved_account_key as accountKey,
COALESCE(selected_labels.account_label, account_events.connection_id, 'unknown') as account,
COALESCE(NULLIF(TRIM(selected_labels.account_label), ''), NULLIF(TRIM(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,

View File

@@ -4,6 +4,7 @@ import {
updateProviderConnection,
} from "@/lib/localDb";
import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
type JsonRecord = Record<string, unknown>;
@@ -179,7 +180,11 @@ export async function createConnectionFromAuthFile(
parsed: ParsedCodexAuth,
options: CreateConnectionOptions
): Promise<{ connection: JsonRecord; created: boolean }> {
const existing = await findExistingCodexConnection(parsed.accountId, parsed.userId);
const existing = await findExistingCodexConnection(
parsed.accountId,
parsed.userId,
options.email || parsed.email || null
);
if (existing) {
if (!options.overwriteExisting) {
@@ -259,36 +264,25 @@ export async function createConnectionFromAuthFile(
// Dedup key is the workspace/account id AND the per-user id. Two distinct users in the
// same workspace share an accountId but have different userIds, so they must NOT collide
// (#6301). Backward-compat: connections imported before the chatgptUserId field existed
// carry no stored userId — when NONE of the workspace matches has a stored userId we fall
// back to the legacy accountId-only match so genuinely-same accounts still dedup.
// From the connections already matched on workspace/account id, pick the one that
// belongs to the incoming user. A different user in the same workspace is NOT a
// duplicate — but only refuse to dedup when some stored connection actually records a
// (different) userId; if none do, they are legacy records and we dedup with the first.
function pickCodexConnectionForUser(
workspaceMatches: JsonRecord[],
userId: string
): JsonRecord | null {
const exact = workspaceMatches.find(
(c) => toNonEmptyString(toRecord(c.providerSpecificData).chatgptUserId) === userId
);
if (exact) return exact;
const anyHasStoredUserId = workspaceMatches.some(
(c) => toNonEmptyString(toRecord(c.providerSpecificData).chatgptUserId) !== null
);
return anyHasStoredUserId ? null : workspaceMatches[0];
}
// carry no stored userId — when NONE of the workspace matches has a stored userId we
// promote the legacy row with a compatible email, or an email-less legacy row. From the
// connections already matched on workspace/account id, pick the one that belongs to the
// incoming user. A different user in the same workspace is NOT a duplicate — refuse to
// dedup when some stored connection actually records a different userId.
async function findExistingCodexConnection(
accountId: string,
userId: string | null
userId: string | null,
email: string | null
): Promise<JsonRecord | null> {
const connections = await getProviderConnections({ provider: "codex" });
const connections = await getProviderConnections({
provider: "codex",
authType: "oauth",
});
const workspaceMatches = (connections as JsonRecord[]).filter(
(c) => toNonEmptyString(toRecord(c.providerSpecificData).workspaceId) === accountId
);
if (workspaceMatches.length === 0) return null;
// No incoming userId → legacy accountId-only dedup with the first workspace match.
if (!userId) return workspaceMatches[0];
return pickCodexConnectionForUser(workspaceMatches, userId);
return pickCodexConnectionForUser(workspaceMatches, userId, email);
}

View File

@@ -0,0 +1,62 @@
type JsonRecord = Record<string, unknown>;
type CodexConnectionIdentity = {
workspaceId: string | null;
userId: string | null;
email: string | null;
};
function toRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) return value as JsonRecord;
if (typeof value !== "string" || !value) return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as JsonRecord)
: {};
} catch {
return {};
}
}
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function readIdentity(connection: JsonRecord): CodexConnectionIdentity {
const providerSpecificData = toRecord(
connection.providerSpecificData ?? connection.provider_specific_data
);
return {
workspaceId: nonEmptyString(providerSpecificData.workspaceId),
userId: nonEmptyString(providerSpecificData.chatgptUserId),
email: nonEmptyString(connection.email),
};
}
/** Select the existing Codex row that may represent an incoming strong user identity. */
export function pickCodexConnectionForUser(
workspaceMatches: JsonRecord[],
userId: string,
email: string | null
): JsonRecord | null {
const identities = workspaceMatches.map((connection) => ({
connection,
identity: readIdentity(connection),
}));
const exact = identities.find(({ identity }) => identity.userId === userId);
if (exact) return exact.connection;
if (identities.some(({ identity }) => identity.userId !== null)) return null;
const normalizedEmail = nonEmptyString(email);
const compatibleEmail = normalizedEmail
? identities.find(({ identity }) => identity.email === normalizedEmail)
: null;
return (
compatibleEmail?.connection ||
identities.find(({ identity }) => identity.email === null)?.connection ||
null
);
}

View File

@@ -67,10 +67,12 @@ export function resolveUsageAccountIdentity(
const username = identityString(providerSpecificData.username);
let identityParts: string[];
if (authType === "oauth" && provider === "codex" && workspaceId && email) {
if (authType === "oauth" && provider === "codex" && workspaceId && chatgptUserId) {
identityParts = ["oauth", provider, "workspace", workspaceId, "user", chatgptUserId];
} else if (authType === "oauth" && provider === "codex" && chatgptUserId) {
identityParts = ["oauth", provider, "user", chatgptUserId];
} else 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]

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
// #6301: importing a DISTINCT Codex/ChatGPT OAuth auth.json is falsely detected as
// "already exists" when it shares the same account/workspace id but has a different
@@ -10,11 +11,11 @@ import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-userid-dedup-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.STORAGE_ENCRYPTION_KEY = "codex-import-userid-dedup-test-key";
const core = await import("../../src/lib/db/core.ts");
const { parseAndValidateCodexAuth, createConnectionFromAuthFile } = await import(
"../../src/lib/oauth/utils/codexAuthImport.ts"
);
const { parseAndValidateCodexAuth, createConnectionFromAuthFile } =
await import("../../src/lib/oauth/utils/codexAuthImport.ts");
type JsonRecord = Record<string, unknown>;
@@ -25,6 +26,24 @@ function buildJwt(payload: JsonRecord): string {
}
// Build a Codex CLI auth.json sharing accountId but with a caller-chosen chatgpt_user_id.
function encryptWithStorageKey(key: string, values: string[]): string[] {
const script = `
import { encrypt } from "./src/lib/db/encryption.ts";
console.log(JSON.stringify(${JSON.stringify(values)}.map((value) => encrypt(value))));
`;
return JSON.parse(
execFileSync(
process.execPath,
["--import", "tsx/esm", "--input-type=module", "--eval", script],
{
cwd: process.cwd(),
env: { ...process.env, STORAGE_ENCRYPTION_KEY: key },
encoding: "utf8",
}
)
) as string[];
}
function buildAuthFile(accountId: string, userId: string, email: string): JsonRecord {
const idToken = buildJwt({
email,
@@ -84,6 +103,42 @@ test("parseAndValidateCodexAuth extracts userId from chatgpt_user_id claim", ()
assert.equal(parsed.userId, "user-alice");
});
test("Codex auth import ignores matching non-OAuth connections", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const parsed = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-alice", "alice@example.com")
);
const nonOauth = await providersDb.createProviderConnection({
provider: "codex",
authType: "access_token",
accessToken: "website-access-token",
email: parsed.email,
providerSpecificData: {
workspaceId: parsed.accountId,
chatgptUserId: parsed.userId,
},
});
const imported = await createConnectionFromAuthFile(parsed, {});
assert.equal(imported.created, true);
assert.notEqual(imported.connection.id, nonOauth.id);
const untouched = await providersDb.getProviderConnectionById(nonOauth.id as string);
assert.equal(untouched?.authType, "access_token");
assert.equal(untouched?.accessToken, "website-access-token");
const rawOauth = core
.getDbInstance()
.prepare(
`SELECT access_token, refresh_token, id_token
FROM provider_connections WHERE id = ?`
)
.get(imported.connection.id) as Record<string, string>;
assert.match(rawOauth.access_token, /^enc:v1:/);
assert.match(rawOauth.refresh_token, /^enc:v1:/);
assert.match(rawOauth.id_token, /^enc:v1:/);
});
test("#6301: same workspace, DIFFERENT user → both imports create a new connection", async () => {
const alice = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-alice", "alice@example.com")
@@ -92,10 +147,6 @@ test("#6301: same workspace, DIFFERENT user → both imports create a new connec
buildAuthFile("acct-shared", "user-bob", "bob@example.com")
);
// Sanity: same account id, distinct user id.
assert.equal(alice.accountId, bob.accountId);
assert.notEqual(alice.userId, bob.userId);
const first = await createConnectionFromAuthFile(alice, {});
assert.equal(first.created, true);
@@ -105,46 +156,342 @@ test("#6301: same workspace, DIFFERENT user → both imports create a new connec
assert.notEqual((second.connection as JsonRecord).id, (first.connection as JsonRecord).id);
});
test("same workspace AND same user → still deduped (update, not create)", async () => {
const alice1 = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-alice", "alice@example.com")
test("same workspace AND same user stays one connection when the email changes", async () => {
const first = await createConnectionFromAuthFile(
parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "old@example.com")),
{}
);
const second = await createConnectionFromAuthFile(
parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "new@example.com")),
{ overwriteExisting: true }
);
const first = await createConnectionFromAuthFile(alice1, {});
assert.equal(first.created, true);
// Re-import the same identity with overwrite → dedup to the existing connection.
const alice2 = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-alice", "alice@example.com")
);
const second = await createConnectionFromAuthFile(alice2, { overwriteExisting: true });
assert.equal(second.created, false);
assert.equal((second.connection as JsonRecord).id, (first.connection as JsonRecord).id);
assert.equal((second.connection as JsonRecord).email, "new@example.com");
});
test("backward-compat: legacy connection without stored userId still dedups by accountId", async () => {
test("an email-less legacy workspace can promote when a user ID appears", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
// Simulate a connection imported before the chatgptUserId field existed.
const legacy = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
name: "Legacy Codex",
accessToken: "at-legacy",
refreshToken: "rt-legacy",
idToken: "id-legacy",
isActive: true,
testStatus: "active",
providerSpecificData: { workspaceId: "acct-shared" },
});
const promoted = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" },
});
assert.equal(promoted.id, legacy.id);
});
test("same workspace and email but different users remain separate", async () => {
const first = await createConnectionFromAuthFile(
parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "shared@example.com")),
{}
);
const second = await createConnectionFromAuthFile(
parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-bob", "shared@example.com")),
{}
);
assert.equal(second.created, true);
assert.notEqual((second.connection as JsonRecord).id, (first.connection as JsonRecord).id);
});
test("Codex auth import promotes the compatible email legacy row and leaves its peer untouched", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const alice = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
displayName: "Alice history",
providerSpecificData: { workspaceId: "acct-shared" },
});
const bob = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "bob@example.com",
displayName: "Bob history",
providerSpecificData: { workspaceId: "acct-shared" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: alice.id as string,
tokens: { input: 10, output: 5 },
timestamp: "2026-01-01T00:00:00.000Z",
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: bob.id as string,
tokens: { input: 20, output: 10 },
timestamp: "2026-01-02T00:00:00.000Z",
});
const imported = await createConnectionFromAuthFile(
parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-bob", "bob@example.com")),
{ overwriteExisting: true }
);
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: imported.connection.id as string,
tokens: { input: 30, output: 15 },
timestamp: "2026-01-03T00:00:00.000Z",
});
const rows = core
.getDbInstance()
.prepare(
`SELECT account_label label, COUNT(*) requests, SUM(tokens_input + tokens_output) tokens
FROM usage_history GROUP BY account_key ORDER BY label`
)
.all();
assert.equal(imported.connection.id, bob.id);
assert.deepEqual(rows, [
{ label: "Alice history", requests: 1, tokens: 15 },
{ label: "Bob history", requests: 2, tokens: 75 },
]);
});
test("an established Codex user prevents an email-less legacy row from absorbing another user", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const alice = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
displayName: "Alice history",
providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" },
});
const legacy = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
displayName: "Legacy history",
providerSpecificData: { workspaceId: "acct-shared" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: alice.id as string,
tokens: { input: 10, output: 5 },
timestamp: "2026-01-01T00:00:00.000Z",
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: legacy.id as string,
tokens: { input: 20, output: 10 },
timestamp: "2026-01-02T00:00:00.000Z",
});
const parsed = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-bob", "bob@example.com")
);
const imported = await createConnectionFromAuthFile(parsed, { overwriteExisting: true });
assert.equal(imported.created, true);
assert.notEqual(imported.connection.id, alice.id);
assert.notEqual(imported.connection.id, legacy.id);
assert.equal(imported.connection.accessToken, parsed.accessToken);
assert.equal(
(await providersDb.getProviderConnectionById(imported.connection.id as string))?.accessToken,
parsed.accessToken
);
const stored = core
.getDbInstance()
.prepare(
`SELECT access_token, refresh_token, id_token
FROM provider_connections WHERE id = ?`
)
.get(imported.connection.id) as Record<string, string>;
assert.notEqual(stored.access_token, parsed.accessToken);
assert.notEqual(stored.refresh_token, parsed.refreshToken);
assert.notEqual(stored.id_token, parsed.idToken);
const histories = core
.getDbInstance()
.prepare(
`SELECT connection_id, account_label, COUNT(*) requests,
SUM(tokens_input + tokens_output) tokens
FROM usage_history GROUP BY connection_id, account_label ORDER BY account_label`
)
.all();
assert.deepEqual(histories, [
{ connection_id: alice.id, account_label: "Alice history", requests: 1, tokens: 15 },
{ connection_id: legacy.id, account_label: "Legacy history", requests: 1, tokens: 30 },
]);
});
test("legacy workspace/email identity promotes to a user identity without losing its history", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const legacy = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
displayName: "Historic label",
apiKey: "preserved-legacy-api-key",
providerSpecificData: { workspaceId: "acct-shared" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: legacy.id as string,
tokens: { input: 10, output: 5 },
timestamp: "2026-01-01T00:00:00.000Z",
});
const result = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
accessToken: "safe-promotion-access-token",
refreshToken: "safe-promotion-refresh-token",
idToken: "safe-promotion-id-token",
providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: result.id as string,
tokens: { input: 20, output: 10 },
timestamp: "2026-01-02T00:00:00.000Z",
});
const groups = core
.getDbInstance()
.prepare(
`SELECT account_key, COUNT(*) requests, SUM(tokens_input + tokens_output) tokens,
MAX(account_label) FILTER (WHERE account_label_priority = 4) historic_label
FROM usage_history GROUP BY account_key`
)
.all() as Array<Record<string, unknown>>;
assert.equal(result.id, legacy.id);
assert.equal(result.accessToken, "safe-promotion-access-token");
assert.equal(result.apiKey, "preserved-legacy-api-key");
const decrypted = await providersDb.getProviderConnectionById(result.id as string);
assert.equal(decrypted?.accessToken, "safe-promotion-access-token");
assert.equal(decrypted?.apiKey, "preserved-legacy-api-key");
const stored = core
.getDbInstance()
.prepare(
`SELECT access_token, refresh_token, id_token
FROM provider_connections WHERE id = ?`
)
.get(result.id) as Record<string, string>;
assert.notEqual(stored.access_token, "safe-promotion-access-token");
assert.notEqual(stored.refresh_token, "safe-promotion-refresh-token");
assert.notEqual(stored.id_token, "safe-promotion-id-token");
assert.deepEqual(
groups.map(({ requests, tokens, historic_label }) => ({ requests, tokens, historic_label })),
[{ requests: 2, tokens: 45, historic_label: "Historic label" }]
);
});
test("partial legacy identity promotion preserves credentials encrypted under another key", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const legacy = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
providerSpecificData: { workspaceId: "acct-shared" },
});
const [accessToken, refreshToken, idToken] = encryptWithStorageKey("codex-import-key-a", [
"legacy-access",
"legacy-refresh",
"legacy-id",
]);
core
.getDbInstance()
.prepare(
`UPDATE provider_connections
SET access_token = ?, refresh_token = ?, id_token = ?
WHERE id = ?`
)
.run(accessToken, refreshToken, idToken, legacy.id);
const beforeCiphertext = {
access_token: accessToken,
refresh_token: refreshToken,
id_token: idToken,
};
const promoted = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
displayName: "Promoted identity",
providerSpecificData: {
workspaceId: "acct-shared",
importedAt: new Date().toISOString(),
// no chatgptUserId
chatgptUserId: "user-alice",
},
});
const incoming = parseAndValidateCodexAuth(
buildAuthFile("acct-shared", "user-alice", "alice@example.com")
assert.equal(promoted?.accessToken, undefined);
assert.equal(promoted?.refreshToken, undefined);
assert.equal(promoted?.idToken, undefined);
const after = core
.getDbInstance()
.prepare(
`SELECT access_token, refresh_token, id_token
FROM provider_connections WHERE id = ?`
)
.get(legacy.id) as Record<string, string>;
assert.deepEqual({ ...after }, beforeCiphertext);
});
test("failed legacy promotion rolls back both provider and usage identity", async () => {
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const legacy = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
displayName: "Historic label",
providerSpecificData: { workspaceId: "acct-shared" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: legacy.id as string,
tokens: { input: 10, output: 5 },
timestamp: "2026-01-01T00:00:00.000Z",
});
const db = core.getDbInstance();
const semanticState = () => ({
provider: db
.prepare("SELECT email, provider_specific_data FROM provider_connections WHERE id = ?")
.get(legacy.id),
usage: db
.prepare(
`SELECT COUNT(DISTINCT account_key) accounts, COUNT(*) requests,
SUM(tokens_input + tokens_output) tokens, MAX(account_label) label
FROM usage_history WHERE connection_id = ?`
)
.get(legacy.id),
});
const before = semanticState();
db.exec(`CREATE TRIGGER reject_codex_promotion BEFORE UPDATE ON provider_connections
BEGIN SELECT RAISE(ABORT, 'forced provider update failure'); END`);
await assert.rejects(
providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "alice@example.com",
providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" },
}),
/forced provider update failure/
);
const result = await createConnectionFromAuthFile(incoming, { overwriteExisting: true });
assert.equal(result.created, false);
assert.equal((result.connection as JsonRecord).id, legacy.id);
assert.deepEqual(semanticState(), before);
});

View File

@@ -4,6 +4,10 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import {
resolveOrphanedUsageAccountIdentity,
resolveUsageAccountIdentity,
} from "../../src/lib/usage/accountIdentity.ts";
async function importFresh(modulePath) {
const url = pathToFileURL(path.resolve(modulePath)).href;
@@ -29,13 +33,16 @@ function withMockedMigrationFs(files, fn) {
return originalExistsSync(target);
};
fs.readdirSync = ((target: string, options?: any) => {
fs.readdirSync = ((
target: fs.PathLike,
options?: BufferEncoding | { encoding: BufferEncoding }
) => {
if (files && isMigrationDir(target)) {
return Object.keys(files);
}
return originalReaddirSync(target, options);
}) as any;
return originalReaddirSync(target, options as never);
}) as typeof fs.readdirSync;
fs.readFileSync = (target, options) => {
const fileName = path.basename(String(target));
@@ -59,7 +66,7 @@ function createDb() {
return new Database(":memory:");
}
test("migration 123 backfills account snapshots and creates its index", async () => {
test("migration 127 backfills account snapshots for historical rows", async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
const migrationSql = fs.readFileSync(
@@ -101,30 +108,266 @@ test("migration 123 backfills account snapshots and creates its index", async ()
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_key: resolveOrphanedUsageAccountIdentity("codex", "orphan-id").accountKey,
account_label: "orphan-id",
},
{
account_key: '["oauth","unknown","email","member@example.com","username","member"]',
account_key: resolveUsageAccountIdentity({
id: "empty-provider",
provider: "",
authType: "oauth",
email: "member@example.com",
providerSpecificData: { username: "member" },
}).accountKey,
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 () => {
test("migration 128 promotes only user-proven snapshots after a fresh 127 backfill", async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
const migration127 = fs.readFileSync(
"src/lib/db/migrations/127_usage_history_account_identity.sql",
"utf8"
);
const migration128 = fs.readFileSync(
"src/lib/db/migrations/129_usage_history_codex_strong_identity.sql",
"utf8"
);
const strong = (connection) =>
resolveUsageAccountIdentity({
id: connection.id,
provider: "codex",
authType: "oauth",
email: connection.email,
providerSpecificData: connection.providerSpecificData,
}).accountKey;
const weakUserEmail = (userId, email) =>
JSON.stringify(["oauth", "codex", "user", userId, "email", email]);
const weakWorkspaceEmail = (workspaceId, email) =>
JSON.stringify(["oauth", "codex", "workspace", workspaceId, "email", email]);
const connections = {
promoted: {
id: "promoted",
email: "new@example.com",
providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-a" },
},
userOnly: {
id: "user-only",
email: "new-solo@example.com",
providerSpecificData: { chatgptUserId: "user-solo" },
},
ambiguousWorkspace: {
id: "ambiguous-workspace",
email: "member@example.com",
providerSpecificData: { workspaceId: "workspace-b", chatgptUserId: "user-b" },
},
alreadyStrong: {
id: "already-strong",
email: "member@example.com",
providerSpecificData: { workspaceId: "workspace-c", chatgptUserId: "user-c" },
},
historicalUserMismatch: {
id: "historical-user-mismatch",
email: "member@example.com",
providerSpecificData: { chatgptUserId: "current-user" },
},
nonOauth: {
id: "non-oauth",
email: "member@example.com",
providerSpecificData: { chatgptUserId: "user-d" },
},
malformedKey: {
id: "malformed-key",
email: "member@example.com",
providerSpecificData: { chatgptUserId: "user-e" },
},
malformedConnection: {
id: "malformed-connection",
email: "member@example.com",
providerSpecificData: null,
},
};
const weakKeys = {
// The current workspace cannot prove the historical workspace for this
// shipped user+email snapshot, so migration 128 must leave it unchanged.
promoted: weakUserEmail("user-a", "old@example.com"),
// Email changes do not matter when the current OAuth connection has no
// workspace and the embedded historical user ID matches.
userOnly: weakUserEmail("user-solo", "old-solo@example.com"),
ambiguousWorkspace: weakWorkspaceEmail("workspace-b", "member@example.com"),
deleted: weakUserEmail("user-deleted", "deleted@example.com"),
nonCodex: JSON.stringify(["oauth", "openai", "email", "member@example.com"]),
nonOauth: weakUserEmail("user-d", "member@example.com"),
malformedKey: "not valid JSON",
malformedConnection: weakUserEmail("user-f", "member@example.com"),
// Shipped historical input: the embedded user differs from the connection's
// current ChatGPT user, so migration 128 must not rewrite this snapshot.
historicalUserMismatch:
'["oauth","codex","user","historical-user","email","member@example.com"]',
};
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
('promoted', 'codex', 'oauth', 'new@example.com',
'{"workspaceId":"workspace-a","chatgptUserId":"user-a"}'),
('user-only', 'codex', 'oauth', 'solo@example.com',
'{"chatgptUserId":"user-solo"}'),
('ambiguous-workspace', 'codex', 'oauth', 'member@example.com',
'{"workspaceId":"workspace-b","chatgptUserId":"user-b"}'),
('already-strong', 'codex', 'oauth', 'member@example.com',
'{"workspaceId":"workspace-c","chatgptUserId":"user-c"}'),
('historical-user-mismatch', 'codex', 'oauth', 'member@example.com',
'{"chatgptUserId":"current-user"}'),
('non-codex', 'openai', 'oauth', 'member@example.com',
'{"username":"member"}'),
('non-oauth', 'codex', 'access_token', 'member@example.com',
'{"chatgptUserId":"user-d"}'),
('malformed-key', 'codex', 'oauth', 'member@example.com',
'{"chatgptUserId":"user-e"}'),
('malformed-connection', 'codex', 'oauth', 'member@example.com',
'{bad json');
-- Historical usage written before account snapshots existed. The
-- literal weak keys model what the shipped migration 127 backfilled.
INSERT INTO usage_history (provider, connection_id, account_key, account_label) VALUES
('codex', 'promoted', '${weakKeys.promoted}', 'Old email label'),
('codex', 'user-only', '${weakKeys.userOnly}', 'Solo label'),
('codex', 'ambiguous-workspace', '${weakKeys.ambiguousWorkspace}', 'Workspace label'),
('codex', 'deleted-connection', '${weakKeys.deleted}', 'Exported orphan'),
('openai', 'non-codex', '${weakKeys.nonCodex}', 'Generic label'),
('codex', 'non-oauth', '${weakKeys.nonOauth}', 'Token label'),
('codex', 'malformed-key', '${weakKeys.malformedKey}', 'Malformed key label'),
('codex', 'malformed-connection', '${weakKeys.malformedConnection}', 'Malformed connection label'),
('codex', 'already-strong', '${strong(connections.alreadyStrong)}', 'Strong label'),
('codex', 'historical-user-mismatch', '${weakKeys.historicalUserMismatch}', 'Historical mismatch label');
`);
// Fresh installs run the original shipped 127 first, then 128. The 127
// backfill only touches rows without a snapshot, so the historical keys
// above survive unchanged into 128.
const applied = withMockedMigrationFs(
{
"127_usage_history_account_identity.sql": migration127,
"129_usage_history_codex_strong_identity.sql": migration128,
},
() => runner.runMigrations(db)
);
assert.equal(applied, 2);
const rows = db
.prepare("SELECT connection_id, account_key, account_label FROM usage_history ORDER BY id")
.all();
assert.deepEqual(rows, [
// The embedded historical user ID matches, but the current workspace
// does not prove the historical workspace. Keep the snapshot and label.
{
connection_id: "promoted",
account_key: weakKeys.promoted,
account_label: "Old email label",
},
// With no current workspace, the matching embedded user ID is sufficient
// even though the connection email changed since the snapshot.
{
connection_id: "user-only",
account_key: strong(connections.userOnly),
account_label: "Solo label",
},
// Same workspace and email, but no embedded user ID: the snapshot could
// belong to any workspace member, so it must stay weak.
{
connection_id: "ambiguous-workspace",
account_key: weakKeys.ambiguousWorkspace,
account_label: "Workspace label",
},
// The connection is gone; nothing can prove who generated the usage.
{
connection_id: "deleted-connection",
account_key: weakKeys.deleted,
account_label: "Exported orphan",
},
{
connection_id: "non-codex",
account_key: weakKeys.nonCodex,
account_label: "Generic label",
},
{
connection_id: "non-oauth",
account_key: weakKeys.nonOauth,
account_label: "Token label",
},
{
connection_id: "malformed-key",
account_key: weakKeys.malformedKey,
account_label: "Malformed key label",
},
{
connection_id: "malformed-connection",
account_key: weakKeys.malformedConnection,
account_label: "Malformed connection label",
},
{
connection_id: "already-strong",
account_key: strong(connections.alreadyStrong),
account_label: "Strong label",
},
// A historical embedded user that differs from the currently connected
// user cannot be safely promoted; both historical key and label remain.
{
connection_id: "historical-user-mismatch",
account_key: weakKeys.historicalUserMismatch,
account_label: "Historical mismatch label",
},
]);
// Rerunning 128 on an up-to-date database must not rewrite anything.
const rerun = withMockedMigrationFs(
{ "129_usage_history_codex_strong_identity.sql": migration128 },
() => runner.runMigrations(db)
);
assert.equal(rerun, 0);
assert.deepEqual(
db.prepare("SELECT account_key FROM usage_history ORDER BY id").all(),
rows.map(({ account_key }) => ({ account_key }))
);
} finally {
db.close();
}
});
test("migration 127 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(
@@ -181,31 +424,55 @@ test("migration 123 preserves exact dedup identity and ignores non-string JSON s
assert.deepEqual(rows, [
{
connection_id: "generic",
account_key: '["oauth","openai","email"," Member@example.com ","username"," Member "]',
account_key: resolveUsageAccountIdentity({
id: "generic",
provider: "openai",
authType: "oauth",
email: " Member@example.com ",
providerSpecificData: { username: " Member " },
}).accountKey,
},
{
connection_id: "workspace",
account_key: '["oauth","codex","workspace"," Workspace ","email"," Member@example.com "]',
account_key: resolveUsageAccountIdentity({
id: "workspace",
provider: "codex",
authType: "oauth",
email: " Member@example.com ",
providerSpecificData: { workspaceId: " Workspace " },
}).accountKey,
},
{
connection_id: "numeric-workspace",
account_key: '["connection","codex","numeric-workspace"]',
account_key: resolveOrphanedUsageAccountIdentity("codex", "numeric-workspace").accountKey,
},
{
connection_id: "object-user",
account_key: '["connection","codex","object-user"]',
account_key: resolveOrphanedUsageAccountIdentity("codex", "object-user").accountKey,
},
{
connection_id: "array-username",
account_key: '["oauth","openai","email","member@example.com"]',
account_key: resolveUsageAccountIdentity({
id: "array-username",
provider: "openai",
authType: "oauth",
email: "member@example.com",
providerSpecificData: { username: ["member"] },
}).accountKey,
},
{
connection_id: "malformed-json",
account_key: '["oauth","openai","email","member@example.com"]',
account_key: resolveUsageAccountIdentity({
id: "malformed-json",
provider: "openai",
authType: "oauth",
email: "member@example.com",
providerSpecificData: "{bad json",
}).accountKey,
},
{
connection_id: "",
account_key: '["connection","unknown","unknown"]',
account_key: resolveOrphanedUsageAccountIdentity("", "").accountKey,
},
]);
} finally {

View File

@@ -10,6 +10,16 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { runJsonMigration } = await import("../../src/lib/db/jsonMigration.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const exportRoute = await import("../../src/app/api/settings/export-json/route.ts");
test.beforeEach(() => {
const db = core.getDbInstance();
db.prepare("DELETE FROM usage_history").run();
db.prepare("DELETE FROM provider_connections").run();
db.prepare("DELETE FROM combos").run();
});
test.after(() => {
core.resetDbInstance();
@@ -18,19 +28,28 @@ test.after(() => {
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
});
test("runJsonMigration preserves exported account snapshots after a connection was deleted", () => {
test("runJsonMigration preserves exported snapshots and camelCase connection attribution", () => {
const db = core.getDbInstance();
runJsonMigration(db, {
providerConnections: [
{
id: "codex-connection",
provider: "codex",
authType: "oauth",
email: "current@example.com",
providerSpecificData: { workspaceId: "team", chatgptUserId: "user-a" },
},
],
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,
connectionId: "codex-connection",
accountKey: "stable-exported-account",
accountLabel: "Historical label",
accountLabelPriority: 4,
tokens_input: 10,
tokens_output: 5,
timestamp: "2026-01-01T00:00:00.000Z",
@@ -40,17 +59,59 @@ test("runJsonMigration preserves exported account snapshots after a connection w
const row = db
.prepare(
"SELECT account_key, account_label, account_label_priority FROM usage_history WHERE id = 1"
`SELECT connection_id, account_key, account_label, account_label_priority,
tokens_input + tokens_output total_tokens
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",
connection_id: "codex-connection",
account_key: "stable-exported-account",
account_label: "Historical label",
account_label_priority: 4,
total_tokens: 15,
});
});
test("usage snapshots survive an export, connection deletion, and import round trip", async () => {
const db = core.getDbInstance();
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "member@example.com",
displayName: "Production Codex",
providerSpecificData: { workspaceId: "team", chatgptUserId: "user-a" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: connection.id as string,
tokens: { input: 10, output: 5 },
timestamp: "2026-02-01T00:00:00.000Z",
});
const response = await exportRoute.GET(
new Request("http://localhost/api/settings/export-json?includeHistory=true")
);
assert.equal(response.status, 200);
const exported = await response.json();
await providersDb.deleteProviderConnection(connection.id as string);
db.prepare("DELETE FROM usage_history").run();
db.prepare("DELETE FROM provider_connections").run();
runJsonMigration(db, exported);
const restored = db
.prepare(
`SELECT COUNT(*) requests, SUM(tokens_input + tokens_output) tokens,
MAX(account_label) label
FROM usage_history WHERE connection_id = ?`
)
.get(connection.id);
assert.deepEqual(restored, { requests: 1, tokens: 15, label: "Production Codex" });
});
test("runJsonMigration normalizes legacy combo strategy names at the import boundary", () => {
const db = core.getDbInstance();

View File

@@ -0,0 +1,390 @@
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-account-analytics-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-account-analytics-secret";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const { resolveUsageAccountIdentity } = await import("../../src/lib/usage/accountIdentity.ts");
const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts");
function makeRequest() {
return new Request("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z");
}
async function readAnalytics() {
const response = await analyticsRoute.GET(makeRequest());
assert.equal(response.status, 200);
return (await response.json()) as {
summary: Record<string, unknown>;
byAccount: Array<Record<string, unknown>>;
};
}
async function readAccounts() {
return (await readAnalytics()).byAccount;
}
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
usageHistory.clearPendingRequests();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("Codex account grouping follows workspace and user identity, not email", async () => {
await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } });
const specs = [
{ workspaceId: "workspace-a", chatgptUserId: "user-a", email: "old@example.com" },
{ workspaceId: "workspace-a", chatgptUserId: "user-b", email: "old@example.com" },
{ workspaceId: "workspace-b", chatgptUserId: "user-a", email: "old@example.com" },
];
for (const [index, spec] of specs.entries()) {
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: spec.email,
displayName: `Account ${index + 1}`,
providerSpecificData: spec,
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: connection.id as string,
tokens: { input: 1000, output: 500 },
timestamp: `2026-01-0${index + 1}T00:00:00.000Z`,
});
}
const sameUser = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "new@example.com",
displayName: "Account 1 renamed",
providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-a" },
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: sameUser.id as string,
tokens: { input: 1000, output: 500 },
timestamp: "2026-01-04T00:00:00.000Z",
});
const accounts = await readAccounts();
assert.equal(accounts.length, 3);
assert.deepEqual(accounts.map((account) => [account.account, account.requests]).sort(), [
["Account 1 renamed", 2],
["Account 2", 1],
["Account 3", 1],
]);
assert.equal(
accounts.reduce((sum, account) => sum + Number(account.cost), 0),
0.008
);
});
test("updating an established Codex user or workspace does not reattribute earlier usage", async () => {
const changes = [
{
name: "user boundary",
before: { workspaceId: "workspace-user", chatgptUserId: "user-a" },
after: { workspaceId: "workspace-user", chatgptUserId: "user-b" },
},
{
name: "workspace boundary",
before: { workspaceId: "workspace-a", chatgptUserId: "stable-user" },
after: { workspaceId: "workspace-b", chatgptUserId: "stable-user" },
},
];
for (const [index, change] of changes.entries()) {
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
displayName: `${change.name} before`,
providerSpecificData: change.before,
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: connection.id as string,
tokens: { input: 10, output: 5 },
timestamp: `2026-02-0${index + 1}T00:00:00.000Z`,
});
await providersDb.updateProviderConnection(connection.id as string, {
displayName: `${change.name} after`,
providerSpecificData: change.after,
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: connection.id as string,
tokens: { input: 20, output: 10 },
timestamp: `2026-02-1${index + 1}T00:00:00.000Z`,
});
}
const accounts = await readAccounts();
assert.deepEqual(
accounts.map((account) => [account.account, account.requests, account.totalTokens]).sort(),
[
["user boundary after", 1, 30],
["user boundary before", 1, 15],
["workspace boundary after", 1, 30],
["workspace boundary before", 1, 15],
]
);
});
test("deleting and recreating the same Codex account preserves one historical account", async () => {
await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } });
const identity = {
workspaceId: "workspace-internal-recreated",
chatgptUserId: "user-internal-recreated",
};
const original = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "member@example.com",
displayName: "Production Codex <member@example.com>",
providerSpecificData: identity,
});
await usageHistory.saveRequestUsage({
provider: "codex",
model: "gpt-5.5",
connectionId: original.id as string,
tokens: { input: 1000, output: 500 },
timestamp: "2026-03-01T00:00:00.000Z",
});
assert.equal(await providersDb.deleteProviderConnection(original.id as string), true);
const recreated = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "member@example.com",
providerSpecificData: identity,
});
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-03-02T00:00:00.000Z",
});
const analytics = await readAnalytics();
assert.deepEqual(
analytics.byAccount.map((account) => ({
account: account.account,
requests: account.requests,
promptTokens: account.promptTokens,
completionTokens: account.completionTokens,
totalTokens: account.totalTokens,
cost: account.cost,
})),
[
{
account: "Production Codex <member@example.com>",
requests: 2,
promptTokens: 2000,
completionTokens: 1000,
totalTokens: 3000,
cost: 0.004,
},
]
);
const serialized = JSON.stringify(analytics);
const { accountKey } = resolveUsageAccountIdentity({
id: recreated.id,
provider: "codex",
authType: "oauth",
email: "member@example.com",
providerSpecificData: identity,
});
for (const privateValue of [
accountKey,
identity.workspaceId,
identity.chatgptUserId,
recreated.id as string,
]) {
assert.equal(serialized.includes(privateValue), false);
}
});
test("account cost and usage share the same trimmed legacy connection fallback", async () => {
await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } });
const timestamp = new Date().toISOString();
core
.getDbInstance()
.prepare(
`INSERT INTO usage_history
(provider, model, connection_id, account_label, account_label_priority,
tokens_input, tokens_output, success, timestamp)
VALUES ('codex', 'gpt-5.5', ' legacy-id ', 'Legacy Codex', 4,
1000, 500, 1, ?)`
)
.run(timestamp);
const analytics = await readAnalytics();
assert.equal(analytics.byAccount.length, 1);
assert.deepEqual(
{
account: analytics.byAccount[0].account,
requests: analytics.byAccount[0].requests,
totalTokens: analytics.byAccount[0].totalTokens,
cost: analytics.byAccount[0].cost,
},
{ account: "Legacy Codex", requests: 1, totalTokens: 1500, cost: 0.002 }
);
assert.equal(analytics.summary.totalCost, analytics.byAccount[0].cost);
});
test("analytics prefers the highest-priority historical label and reports blank orphan IDs as unknown", async () => {
await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } });
const db = core.getDbInstance();
const insert = db.prepare(`INSERT INTO usage_history
(provider, model, connection_id, account_key, account_label, account_label_priority,
tokens_input, tokens_output, success, timestamp)
VALUES (?, 'gpt-5.5', ?, ?, ?, ?, 10, 5, 1, ?)`);
insert.run(
"codex",
"old-uuid",
"stable-account",
" Production Codex ",
4,
"2026-01-01T00:00:00.000Z"
);
insert.run("codex", "new-uuid", "stable-account", " ", 5, "2026-01-02T00:00:00.000Z");
insert.run("codex", " ", null, null, 0, "2026-01-03T00:00:00.000Z");
const analytics = await readAnalytics();
assert.deepEqual(
analytics.byAccount.map((account) => [
account.account,
account.requests,
account.totalTokens,
account.cost,
]),
[
["Production Codex", 2, 30, 0.00004],
["unknown", 1, 15, 0.00002],
]
);
});
test("generic provider and Codex workspace identities stay distinct under a shared email", async () => {
const accountSpecs = [
{ provider: "codex", providerSpecificData: { workspaceId: "workspace-a" } },
{ provider: "codex", providerSpecificData: { workspaceId: "workspace-b" } },
{ provider: "openai", providerSpecificData: {} },
];
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.providerSpecificData,
});
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 accounts = await readAccounts();
// Three separate accounts share one email label; grouping must not collapse
// them because workspace and provider identities differ.
assert.equal(accounts.length, 3);
assert.deepEqual(accounts.map((account) => account.account).sort(), [
"shared@example.com",
"shared@example.com",
"shared@example.com",
]);
assert.deepEqual(accounts.map((account) => account.requests).sort(), [1, 1, 1]);
});
test("a nonblank orphaned connection ID stays a truthful fallback label", async () => {
core
.getDbInstance()
.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 accounts = await readAccounts();
assert.equal(accounts.length, 1);
assert.equal(accounts[0].account, "deleted-legacy-uuid");
});
test("the newest label wins for one account at equal priority", async () => {
const db = core.getDbInstance();
const accountKey = resolveUsageAccountIdentity({
id: "new-uuid",
provider: "codex",
authType: "oauth",
email: "member@example.com",
providerSpecificData: { chatgptUserId: "user-a" },
}).accountKey;
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 analytics = await readAnalytics();
assert.equal(analytics.summary.uniqueAccounts, 1);
assert.equal(analytics.byAccount.length, 1);
assert.equal(analytics.byAccount[0].account, "Alpha after rename");
assert.equal(analytics.byAccount[0].requests, 2);
});

View File

@@ -21,7 +21,16 @@ test("Codex chatgptUserId identity is stable across UUID and label changes", ()
providerSpecificData: { chatgptUserId: "user-production" },
});
const emailChanged = resolveUsageAccountIdentity({
id: "third-uuid",
provider: "codex",
authType: "oauth",
email: "renamed@example.com",
providerSpecificData: { chatgptUserId: "user-production" },
});
assert.equal(first.accountKey, recreated.accountKey);
assert.equal(first.accountKey, emailChanged.accountKey);
assert.equal(first.accountLabel, "Production Codex");
assert.equal(recreated.accountLabel, "member@example.com");
});

View File

@@ -86,225 +86,6 @@ test.after(() => {
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();