style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings
This commit is contained in:
1 parent
b80531f8a8
commit
bac7b5591e
738 files changed
+138513
-138123
No files matched your search
+1551
-1551
File diff suppressed because it is too large.
Load diff
@@ -1,34 +1,34 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
|
||||
export interface DbConfigOptions {
|
||||
readOnly?: boolean;
|
||||
busyTimeoutMs?: number;
|
||||
enableWal?: boolean;
|
||||
}
|
||||
|
||||
export interface DrizzleConnection {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof schema>;
|
||||
}
|
||||
|
||||
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||
const enableWal = options.enableWal ?? !options.readOnly;
|
||||
if (enableWal) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
}
|
||||
|
||||
export function openDrizzleConnection(
|
||||
dbPath: string,
|
||||
options: Database.Options = {},
|
||||
config: DbConfigOptions = {},
|
||||
): DrizzleConnection {
|
||||
const raw = new Database(dbPath, options);
|
||||
configureDatabase(raw, config);
|
||||
const orm = drizzle(raw, { schema });
|
||||
return { raw, orm };
|
||||
}
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
|
||||
export interface DbConfigOptions {
|
||||
readOnly?: boolean;
|
||||
busyTimeoutMs?: number;
|
||||
enableWal?: boolean;
|
||||
}
|
||||
|
||||
export interface DrizzleConnection {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof schema>;
|
||||
}
|
||||
|
||||
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||
const enableWal = options.enableWal ?? !options.readOnly;
|
||||
if (enableWal) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
}
|
||||
|
||||
export function openDrizzleConnection(
|
||||
dbPath: string,
|
||||
options: Database.Options = {},
|
||||
config: DbConfigOptions = {},
|
||||
): DrizzleConnection {
|
||||
const raw = new Database(dbPath, options);
|
||||
configureDatabase(raw, config);
|
||||
const orm = drizzle(raw, { schema });
|
||||
return { raw, orm };
|
||||
}
|
||||
+349
-349
@@ -1,349 +1,349 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isString } from 'lodash-es';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
return openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: readOnly, fileMustExist: false },
|
||||
{ readOnly, busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database and ensures WAL mode is enabled.
|
||||
* Should be called on application startup.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw } = getDatabaseConnection();
|
||||
raw.close();
|
||||
logger.info('Database initialized and verified (WAL mode)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize database on startup', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return openIdeDb(targetPath);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readItemValue(
|
||||
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||
key: string,
|
||||
context: string,
|
||||
): string | null {
|
||||
const rows = orm
|
||||
.select({ value: itemTable.value })
|
||||
.from(itemTable)
|
||||
.where(eq(itemTable.key, key))
|
||||
.all();
|
||||
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
try {
|
||||
authStatus = JSON.parse(authValue);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initValue = readItemValue(
|
||||
orm,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
let initState = null;
|
||||
if (initValue) {
|
||||
try {
|
||||
initState = JSON.parse(initValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
try {
|
||||
googleState = JSON.parse(googleValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (isString(obj.email)) return obj.email;
|
||||
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||
if (value) {
|
||||
try {
|
||||
data[key] = JSON.parse(value);
|
||||
} catch {
|
||||
data[key] = value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
|
||||
try {
|
||||
connection = getDatabaseConnection(dbPath);
|
||||
const { orm } = connection;
|
||||
orm.transaction((tx) => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
target: itemTable.key,
|
||||
set: { value: stringValue },
|
||||
})
|
||||
.run();
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isString } from 'lodash-es';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
return openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: readOnly, fileMustExist: false },
|
||||
{ readOnly, busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database and ensures WAL mode is enabled.
|
||||
* Should be called on application startup.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw } = getDatabaseConnection();
|
||||
raw.close();
|
||||
logger.info('Database initialized and verified (WAL mode)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize database on startup', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return openIdeDb(targetPath);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readItemValue(
|
||||
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||
key: string,
|
||||
context: string,
|
||||
): string | null {
|
||||
const rows = orm
|
||||
.select({ value: itemTable.value })
|
||||
.from(itemTable)
|
||||
.where(eq(itemTable.key, key))
|
||||
.all();
|
||||
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
try {
|
||||
authStatus = JSON.parse(authValue);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initValue = readItemValue(
|
||||
orm,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
let initState = null;
|
||||
if (initValue) {
|
||||
try {
|
||||
initState = JSON.parse(initValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
try {
|
||||
googleState = JSON.parse(googleValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (isString(obj.email)) return obj.email;
|
||||
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||
if (value) {
|
||||
try {
|
||||
data[key] = JSON.parse(value);
|
||||
} catch {
|
||||
data[key] = value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
|
||||
try {
|
||||
connection = getDatabaseConnection(dbPath);
|
||||
const { orm } = connection;
|
||||
orm.transaction((tx) => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
target: itemTable.key,
|
||||
set: { value: stringValue },
|
||||
})
|
||||
.run();
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,452 +1,452 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -1,24 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
+77
-77
@@ -1,77 +1,77 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
tokenJson: text('token_json').notNull(),
|
||||
quotaJson: text('quota_json'),
|
||||
deviceProfileJson: text('device_profile_json'),
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
tokenJson: text('token_json').notNull(),
|
||||
quotaJson: text('quota_json'),
|
||||
deviceProfileJson: text('device_profile_json'),
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
+274
-274
@@ -1,274 +1,274 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user