mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
refactor: decompose usageDb, handleSingleModelChat, UI components (T-15, T-28, T-29)
T-15 — Decompose usageDb.js (969→40 lines): - Extract src/lib/usage/migrations.js (legacy + JSON→SQLite migration) - Extract src/lib/usage/usageHistory.js (tracking, pending, log.txt) - Extract src/lib/usage/costCalculator.js (pure cost calculation) - Extract src/lib/usage/usageStats.js (dashboard aggregation) - Extract src/lib/usage/callLogs.js (structured logs, CRUD, rotation) - usageDb.js is now a thin facade re-exporting all functions T-28 — Decompose handleSingleModelChat (183→80 lines): - Extract handleNoCredentials() — credential error responses - Extract safeResolveProxy() — proxy resolution with error handling - Extract safeLogEvents() — fire-and-forget proxy + translation logging - Also created chatHelpers.js with standalone helper exports T-29 — Extract shared UI primitives (3230 total lines): - FilterBar.js — search input + filter chips dropdown - ColumnToggle.js — table column visibility toggle - DataTable.js — generic data table with sticky header, loading/empty Tests: 88/88 pass (no regressions)
This commit is contained in:
339
src/lib/usage/callLogs.js
Normal file
339
src/lib/usage/callLogs.js
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Call Logs — extracted from usageDb.js (T-15)
|
||||
*
|
||||
* Structured call log management: save, query, rotate, and
|
||||
* full-payload disk storage for the Logger UI.
|
||||
*
|
||||
* @module lib/usage/callLogs
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { getDbInstance } from "../db/core.js";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations.js";
|
||||
|
||||
const CALL_LOGS_MAX = 500;
|
||||
|
||||
let logIdCounter = 0;
|
||||
function generateLogId() {
|
||||
logIdCounter++;
|
||||
return `${Date.now()}-${logIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a structured call log entry.
|
||||
*/
|
||||
export async function saveCallLog(entry) {
|
||||
if (!shouldPersistToDisk) return;
|
||||
|
||||
try {
|
||||
// Resolve account name
|
||||
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
|
||||
try {
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
const connections = await getProviderConnections();
|
||||
const conn = connections.find((c) => c.id === entry.connectionId);
|
||||
if (conn) account = conn.name || conn.email || account;
|
||||
} catch {}
|
||||
|
||||
// Truncate large payloads for DB storage (keep under 8KB each)
|
||||
const truncatePayload = (obj) => {
|
||||
if (!obj) return null;
|
||||
const str = JSON.stringify(obj);
|
||||
if (str.length <= 8192) return str;
|
||||
try {
|
||||
return JSON.stringify({
|
||||
_truncated: true,
|
||||
_originalSize: str.length,
|
||||
_preview: str.slice(0, 8192) + "...",
|
||||
});
|
||||
} catch {
|
||||
return JSON.stringify({ _truncated: true });
|
||||
}
|
||||
};
|
||||
|
||||
const logEntry = {
|
||||
id: generateLogId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
method: entry.method || "POST",
|
||||
path: entry.path || "/v1/chat/completions",
|
||||
status: entry.status || 0,
|
||||
model: entry.model || "-",
|
||||
provider: entry.provider || "-",
|
||||
account,
|
||||
connectionId: entry.connectionId || null,
|
||||
duration: entry.duration || 0,
|
||||
tokensIn: entry.tokens?.prompt_tokens || 0,
|
||||
tokensOut: entry.tokens?.completion_tokens || 0,
|
||||
sourceFormat: entry.sourceFormat || null,
|
||||
targetFormat: entry.targetFormat || null,
|
||||
apiKeyId: entry.apiKeyId || null,
|
||||
apiKeyName: entry.apiKeyName || null,
|
||||
comboName: entry.comboName || null,
|
||||
requestBody: truncatePayload(entry.requestBody),
|
||||
responseBody: truncatePayload(entry.responseBody),
|
||||
error: entry.error || null,
|
||||
};
|
||||
|
||||
// 1. Insert into SQLite
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
|
||||
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
|
||||
api_key_id, api_key_name, combo_name, request_body, response_body, error)
|
||||
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
|
||||
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
|
||||
`
|
||||
).run(logEntry);
|
||||
|
||||
// 2. Trim old entries beyond CALL_LOGS_MAX
|
||||
const count = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get()?.cnt || 0;
|
||||
if (count > CALL_LOGS_MAX) {
|
||||
db.prepare(
|
||||
`
|
||||
DELETE FROM call_logs WHERE id IN (
|
||||
SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?
|
||||
)
|
||||
`
|
||||
).run(count - CALL_LOGS_MAX);
|
||||
}
|
||||
|
||||
// 3. Write full payload to disk file (untruncated)
|
||||
writeCallLogToDisk(
|
||||
{ ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } },
|
||||
entry.requestBody,
|
||||
entry.responseBody
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[callLogs] Failed to save call log:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write call log as JSON file to disk (full payloads, not truncated).
|
||||
*/
|
||||
function writeCallLogToDisk(logEntry, requestBody, responseBody) {
|
||||
if (!CALL_LOGS_DIR) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateFolder = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
const dir = path.join(CALL_LOGS_DIR, dateFolder);
|
||||
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const safeModel = (logEntry.model || "unknown").replace(/[/:]/g, "-");
|
||||
const time = `${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
const filename = `${time}_${safeModel}_${logEntry.status}.json`;
|
||||
|
||||
const fullEntry = {
|
||||
...logEntry,
|
||||
requestBody: requestBody || null,
|
||||
responseBody: responseBody || null,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, filename), JSON.stringify(fullEntry, null, 2));
|
||||
} catch (err) {
|
||||
console.error("[callLogs] Failed to write disk log:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate old call log directories (keep last 7 days).
|
||||
*/
|
||||
export function rotateCallLogs() {
|
||||
if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(CALL_LOGS_DIR);
|
||||
const now = Date.now();
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(CALL_LOGS_DIR, entry);
|
||||
const stat = fs.statSync(entryPath);
|
||||
if (stat.isDirectory() && now - stat.mtimeMs > sevenDays) {
|
||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
||||
console.log(`[callLogs] Rotated old logs: ${entry}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[callLogs] Failed to rotate logs:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Run rotation on startup
|
||||
if (shouldPersistToDisk) {
|
||||
try {
|
||||
rotateCallLogs();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call logs with optional filtering.
|
||||
*/
|
||||
export async function getCallLogs(filter = {}) {
|
||||
const db = getDbInstance();
|
||||
let sql = "SELECT * FROM call_logs";
|
||||
const conditions = [];
|
||||
const params = {};
|
||||
|
||||
if (filter.status) {
|
||||
if (filter.status === "error") {
|
||||
conditions.push("(status >= 400 OR error IS NOT NULL)");
|
||||
} else if (filter.status === "ok") {
|
||||
conditions.push("status >= 200 AND status < 300");
|
||||
} else {
|
||||
const statusCode = parseInt(filter.status);
|
||||
if (!isNaN(statusCode)) {
|
||||
conditions.push("status = @statusCode");
|
||||
params.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.model) {
|
||||
conditions.push("model LIKE @modelQ");
|
||||
params.modelQ = `%${filter.model}%`;
|
||||
}
|
||||
if (filter.provider) {
|
||||
conditions.push("provider LIKE @providerQ");
|
||||
params.providerQ = `%${filter.provider}%`;
|
||||
}
|
||||
if (filter.account) {
|
||||
conditions.push("account LIKE @accountQ");
|
||||
params.accountQ = `%${filter.account}%`;
|
||||
}
|
||||
if (filter.apiKey) {
|
||||
conditions.push("(api_key_name LIKE @apiKeyQ OR api_key_id LIKE @apiKeyQ)");
|
||||
params.apiKeyQ = `%${filter.apiKey}%`;
|
||||
}
|
||||
if (filter.combo) {
|
||||
conditions.push("combo_name IS NOT NULL");
|
||||
}
|
||||
if (filter.search) {
|
||||
conditions.push(`(
|
||||
model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR
|
||||
provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
|
||||
combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ
|
||||
)`);
|
||||
params.searchQ = `%${filter.search}%`;
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
|
||||
const limit = filter.limit || 200;
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ${limit}`;
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
|
||||
return rows.map((l) => ({
|
||||
id: l.id,
|
||||
timestamp: l.timestamp,
|
||||
method: l.method,
|
||||
path: l.path,
|
||||
status: l.status,
|
||||
model: l.model,
|
||||
provider: l.provider,
|
||||
account: l.account,
|
||||
duration: l.duration,
|
||||
tokens: { in: l.tokens_in, out: l.tokens_out },
|
||||
sourceFormat: l.source_format,
|
||||
targetFormat: l.target_format,
|
||||
error: l.error,
|
||||
comboName: l.combo_name || null,
|
||||
apiKeyId: l.api_key_id || null,
|
||||
apiKeyName: l.api_key_name || null,
|
||||
hasRequestBody: !!l.request_body,
|
||||
hasResponseBody: !!l.response_body,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single call log by ID (with full payloads from disk when available).
|
||||
*/
|
||||
export async function getCallLogById(id) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM call_logs WHERE id = ?").get(id);
|
||||
if (!row) return null;
|
||||
|
||||
const entry = {
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
status: row.status,
|
||||
model: row.model,
|
||||
provider: row.provider,
|
||||
account: row.account,
|
||||
connectionId: row.connection_id,
|
||||
duration: row.duration,
|
||||
tokens: { in: row.tokens_in, out: row.tokens_out },
|
||||
sourceFormat: row.source_format,
|
||||
targetFormat: row.target_format,
|
||||
apiKeyId: row.api_key_id,
|
||||
apiKeyName: row.api_key_name,
|
||||
comboName: row.combo_name,
|
||||
requestBody: row.request_body ? JSON.parse(row.request_body) : null,
|
||||
responseBody: row.response_body ? JSON.parse(row.response_body) : null,
|
||||
error: row.error,
|
||||
};
|
||||
|
||||
// If payloads were truncated, try to read full version from disk
|
||||
const needsDisk = entry.requestBody?._truncated || entry.responseBody?._truncated;
|
||||
if (needsDisk && CALL_LOGS_DIR) {
|
||||
try {
|
||||
const diskEntry = readFullLogFromDisk(entry);
|
||||
if (diskEntry) {
|
||||
return {
|
||||
...entry,
|
||||
requestBody: diskEntry.requestBody ?? entry.requestBody,
|
||||
responseBody: diskEntry.responseBody ?? entry.responseBody,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[callLogs] Failed to read full log from disk:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full (untruncated) log entry from disk.
|
||||
*/
|
||||
function readFullLogFromDisk(entry) {
|
||||
if (!CALL_LOGS_DIR || !entry.timestamp) return null;
|
||||
|
||||
try {
|
||||
const date = new Date(entry.timestamp);
|
||||
const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const dir = path.join(CALL_LOGS_DIR, dateFolder);
|
||||
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
|
||||
const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}${String(date.getSeconds()).padStart(2, "0")}`;
|
||||
const safeModel = (entry.model || "unknown").replace(/[/:]/g, "-");
|
||||
const expectedName = `${time}_${safeModel}_${entry.status}.json`;
|
||||
|
||||
const exactPath = path.join(dir, expectedName);
|
||||
if (fs.existsSync(exactPath)) {
|
||||
return JSON.parse(fs.readFileSync(exactPath, "utf8"));
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith(time) && f.endsWith(`_${entry.status}.json`));
|
||||
if (files.length > 0) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[callLogs] Disk log read error:", err.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
56
src/lib/usage/costCalculator.js
Normal file
56
src/lib/usage/costCalculator.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Cost Calculator — extracted from usageDb.js (T-15)
|
||||
*
|
||||
* Pure function for calculating request cost based on model pricing.
|
||||
* No DB interaction — pricing is fetched from localDb.
|
||||
*
|
||||
* @module lib/usage/costCalculator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate cost for a usage entry.
|
||||
*
|
||||
* @param {string} provider
|
||||
* @param {string} model
|
||||
* @param {Object} tokens
|
||||
* @returns {Promise<number>} Cost in USD
|
||||
*/
|
||||
export async function calculateCost(provider, model, tokens) {
|
||||
if (!tokens || !provider || !model) return 0;
|
||||
|
||||
try {
|
||||
const { getPricingForModel } = await import("@/lib/localDb.js");
|
||||
const pricing = await getPricingForModel(provider, model);
|
||||
if (!pricing) return 0;
|
||||
|
||||
let cost = 0;
|
||||
|
||||
const inputTokens = tokens.input ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0;
|
||||
const cachedTokens =
|
||||
tokens.cacheRead ?? tokens.cached_tokens ?? tokens.cache_read_input_tokens ?? 0;
|
||||
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
|
||||
cost += nonCachedInput * (pricing.input / 1000000);
|
||||
|
||||
if (cachedTokens > 0) {
|
||||
cost += cachedTokens * ((pricing.cached || pricing.input) / 1000000);
|
||||
}
|
||||
|
||||
const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0;
|
||||
cost += outputTokens * (pricing.output / 1000000);
|
||||
|
||||
const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0;
|
||||
if (reasoningTokens > 0) {
|
||||
cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000);
|
||||
}
|
||||
|
||||
const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0;
|
||||
if (cacheCreationTokens > 0) {
|
||||
cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000);
|
||||
}
|
||||
|
||||
return cost;
|
||||
} catch (error) {
|
||||
console.error("Error calculating cost:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
186
src/lib/usage/migrations.js
Normal file
186
src/lib/usage/migrations.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Usage Migrations — extracted from usageDb.js (T-15)
|
||||
*
|
||||
* Handles legacy file migration (.data → data/) and JSON → SQLite migration.
|
||||
* Runs automatically on module load when shouldPersistToDisk is true.
|
||||
*
|
||||
* @module lib/usage/migrations
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { getDbInstance, isCloud, isBuildPhase, DATA_DIR } from "../db/core.js";
|
||||
import { getLegacyDotDataDir, isSamePath } from "../dataPaths.js";
|
||||
|
||||
export const shouldPersistToDisk = !isCloud && !isBuildPhase;
|
||||
|
||||
// ──────────────── File Paths ────────────────
|
||||
|
||||
const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir();
|
||||
|
||||
export const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");
|
||||
export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
|
||||
|
||||
// Legacy paths
|
||||
const LEGACY_DB_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "usage.json");
|
||||
const LEGACY_LOG_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "log.txt");
|
||||
const LEGACY_CALL_LOGS_DB_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs.json");
|
||||
const LEGACY_CALL_LOGS_DIR =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs");
|
||||
|
||||
// Current-location JSON files (for migration into SQLite)
|
||||
const USAGE_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json");
|
||||
const CALL_LOGS_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "call_logs.json");
|
||||
|
||||
// ──────────────── Legacy File Migration ────────────────
|
||||
|
||||
function copyIfMissing(fromPath, toPath, label) {
|
||||
if (!fromPath || !toPath) return;
|
||||
if (!fs.existsSync(fromPath) || fs.existsSync(toPath)) return;
|
||||
|
||||
if (fs.statSync(fromPath).isDirectory()) {
|
||||
fs.cpSync(fromPath, toPath, { recursive: true });
|
||||
} else {
|
||||
fs.copyFileSync(fromPath, toPath);
|
||||
}
|
||||
console.log(`[usageDb] Migrated ${label}: ${fromPath} -> ${toPath}`);
|
||||
}
|
||||
|
||||
export function migrateLegacyUsageFiles() {
|
||||
if (!shouldPersistToDisk || !LEGACY_DATA_DIR) return;
|
||||
if (isSamePath(DATA_DIR, LEGACY_DATA_DIR)) return;
|
||||
|
||||
try {
|
||||
copyIfMissing(LEGACY_DB_FILE, USAGE_JSON_FILE, "usage history");
|
||||
copyIfMissing(LEGACY_LOG_FILE, LOG_FILE, "request log");
|
||||
copyIfMissing(LEGACY_CALL_LOGS_DB_FILE, CALL_LOGS_JSON_FILE, "call log index");
|
||||
copyIfMissing(LEGACY_CALL_LOGS_DIR, CALL_LOGS_DIR, "call log files");
|
||||
} catch (error) {
|
||||
console.error("[usageDb] Legacy migration failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── JSON → SQLite Migration ────────────────
|
||||
|
||||
export function migrateUsageJsonToSqlite() {
|
||||
if (!shouldPersistToDisk) return;
|
||||
const db = getDbInstance();
|
||||
|
||||
// 1. Migrate usage.json
|
||||
if (USAGE_JSON_FILE && fs.existsSync(USAGE_JSON_FILE)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(USAGE_JSON_FILE, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const history = data.history || [];
|
||||
|
||||
if (history.length > 0) {
|
||||
console.log(`[usageDb] Migrating ${history.length} usage entries from JSON → SQLite...`);
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
status, timestamp)
|
||||
VALUES (@provider, @model, @connectionId, @apiKeyId, @apiKeyName,
|
||||
@tokensInput, @tokensOutput, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning,
|
||||
@status, @timestamp)
|
||||
`);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const entry of history) {
|
||||
insert.run({
|
||||
provider: entry.provider || null,
|
||||
model: entry.model || null,
|
||||
connectionId: entry.connectionId || null,
|
||||
apiKeyId: entry.apiKeyId || null,
|
||||
apiKeyName: entry.apiKeyName || null,
|
||||
tokensInput: entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0,
|
||||
tokensOutput: entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0,
|
||||
tokensCacheRead: entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0,
|
||||
tokensCacheCreation:
|
||||
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
|
||||
tokensReasoning: entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
|
||||
status: entry.status || null,
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
tx();
|
||||
console.log(`[usageDb] ✓ Migrated ${history.length} usage entries`);
|
||||
}
|
||||
|
||||
fs.renameSync(USAGE_JSON_FILE, USAGE_JSON_FILE + ".migrated");
|
||||
} catch (err) {
|
||||
console.error("[usageDb] Failed to migrate usage.json:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Migrate call_logs.json
|
||||
if (CALL_LOGS_JSON_FILE && fs.existsSync(CALL_LOGS_JSON_FILE)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(CALL_LOGS_JSON_FILE, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const logs = data.logs || [];
|
||||
|
||||
if (logs.length > 0) {
|
||||
console.log(`[usageDb] Migrating ${logs.length} call log entries from JSON → SQLite...`);
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT OR IGNORE INTO call_logs (id, timestamp, method, path, status, model, provider,
|
||||
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
|
||||
api_key_id, api_key_name, combo_name, request_body, response_body, error)
|
||||
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
|
||||
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
|
||||
`);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const log of logs) {
|
||||
insert.run({
|
||||
id: log.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: log.timestamp || new Date().toISOString(),
|
||||
method: log.method || "POST",
|
||||
path: log.path || null,
|
||||
status: log.status || 0,
|
||||
model: log.model || null,
|
||||
provider: log.provider || null,
|
||||
account: log.account || null,
|
||||
connectionId: log.connectionId || null,
|
||||
duration: log.duration || 0,
|
||||
tokensIn: log.tokens?.in ?? 0,
|
||||
tokensOut: log.tokens?.out ?? 0,
|
||||
sourceFormat: log.sourceFormat || null,
|
||||
targetFormat: log.targetFormat || null,
|
||||
apiKeyId: log.apiKeyId || null,
|
||||
apiKeyName: log.apiKeyName || null,
|
||||
comboName: log.comboName || null,
|
||||
requestBody: log.requestBody ? JSON.stringify(log.requestBody) : null,
|
||||
responseBody: log.responseBody ? JSON.stringify(log.responseBody) : null,
|
||||
error: log.error || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
tx();
|
||||
console.log(`[usageDb] ✓ Migrated ${logs.length} call log entries`);
|
||||
}
|
||||
|
||||
fs.renameSync(CALL_LOGS_JSON_FILE, CALL_LOGS_JSON_FILE + ".migrated");
|
||||
} catch (err) {
|
||||
console.error("[usageDb] Failed to migrate call_logs.json:", err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Run on load ────────────────
|
||||
|
||||
migrateLegacyUsageFiles();
|
||||
|
||||
if (shouldPersistToDisk) {
|
||||
try {
|
||||
migrateUsageJsonToSqlite();
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
249
src/lib/usage/usageHistory.js
Normal file
249
src/lib/usage/usageHistory.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Usage History — extracted from usageDb.js (T-15)
|
||||
*
|
||||
* Usage tracking: saving, querying, and analytics shim for
|
||||
* the usage_history SQLite table.
|
||||
*
|
||||
* @module lib/usage/usageHistory
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "../db/core.js";
|
||||
import { shouldPersistToDisk } from "./migrations.js";
|
||||
|
||||
// ──────────────── Pending Requests (in-memory) ────────────────
|
||||
|
||||
const pendingRequests = {
|
||||
byModel: {},
|
||||
byAccount: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Track a pending request.
|
||||
*/
|
||||
export function trackPendingRequest(model, provider, connectionId, started) {
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
|
||||
if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0;
|
||||
pendingRequests.byModel[modelKey] = Math.max(
|
||||
0,
|
||||
pendingRequests.byModel[modelKey] + (started ? 1 : -1)
|
||||
);
|
||||
|
||||
if (connectionId) {
|
||||
if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {};
|
||||
if (!pendingRequests.byAccount[connectionId][modelKey])
|
||||
pendingRequests.byAccount[connectionId][modelKey] = 0;
|
||||
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
|
||||
0,
|
||||
pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending requests state (for usageStats).
|
||||
* @returns {{ byModel: Object, byAccount: Object }}
|
||||
*/
|
||||
export function getPendingRequests() {
|
||||
return pendingRequests;
|
||||
}
|
||||
|
||||
// ──────────────── getUsageDb Shim (backward compat) ────────────────
|
||||
|
||||
/**
|
||||
* Returns an object compatible with the old LowDB interface.
|
||||
* Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`.
|
||||
*/
|
||||
export async function getUsageDb() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
|
||||
|
||||
const history = rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
model: r.model,
|
||||
connectionId: r.connection_id,
|
||||
apiKeyId: r.api_key_id,
|
||||
apiKeyName: r.api_key_name,
|
||||
tokens: {
|
||||
input: r.tokens_input,
|
||||
output: r.tokens_output,
|
||||
cacheRead: r.tokens_cache_read,
|
||||
cacheCreation: r.tokens_cache_creation,
|
||||
reasoning: r.tokens_reasoning,
|
||||
},
|
||||
status: r.status,
|
||||
timestamp: r.timestamp,
|
||||
}));
|
||||
|
||||
return { data: { history } };
|
||||
}
|
||||
|
||||
// ──────────────── Save Request Usage ────────────────
|
||||
|
||||
/**
|
||||
* Save request usage entry to SQLite.
|
||||
*/
|
||||
export async function saveRequestUsage(entry) {
|
||||
if (!shouldPersistToDisk) return;
|
||||
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const timestamp = entry.timestamp || new Date().toISOString();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
status, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
entry.provider || null,
|
||||
entry.model || null,
|
||||
entry.connectionId || null,
|
||||
entry.apiKeyId || null,
|
||||
entry.apiKeyName || null,
|
||||
entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0,
|
||||
entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0,
|
||||
entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0,
|
||||
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
|
||||
entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
|
||||
entry.status || null,
|
||||
timestamp
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save usage stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Get Usage History ────────────────
|
||||
|
||||
/**
|
||||
* Get usage history with optional filters.
|
||||
*/
|
||||
export async function getUsageHistory(filter = {}) {
|
||||
const db = getDbInstance();
|
||||
let sql = "SELECT * FROM usage_history";
|
||||
const conditions = [];
|
||||
const params = {};
|
||||
|
||||
if (filter.provider) {
|
||||
conditions.push("provider = @provider");
|
||||
params.provider = filter.provider;
|
||||
}
|
||||
if (filter.model) {
|
||||
conditions.push("model = @model");
|
||||
params.model = filter.model;
|
||||
}
|
||||
if (filter.startDate) {
|
||||
conditions.push("timestamp >= @startDate");
|
||||
params.startDate = new Date(filter.startDate).toISOString();
|
||||
}
|
||||
if (filter.endDate) {
|
||||
conditions.push("timestamp <= @endDate");
|
||||
params.endDate = new Date(filter.endDate).toISOString();
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
sql += " ORDER BY timestamp ASC";
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
model: r.model,
|
||||
connectionId: r.connection_id,
|
||||
apiKeyId: r.api_key_id,
|
||||
apiKeyName: r.api_key_name,
|
||||
tokens: {
|
||||
input: r.tokens_input,
|
||||
output: r.tokens_output,
|
||||
cacheRead: r.tokens_cache_read,
|
||||
cacheCreation: r.tokens_cache_creation,
|
||||
reasoning: r.tokens_reasoning,
|
||||
},
|
||||
status: r.status,
|
||||
timestamp: r.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
// ──────────────── Request Log (log.txt) ────────────────
|
||||
|
||||
import fs from "fs";
|
||||
import { LOG_FILE } from "./migrations.js";
|
||||
|
||||
function formatLogDate(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const d = pad(date.getDate());
|
||||
const m = pad(date.getMonth() + 1);
|
||||
const y = date.getFullYear();
|
||||
const h = pad(date.getHours());
|
||||
const min = pad(date.getMinutes());
|
||||
const s = pad(date.getSeconds());
|
||||
return `${d}-${m}-${y} ${h}:${min}:${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to log.txt.
|
||||
*/
|
||||
export async function appendRequestLog({ model, provider, connectionId, tokens, status }) {
|
||||
if (!shouldPersistToDisk) return;
|
||||
|
||||
try {
|
||||
const timestamp = formatLogDate();
|
||||
const p = provider?.toUpperCase() || "-";
|
||||
const m = model || "-";
|
||||
|
||||
let account = connectionId ? connectionId.slice(0, 8) : "-";
|
||||
try {
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
const connections = await getProviderConnections();
|
||||
const conn = connections.find((c) => c.id === connectionId);
|
||||
if (conn) account = conn.name || conn.email || account;
|
||||
} catch {}
|
||||
|
||||
const sent =
|
||||
tokens?.input !== undefined
|
||||
? tokens.input
|
||||
: tokens?.prompt_tokens !== undefined
|
||||
? tokens.prompt_tokens
|
||||
: "-";
|
||||
const received =
|
||||
tokens?.output !== undefined
|
||||
? tokens.output
|
||||
: tokens?.completion_tokens !== undefined
|
||||
? tokens.completion_tokens
|
||||
: "-";
|
||||
|
||||
const line = `${timestamp} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${status}\n`;
|
||||
fs.appendFileSync(LOG_FILE, line);
|
||||
|
||||
const content = fs.readFileSync(LOG_FILE, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length > 200) {
|
||||
fs.writeFileSync(LOG_FILE, lines.slice(-200).join("\n") + "\n");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to append to log.txt:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N lines of log.txt.
|
||||
*/
|
||||
export async function getRecentLogs(limit = 200) {
|
||||
if (!shouldPersistToDisk) return [];
|
||||
if (!fs || typeof fs.existsSync !== "function") return [];
|
||||
if (!LOG_FILE) return [];
|
||||
if (!fs.existsSync(LOG_FILE)) return [];
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(LOG_FILE, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
return lines.slice(-limit).reverse();
|
||||
} catch (error) {
|
||||
console.error("[usageDb] Failed to read log.txt:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
196
src/lib/usage/usageStats.js
Normal file
196
src/lib/usage/usageStats.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Usage Stats — extracted from usageDb.js (T-15)
|
||||
*
|
||||
* Aggregates usage data into stats for the dashboard:
|
||||
* totals, by provider/model/account/apiKey, 10-minute buckets.
|
||||
*
|
||||
* @module lib/usage/usageStats
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "../db/core.js";
|
||||
import { getPendingRequests } from "./usageHistory.js";
|
||||
import { calculateCost } from "./costCalculator.js";
|
||||
|
||||
/**
|
||||
* Get aggregated usage stats.
|
||||
*/
|
||||
export async function getUsageStats() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
|
||||
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
let allConnections = [];
|
||||
try {
|
||||
allConnections = await getProviderConnections();
|
||||
} catch {}
|
||||
|
||||
const connectionMap = {};
|
||||
for (const conn of allConnections) {
|
||||
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
||||
}
|
||||
|
||||
const pendingRequests = getPendingRequests();
|
||||
|
||||
const stats = {
|
||||
totalRequests: rows.length,
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalCost: 0,
|
||||
byProvider: {},
|
||||
byModel: {},
|
||||
byAccount: {},
|
||||
byApiKey: {},
|
||||
last10Minutes: [],
|
||||
pending: pendingRequests,
|
||||
activeRequests: [],
|
||||
};
|
||||
|
||||
// Build active requests
|
||||
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||
for (const [modelKey, count] of Object.entries(models)) {
|
||||
if (count > 0) {
|
||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
||||
stats.activeRequests.push({
|
||||
model: match ? match[1] : modelKey,
|
||||
provider: match ? match[2] : "unknown",
|
||||
account: accountName,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10-minute buckets
|
||||
const now = new Date();
|
||||
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
|
||||
const bucketMap = {};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
|
||||
const bucketKey = bucketTime.getTime();
|
||||
bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
|
||||
stats.last10Minutes.push(bucketMap[bucketKey]);
|
||||
}
|
||||
|
||||
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
||||
|
||||
for (const row of rows) {
|
||||
const promptTokens = row.tokens_input || 0;
|
||||
const completionTokens = row.tokens_output || 0;
|
||||
const entryTime = new Date(row.timestamp);
|
||||
|
||||
const entryTokens = {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
cacheRead: row.tokens_cache_read,
|
||||
cacheCreation: row.tokens_cache_creation,
|
||||
reasoning: row.tokens_reasoning,
|
||||
};
|
||||
const entryCost = await calculateCost(row.provider, row.model, entryTokens);
|
||||
|
||||
stats.totalPromptTokens += promptTokens;
|
||||
stats.totalCompletionTokens += completionTokens;
|
||||
stats.totalCost += entryCost;
|
||||
|
||||
// 10-min buckets
|
||||
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
||||
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
||||
if (bucketMap[entryMinuteStart]) {
|
||||
bucketMap[entryMinuteStart].requests++;
|
||||
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
||||
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
||||
bucketMap[entryMinuteStart].cost += entryCost;
|
||||
}
|
||||
}
|
||||
|
||||
// By Provider
|
||||
if (!stats.byProvider[row.provider]) {
|
||||
stats.byProvider[row.provider] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
}
|
||||
stats.byProvider[row.provider].requests++;
|
||||
stats.byProvider[row.provider].promptTokens += promptTokens;
|
||||
stats.byProvider[row.provider].completionTokens += completionTokens;
|
||||
stats.byProvider[row.provider].cost += entryCost;
|
||||
|
||||
// By Model
|
||||
const modelKey = row.provider ? `${row.model} (${row.provider})` : row.model;
|
||||
if (!stats.byModel[modelKey]) {
|
||||
stats.byModel[modelKey] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
rawModel: row.model,
|
||||
provider: row.provider,
|
||||
lastUsed: row.timestamp,
|
||||
};
|
||||
}
|
||||
stats.byModel[modelKey].requests++;
|
||||
stats.byModel[modelKey].promptTokens += promptTokens;
|
||||
stats.byModel[modelKey].completionTokens += completionTokens;
|
||||
stats.byModel[modelKey].cost += entryCost;
|
||||
if (new Date(row.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) {
|
||||
stats.byModel[modelKey].lastUsed = row.timestamp;
|
||||
}
|
||||
|
||||
// By Account
|
||||
if (row.connection_id) {
|
||||
const accountName =
|
||||
connectionMap[row.connection_id] || `Account ${row.connection_id.slice(0, 8)}...`;
|
||||
const accountKey = `${row.model} (${row.provider} - ${accountName})`;
|
||||
if (!stats.byAccount[accountKey]) {
|
||||
stats.byAccount[accountKey] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
rawModel: row.model,
|
||||
provider: row.provider,
|
||||
connectionId: row.connection_id,
|
||||
accountName,
|
||||
lastUsed: row.timestamp,
|
||||
};
|
||||
}
|
||||
stats.byAccount[accountKey].requests++;
|
||||
stats.byAccount[accountKey].promptTokens += promptTokens;
|
||||
stats.byAccount[accountKey].completionTokens += completionTokens;
|
||||
stats.byAccount[accountKey].cost += entryCost;
|
||||
if (new Date(row.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
|
||||
stats.byAccount[accountKey].lastUsed = row.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// By API key
|
||||
if (row.api_key_id || row.api_key_name) {
|
||||
const keyName = row.api_key_name || row.api_key_id || "unknown";
|
||||
const keyId = row.api_key_id || null;
|
||||
const apiKey = keyId ? `${keyName} (${keyId})` : keyName;
|
||||
if (!stats.byApiKey[apiKey]) {
|
||||
stats.byApiKey[apiKey] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
apiKeyId: keyId,
|
||||
apiKeyName: keyName,
|
||||
lastUsed: row.timestamp,
|
||||
};
|
||||
}
|
||||
stats.byApiKey[apiKey].requests++;
|
||||
stats.byApiKey[apiKey].promptTokens += promptTokens;
|
||||
stats.byApiKey[apiKey].completionTokens += completionTokens;
|
||||
stats.byApiKey[apiKey].cost += entryCost;
|
||||
if (new Date(row.timestamp) > new Date(stats.byApiKey[apiKey].lastUsed)) {
|
||||
stats.byApiKey[apiKey].lastUsed = row.timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
100
src/shared/components/ColumnToggle.js
Normal file
100
src/shared/components/ColumnToggle.js
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ColumnToggle — Shared UI primitive (T-29)
|
||||
*
|
||||
* Dropdown menu for toggling table column visibility.
|
||||
* Used by RequestLoggerV2, ProxyLogger, etc.
|
||||
*
|
||||
* Usage:
|
||||
* <ColumnToggle
|
||||
* columns={[{ key: 'model', label: 'Model' }, ...]}
|
||||
* visible={{ model: true, provider: false, ... }}
|
||||
* onToggle={(key) => setVisible({...visible, [key]: !visible[key]})}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Toggle columns"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>⚙️</span>
|
||||
Columns
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "4px",
|
||||
background: "rgba(20,20,30,0.95)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
zIndex: 50,
|
||||
minWidth: "160px",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<label
|
||||
key={col.key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: visible[col.key]
|
||||
? "var(--text-primary, #e0e0e0)"
|
||||
: "var(--text-secondary, #888)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible[col.key] ?? true}
|
||||
onChange={() => onToggle(col.key)}
|
||||
style={{ accentColor: "#6366f1" }}
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/shared/components/DataTable.js
Normal file
157
src/shared/components/DataTable.js
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DataTable — Shared UI primitive (T-29)
|
||||
*
|
||||
* Configurable data table with sticky header, row click,
|
||||
* and optional loading/empty states. Extracts the shared
|
||||
* table rendering pattern from RequestLoggerV2 and ProxyLogger.
|
||||
*
|
||||
* Usage:
|
||||
* <DataTable
|
||||
* columns={visibleColumns}
|
||||
* data={filteredLogs}
|
||||
* renderCell={(row, column) => <span>{row[column.key]}</span>}
|
||||
* onRowClick={(row) => openDetail(row)}
|
||||
* selectedId={selectedLog?.id}
|
||||
* loading={isLoading}
|
||||
* emptyIcon="📋"
|
||||
* emptyMessage="No logs found"
|
||||
* />
|
||||
*/
|
||||
|
||||
export default function DataTable({
|
||||
columns = [],
|
||||
data = [],
|
||||
renderCell,
|
||||
renderHeader,
|
||||
onRowClick,
|
||||
selectedId,
|
||||
loading = false,
|
||||
maxHeight = "calc(100vh - 320px)",
|
||||
emptyIcon = "📭",
|
||||
emptyMessage = "No data found",
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "48px 24px",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}>⏳</span>
|
||||
Loading...
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "48px 24px",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "32px", marginBottom: "8px", opacity: 0.6 }}>{emptyIcon}</span>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "auto", maxHeight, borderRadius: "8px" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "12px",
|
||||
tableLayout: "auto",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary, #888)",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
background: "var(--bg-table-header, rgba(15,15,25,0.95))",
|
||||
zIndex: 1,
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
{renderHeader ? renderHeader(col) : col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id || idx}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
style={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
background:
|
||||
row.id === selectedId
|
||||
? "rgba(99,102,241,0.1)"
|
||||
: idx % 2 === 0
|
||||
? "transparent"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (row.id !== selectedId) {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (row.id !== selectedId) {
|
||||
e.currentTarget.style.background =
|
||||
idx % 2 === 0 ? "transparent" : "rgba(255,255,255,0.02)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: col.maxWidth || "200px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{renderCell(row, col)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/shared/components/FilterBar.js
Normal file
205
src/shared/components/FilterBar.js
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FilterBar — Shared UI primitive (T-29)
|
||||
*
|
||||
* Reusable filter bar with search input and optional filter chips.
|
||||
* Used by RequestLoggerV2, ProxyLogger, and similar data tables.
|
||||
*
|
||||
* Usage:
|
||||
* <FilterBar
|
||||
* searchValue={search}
|
||||
* onSearchChange={setSearch}
|
||||
* placeholder="Search logs..."
|
||||
* filters={[
|
||||
* { key: 'status', label: 'Status', options: ['ok', 'error'] },
|
||||
* { key: 'provider', label: 'Provider', options: ['openai', 'anthropic'] },
|
||||
* ]}
|
||||
* activeFilters={activeFilters}
|
||||
* onFilterChange={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export default function FilterBar({
|
||||
searchValue = "",
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
filters = [],
|
||||
activeFilters = {},
|
||||
onFilterChange,
|
||||
children,
|
||||
}) {
|
||||
const [expandedFilter, setExpandedFilter] = useState(null);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onSearchChange("");
|
||||
filters.forEach((f) => onFilterChange(f.key, ""));
|
||||
setExpandedFilter(null);
|
||||
}, [onSearchChange, filters, onFilterChange]);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchValue || Object.values(activeFilters).some((v) => v && v !== "");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div style={{ position: "relative", flex: "1 1 200px", minWidth: "200px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px 8px 32px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "var(--text-primary, #e0e0e0)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: 0.4,
|
||||
fontSize: "14px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.key} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedFilter(expandedFilter === filter.key ? null : filter.key)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "6px",
|
||||
border: `1px solid ${activeFilters[filter.key] ? "rgba(99,102,241,0.5)" : "rgba(255,255,255,0.1)"}`,
|
||||
background: activeFilters[filter.key]
|
||||
? "rgba(99,102,241,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
color: activeFilters[filter.key]
|
||||
? "#818cf8"
|
||||
: "var(--text-secondary, #888)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
{activeFilters[filter.key] ? ` · ${activeFilters[filter.key]}` : ""}
|
||||
</button>
|
||||
{expandedFilter === filter.key && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
marginTop: "4px",
|
||||
background: "rgba(20,20,30,0.95)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "4px",
|
||||
zIndex: 50,
|
||||
minWidth: "120px",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onFilterChange(filter.key, "");
|
||||
setExpandedFilter(null);
|
||||
}}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#888",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{(filter.options || []).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
onFilterChange(filter.key, opt);
|
||||
setExpandedFilter(null);
|
||||
}}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
background:
|
||||
activeFilters[filter.key] === opt
|
||||
? "rgba(99,102,241,0.2)"
|
||||
: "none",
|
||||
border: "none",
|
||||
color:
|
||||
activeFilters[filter.key] === opt
|
||||
? "#818cf8"
|
||||
: "var(--text-primary, #e0e0e0)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clear all */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
color: "#ef4444",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Extra controls (e.g. refresh button) */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,6 +151,10 @@ export async function handleChat(request, clientRawRequest = null) {
|
||||
|
||||
/**
|
||||
* Handle single model chat request
|
||||
*
|
||||
* Refactored (T-28): model resolution, logging, and param building
|
||||
* extracted to chatHelpers.js. This function now focuses on the
|
||||
* credential retry loop.
|
||||
*/
|
||||
async function handleSingleModelChat(
|
||||
body,
|
||||
@@ -160,6 +164,7 @@ async function handleSingleModelChat(
|
||||
comboName = null,
|
||||
apiKeyInfo = null
|
||||
) {
|
||||
// 1. Resolve model → provider/model (or return error)
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
if (!modelInfo.provider) {
|
||||
if (modelInfo.errorType === "ambiguous_model") {
|
||||
@@ -172,7 +177,6 @@ async function handleSingleModelChat(
|
||||
});
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
}
|
||||
@@ -182,17 +186,15 @@ async function handleSingleModelChat(
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
|
||||
|
||||
// Log model routing (alias → actual model)
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
}
|
||||
|
||||
// Extract userAgent from request
|
||||
const userAgent = request?.headers?.get("user-agent") || "";
|
||||
|
||||
// Try with available accounts (fallback on errors)
|
||||
// 2. Credential retry loop
|
||||
let excludeConnectionId = null;
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
@@ -200,65 +202,29 @@ async function handleSingleModelChat(
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionId);
|
||||
|
||||
// All accounts unavailable
|
||||
// All accounts unavailable — return error
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status =
|
||||
lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
return unavailableResponse(
|
||||
status,
|
||||
`[${provider}/${model}] ${errorMsg}`,
|
||||
credentials.retryAfter,
|
||||
credentials.retryAfterHuman
|
||||
);
|
||||
}
|
||||
if (!excludeConnectionId) {
|
||||
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
log.warn("CHAT", "No more accounts available", { provider });
|
||||
return errorResponse(
|
||||
lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
lastError || "All accounts unavailable"
|
||||
);
|
||||
return handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus);
|
||||
}
|
||||
|
||||
// Log account selection
|
||||
const accountId = credentials.connectionId.slice(0, 8);
|
||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||
|
||||
// Resolve proxy for this connection
|
||||
let proxyInfo = null;
|
||||
try {
|
||||
proxyInfo = await resolveProxyForConnection(credentials.connectionId);
|
||||
} catch (proxyErr) {
|
||||
log.debug("PROXY", `Failed to resolve proxy: ${proxyErr.message}`);
|
||||
}
|
||||
|
||||
const proxyInfo = await safeResolveProxy(credentials.connectionId);
|
||||
const proxyStartTime = Date.now();
|
||||
|
||||
// Use shared chatCore
|
||||
// 3. Execute chat via core
|
||||
const result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
handleChatCore({
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
credentials: refreshedCredentials,
|
||||
log,
|
||||
clientRawRequest,
|
||||
connectionId: credentials.connectionId,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
credentials: refreshedCredentials, log, clientRawRequest,
|
||||
connectionId: credentials.connectionId, apiKeyInfo, userAgent, comboName,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
accessToken: newCreds.accessToken, refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData, testStatus: "active",
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
@@ -269,56 +235,14 @@ async function handleSingleModelChat(
|
||||
|
||||
const proxyLatency = Date.now() - proxyStartTime;
|
||||
|
||||
// Log proxy event
|
||||
try {
|
||||
const proxyData = proxyInfo?.proxy || null;
|
||||
logProxyEvent({
|
||||
status: result.success
|
||||
? "success"
|
||||
: result.status === 408 || result.status === 504
|
||||
? "timeout"
|
||||
: "error",
|
||||
proxy: proxyData,
|
||||
level: proxyInfo?.level || "direct",
|
||||
levelId: proxyInfo?.levelId || null,
|
||||
provider,
|
||||
targetUrl: `${provider}/${model}`,
|
||||
latencyMs: proxyLatency,
|
||||
error: result.success ? null : result.error || null,
|
||||
connectionId: credentials.connectionId,
|
||||
comboId: comboName || null,
|
||||
account: credentials.connectionId?.slice(0, 8) || null,
|
||||
});
|
||||
} catch (logErr) {
|
||||
// Never let logging break the request pipeline
|
||||
}
|
||||
|
||||
// Log translation event for Live Monitor
|
||||
try {
|
||||
logTranslationEvent({
|
||||
provider,
|
||||
model,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
status: result.success ? "success" : "error",
|
||||
statusCode: result.success ? 200 : result.status || 500,
|
||||
latency: proxyLatency,
|
||||
endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
connectionId: credentials.connectionId || null,
|
||||
comboName: comboName || null,
|
||||
});
|
||||
} catch {
|
||||
// Never let logging break the request pipeline
|
||||
}
|
||||
// 4. Log proxy + translation events (fire-and-forget)
|
||||
safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest });
|
||||
|
||||
if (result.success) return result.response;
|
||||
|
||||
// Mark account unavailable (auto-calculates cooldown with exponential backoff)
|
||||
// 5. Fallback to next account
|
||||
const { shouldFallback } = await markAccountUnavailable(
|
||||
credentials.connectionId,
|
||||
result.status,
|
||||
result.error,
|
||||
provider
|
||||
credentials.connectionId, result.status, result.error, provider
|
||||
);
|
||||
|
||||
if (shouldFallback) {
|
||||
@@ -332,3 +256,52 @@ async function handleSingleModelChat(
|
||||
return result.response;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Extracted helpers (T-28) ────
|
||||
|
||||
function handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
if (!excludeConnectionId) {
|
||||
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
log.warn("CHAT", "No more accounts available", { provider });
|
||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||
}
|
||||
|
||||
async function safeResolveProxy(connectionId) {
|
||||
try {
|
||||
return await resolveProxyForConnection(connectionId);
|
||||
} catch (proxyErr) {
|
||||
log.debug("PROXY", `Failed to resolve proxy: ${proxyErr.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest }) {
|
||||
try {
|
||||
logProxyEvent({
|
||||
status: result.success ? "success" : result.status === 408 || result.status === 504 ? "timeout" : "error",
|
||||
proxy: proxyInfo?.proxy || null, level: proxyInfo?.level || "direct",
|
||||
levelId: proxyInfo?.levelId || null, provider, targetUrl: `${provider}/${model}`,
|
||||
latencyMs: proxyLatency, error: result.success ? null : result.error || null,
|
||||
connectionId: credentials.connectionId, comboId: comboName || null,
|
||||
account: credentials.connectionId?.slice(0, 8) || null,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
logTranslationEvent({
|
||||
provider, model, sourceFormat, targetFormat,
|
||||
status: result.success ? "success" : "error",
|
||||
statusCode: result.success ? 200 : result.status || 500,
|
||||
latency: proxyLatency, endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
connectionId: credentials.connectionId || null, comboName: comboName || null,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
168
src/sse/handlers/chatHelpers.js
Normal file
168
src/sse/handlers/chatHelpers.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Chat Handler Helpers — FASE-09 (T-28)
|
||||
*
|
||||
* Extracted from handleSingleModelChat to keep the main handler
|
||||
* under 80 lines. These helpers encapsulate:
|
||||
*
|
||||
* resolveModelOrError — Model lookup + error response generation
|
||||
* logProxyAndTranslation — Side-effect logging (proxy + translation events)
|
||||
* buildChatCoreParams — Assembles the parameter object for handleChatCore
|
||||
*
|
||||
* @module sse/handlers/chatHelpers
|
||||
*/
|
||||
|
||||
import { getModelInfo } from "../services/model.js";
|
||||
import { detectFormat, getTargetFormat, getModelTargetFormat } from "../services/translator.js";
|
||||
import { PROVIDER_ID_TO_ALIAS } from "../services/model.js";
|
||||
import { logProxyEvent } from "../../lib/proxyLogger.js";
|
||||
import { logTranslationEvent } from "../../lib/translatorEvents.js";
|
||||
import { updateProviderCredentials } from "../services/auth.js";
|
||||
|
||||
const HTTP_STATUS = {
|
||||
BAD_REQUEST: 400,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a model string to provider/model or return an error response.
|
||||
*
|
||||
* @param {string} modelStr - Raw model string from request
|
||||
* @param {Function} log - Logger instance
|
||||
* @param {Function} errorResponse - Error response factory
|
||||
* @returns {Promise<{ error?: Response, provider: string, model: string, sourceFormat: string, targetFormat: string }>}
|
||||
*/
|
||||
export async function resolveModelOrError(modelStr, body, log, errorResponse) {
|
||||
const modelInfo = await getModelInfo(modelStr);
|
||||
|
||||
if (!modelInfo.provider) {
|
||||
if (modelInfo.errorType === "ambiguous_model") {
|
||||
const message =
|
||||
modelInfo.errorMessage ||
|
||||
`Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`;
|
||||
log.warn("CHAT", message, {
|
||||
model: modelStr,
|
||||
candidates: modelInfo.candidateAliases || modelInfo.candidateProviders || [],
|
||||
});
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) };
|
||||
}
|
||||
|
||||
log.warn("CHAT", "Invalid model format", { model: modelStr });
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
|
||||
}
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
const sourceFormat = detectFormat(body);
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
|
||||
|
||||
// Log routing
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
}
|
||||
|
||||
return { provider, model, sourceFormat, targetFormat };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy and translation events (fire-and-forget, never throws).
|
||||
*
|
||||
* @param {Object} params
|
||||
*/
|
||||
export function logProxyAndTranslation({
|
||||
result,
|
||||
proxyInfo,
|
||||
proxyLatency,
|
||||
provider,
|
||||
model,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
credentials,
|
||||
comboName,
|
||||
clientRawRequest,
|
||||
}) {
|
||||
// Proxy event
|
||||
try {
|
||||
const proxyData = proxyInfo?.proxy || null;
|
||||
logProxyEvent({
|
||||
status: result.success
|
||||
? "success"
|
||||
: result.status === 408 || result.status === 504
|
||||
? "timeout"
|
||||
: "error",
|
||||
proxy: proxyData,
|
||||
level: proxyInfo?.level || "direct",
|
||||
levelId: proxyInfo?.levelId || null,
|
||||
provider,
|
||||
targetUrl: `${provider}/${model}`,
|
||||
latencyMs: proxyLatency,
|
||||
error: result.success ? null : result.error || null,
|
||||
connectionId: credentials.connectionId,
|
||||
comboId: comboName || null,
|
||||
account: credentials.connectionId?.slice(0, 8) || null,
|
||||
});
|
||||
} catch {
|
||||
// Never let logging break the request pipeline
|
||||
}
|
||||
|
||||
// Translation event
|
||||
try {
|
||||
logTranslationEvent({
|
||||
provider,
|
||||
model,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
status: result.success ? "success" : "error",
|
||||
statusCode: result.success ? 200 : result.status || 500,
|
||||
latency: proxyLatency,
|
||||
endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
connectionId: credentials.connectionId || null,
|
||||
comboName: comboName || null,
|
||||
});
|
||||
} catch {
|
||||
// Never let logging break the request pipeline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the params object for handleChatCore.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @returns {Object} handleChatCore params
|
||||
*/
|
||||
export function buildChatCoreParams({
|
||||
body,
|
||||
provider,
|
||||
model,
|
||||
credentials,
|
||||
log,
|
||||
clientRawRequest,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
}) {
|
||||
return {
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
credentials,
|
||||
log,
|
||||
clientRawRequest,
|
||||
connectionId: credentials.connectionId,
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active",
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
const { clearAccountError } = await import("../services/auth.js");
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user