Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
01ab5d1
chore(ci): add .mergify.yml to main — Mergify only reads config from …
diegosouzapw Jul 14, 2026
fb24740
fix(ci): add the auto-enqueue pull_request_rule to the Mergify config…
diegosouzapw Jul 14, 2026
fb25ebd
fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.a…
diegosouzapw Jul 15, 2026
a795694
fix(ci): drop Mergify batch settings (batching is a paid-tier feature…
diegosouzapw Jul 15, 2026
9875ccf
fix(ci): merge queue tolerates the advisory dast-smoke failure (its G…
diegosouzapw Jul 15, 2026
0065f1b
test(ci): make the #6634 selfref guard hermetic — main's copy hard-fa…
diegosouzapw Jul 16, 2026
ddd6d09
chore(quality): tighten main's coverage baseline to the CI's real num…
diegosouzapw Jul 16, 2026
66c56ec
Add cliproxy provider exposure controls and manifest injection (#7329)
KooshaPari Jul 17, 2026
8e383f5
test(ci): static body in codex e2e mock route bridge (CodeQL #737) (#…
diegosouzapw Jul 17, 2026
025c053
fix(usage): preserve account identity history
xz-dev Jul 18, 2026
9f10f6b
merge: reconcile release/v3.8.49 into fix(usage): preserve account id…
diegosouzapw Jul 19, 2026
9c3b34a
fix(db): renumber usage-identity migration to 127 (collides with 123_…
diegosouzapw Jul 19, 2026
18b6902
Merge remote-tracking branch 'origin/release/v3.8.49' into HEAD
diegosouzapw Jul 19, 2026
4885f44
test(db,api): split test files to satisfy file-size gate for account …
xz-dev Jul 19, 2026
a0b0763
Merge remote-tracking branch 'origin/release/v3.8.49' into HEAD
diegosouzapw Jul 20, 2026
ad97193
chore(quality): suppress pre-existing any in split db-migration-runne…
xz-dev Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/usage/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,15 +673,15 @@ export async function GET(request: Request) {

const accountCostByAccount = new Map<string, number>();
for (const row of accountCostRows) {
const account = toStringValue(row.account, "unknown");
const accountKey = toStringValue(row.accountKey, "unknown");
const cost = computeUsageRowCost(
row,
pricingByProvider,
PROVIDER_ID_TO_ALIAS,
normalizeModelName,
computeCostFromPricing
);
accountCostByAccount.set(account, (accountCostByAccount.get(account) || 0) + cost);
accountCostByAccount.set(accountKey, (accountCostByAccount.get(accountKey) || 0) + cost);
}

const byAccount = accountRows.map((row) => ({
Expand All @@ -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<
Expand Down
5 changes: 5 additions & 0 deletions src/lib/db/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
* All domain modules import `getDbInstance` and helpers from here.
*/

import type { SqliteAdapter } from "./adapters/types";

Check failure on line 7 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './adapters/types.js'?
import {
tryOpenSync,
getSqlJsAdapter,
preInitSqlJs,
getSqlJsPreInitError,
openDatabaseAsync,
} from "./adapters/driverFactory";

Check failure on line 14 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './adapters/driverFactory.js'?
import path from "path";
import fs from "fs";
import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths";

Check failure on line 17 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../dataPaths.js'?
import { runMigrations } from "./migrationRunner";

Check failure on line 18 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './migrationRunner.js'?
import { runDbHealthCheck } from "./healthCheck";

Check failure on line 19 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './healthCheck.js'?
import { resetAllDbModuleState } from "./stateReset";

Check failure on line 20 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './stateReset.js'?
import { parseStoredPayload } from "../logPayloads";

Check failure on line 21 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../logPayloads.js'?
import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings";

Check failure on line 22 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Cannot find module '@/types/databaseSettings' or its corresponding type declarations.
import {
applyDatabaseOptimizationSettingsForDb,
applyStoredDatabaseOptimizationSettings,
Expand All @@ -41,6 +41,7 @@
export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping";
import {
ensureProviderConnectionsColumns,
ensureUsageHistoryAccountIndex,
ensureUsageHistoryColumns,
ensureCallLogsColumns,
hasTable,
Expand Down Expand Up @@ -302,6 +303,9 @@
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,
Expand Down Expand Up @@ -958,6 +962,7 @@
memoryDb.pragma("journal_mode = WAL");
memoryDb.exec(SCHEMA_SQL);
ensureUsageHistoryColumns(memoryDb);
ensureUsageHistoryAccountIndex(memoryDb);
ensureCallLogsColumns(memoryDb);
ensureProviderConnectionsColumns(memoryDb);
setDb(memoryDb);
Expand Down
30 changes: 24 additions & 6 deletions src/lib/db/jsonMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions src/lib/db/migrations/127_usage_history_account_identity.sql
Original file line number Diff line number Diff line change
@@ -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);
33 changes: 32 additions & 1 deletion src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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,
Expand All @@ -38,6 +39,7 @@ interface StatementLike<TRow = unknown> {

interface DbLike {
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
transaction: <T>(fn: () => T) => () => T;
}

// Real column set for provider_connections (must match the CREATE TABLE in
Expand Down Expand Up @@ -210,6 +212,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
Expand All @@ -235,6 +238,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
Expand Down Expand Up @@ -337,7 +341,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(
Expand Down
33 changes: 33 additions & 0 deletions src/lib/db/schemaColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading