Feature/ai observability dashboard (#79)
* feat: add AI request details feature with latency tracking
Add comprehensive request history and debugging capability to the Usage dashboard:
**Storage Layer** (usageDb.js):
- Add saveRequestDetail() for storing full request/response details
- Implement FIFO queue with 1000-record limit in request-details.json
- Auto-sanitize sensitive headers (authorization, api-key, cookie, token)
- Add getRequestDetails() with pagination and filtering support
- Add getRequestDetailById() for single record lookup
**Pipeline Integration** (chatCore.js):
- Track request start time and calculate total latency
- Record TTFT (Time To First Token) and total latency for all requests
- Capture full request details (messages, model, parameters)
- Save response content for non-streaming, mark streaming responses
- Handle error cases with detailed error information
- Async non-blocking saves to avoid impacting request performance
**API Layer** (/api/usage/request-details):
- GET endpoint with pagination (page, pageSize: 1-100)
- Filter by provider, model, connectionId, status, date range
- Returns { details: [...], pagination: {...} } format
**UI Components**:
- Drawer.js: Right slide-out panel with backdrop blur and ESC close
- Pagination.js: Full pagination with page size selector (10/20/50)
- RequestDetailsTab.js: Complete table view with filters and detail drawer
**Dashboard Integration**:
- Add "Details" tab to Usage page (4th tab after Overview/Logger/Limits)
- Table columns: Timestamp, Model, Provider, Input Tokens, Output Tokens, Latency (TTFT/Total), Action
- Provider filter dropdown (9 providers supported)
- Date range filters (start/end datetime)
- Click "Detail" button to view full request/response JSON in slide-out drawer
**Features**:
- Real-time latency monitoring (TTFT & Total)
- Complete request/response inspection for debugging
- Filterable and searchable request history
- Responsive design with mobile-friendly filters
- Data security with automatic header sanitization
- Performance: async saves don't block request pipeline
**Files Created/Modified**:
- src/lib/usageDb.js (modified)
- open-sse/handlers/chatCore.js (modified)
- src/app/api/usage/request-details/route.js (new)
- src/shared/components/Drawer.js (new)
- src/shared/components/Pagination.js (new)
- src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js (new)
- src/app/(dashboard)/dashboard/usage/page.js (modified)
Closes: AI Observability Dashboard feature
* feat: enhance request details with full config and streaming content capture
Improve Request Details feature to capture comprehensive request parameters
and actual streaming response content:
**Request Configuration Enhancement** (chatCore.js):
- Add extractRequestConfig() helper function to capture all request parameters
- Include temperature controls: temperature, top_p, top_k
- Include token limits: max_tokens, max_completion_tokens
- Include thinking/reasoning modes: thinking, reasoning, enable_thinking
- Include OpenAI parameters: presence_penalty, frequency_penalty, seed, stop,
tools, tool_choice, response_format, n, logprobs, top_logprobs, logit_bias,
user, parallel_tool_calls, prediction, store, metadata
- Apply to all request types: non-streaming, streaming, and error cases
**Streaming Content Capture** (chatCore.js & stream.js):
- Add onStreamComplete callback mechanism to stream processors
- Accumulate content from all formats: OpenAI, Claude, Gemini
- Track content from delta.content, delta.reasoning_content, delta.text,
delta.thinking, and Gemini content.parts
- Save initial record with "[Streaming in progress...]" marker
- Update record with actual content when stream completes
- Include usage tokens when available from stream
**Files Modified**:
- open-sse/handlers/chatCore.js - extractRequestConfig() + streaming capture
- open-sse/utils/stream.js - onStreamComplete callback + content accumulation
**Benefits**:
- View complete request configuration in Request Details (thinking mode, etc.)
- See actual streaming response content instead of placeholder
- Better debugging and observability for AI requests
Refs: #request-details-enhancement
* feat: separate thinking/reasoning content from response content
Improve Request Details to display thinking process separately from final response:
**Backend Changes**:
- stream.js: Capture content and thinking separately in streaming mode
- Add accumulatedThinking variable alongside accumulatedContent
- Route delta.content to content, delta.reasoning_content to thinking
- Support OpenAI (reasoning_content), Claude (thinking), Gemini (part.thought)
- Update onStreamComplete callback to return { content, thinking } object
- chatCore.js: Update response structure to include thinking field
- Non-streaming: Extract thinking from reasoning_content field
- Streaming: Receive { content, thinking } from stream callback
- Error responses: Include thinking: null
- Initial streaming save: Include thinking: null
**Frontend Changes**:
- RequestDetailsTab.js: Display thinking and content in separate sections
- Add amber/yellow themed "Thinking Process" section with psychology icon
- Show "Final Response" label when thinking is present
- Use distinct visual styling for thinking (amber bg) vs content (gray bg)
- Only show thinking section when thinking content exists
**Benefits**:
- Users can clearly see model's reasoning process vs final answer
- Better debugging for models with thinking capabilities (Claude, o1, etc.)
- Visual distinction makes it easy to identify thinking vs response
Refs: #thinking-content-separation
* fix: map Claude thinking to reasoning_content field
Fix Claude thinking content to be properly captured as reasoning_content
instead of regular content, enabling separate display in Request Details:
**Changes**:
- claude-to-openai.js: Use reasoning_content field for thinking blocks
- thinking start: send { reasoning_content: "" } instead of { content: "```\n```" }
- thinking delta: map to reasoning_content instead of content
- thinking stop: send { reasoning_content: "" } instead of { content: "```\n```" }
**Why This Matters**:
- Previously Claude thinking was sent as `content` field, mixed with actual response
- Now thinking uses `reasoning_content` field, matching OpenAI's o1 format
- stream.js can now properly route thinking to accumulatedThinking variable
- Request Details UI will show Claude thinking in separate "Thinking Process" section
**Supported Thinking Formats**:
- OpenAI: delta.reasoning_content → thinking
- Claude: delta.thinking → reasoning_content (now fixed)
- Gemini: part.thought === true → thinking
Refs: #claude-thinking-fix
* feat(observability): capture and display full 4-layer request chain
Capture complete request/response chain in AI Request Details:
- Add providerRequest field (translated request sent to provider)
- Add providerResponse field (raw provider response, streaming indicator)
- Update chatCore.js at all 5 saveRequestDetail() call sites
- Reorganize UI into 4 collapsible sections with Material icons
- Preserve backward compatibility for old records
- Add distinct styling for streaming indicator
* fix(observability): resolve React duplicate key warning in request details table
- Use composite key (detail.id + index) to ensure unique keys
- Prevents React warnings when database contains duplicate IDs from old ID generation
* fix(observability): display actual content in streaming request details
Change providerResponse field for streaming requests from placeholder
"[Streaming - raw response not captured]" to actual final content.
This improves debugging experience by showing the real AI response
in the "Provider Response (Raw)" section instead of a confusing
placeholder message.
Files changed:
- open-sse/handlers/chatCore.js: Save contentObj.content to providerResponse
- src/app/.../RequestDetailsTab.js: Remove special handling for placeholder
* refactor(observability): migrate request details to SQLite for improved concurrency
- Replace LowDB JSON storage with better-sqlite3
- Enable WAL mode for true concurrent read/write support
- Add 5 indexes to accelerate queries (timestamp, provider, model, connection_id, status)
- Perform pagination at the database level to reduce memory footprint
- Maintain 1000 record limit with automatic cleanup of old data
- Ensure API compatibility via re-exports, requiring no caller changes
Performance improvements:
- Concurrent Writes: Lock-free WAL mode prevents data contention
- Query Efficiency: Index-based searches replace full dataset loading
- Data Integrity: Atomic operations prevent file corruption
* fix(observability): resolve pagination statistics display issues
- Fix issue where totalItems=0 showed 'Showing 1 to 0 of 0 results'
- Hide pagination controls when totalItems=0 or totalPages<=1
- Standardize API response fields: pagination.total -> pagination.totalItems
Before: Incorrect stats shown for empty data, and pager visible even for single-page results
After: Stats hidden for empty data, pager hidden when navigation is unnecessary
* feat(observability): display friendly provider names in request details
- Add /api/usage/providers endpoint to dynamically fetch provider list with names
- Replace hardcoded provider options with dynamic loading from database
- Display friendly provider names instead of IDs in both table and detail drawer
- Support custom provider nodes (e.g., OpenAI-compatible) with user-defined names
- Add provider name caching to optimize performance
* fix(observability): use INSERT OR REPLACE for request details to handle streaming updates
* fix(observability): resolve zero-token display issue by ensuring streaming usage capture and fixing key mismatch
* fix(observability): separate TTFT and total latency calculation for streaming requests
* feat(observability): implement SQLite write queue and JSON size limits
- Added in-memory buffer and batch writing for SQLite to prevent lock contention
- Implemented with configurable 1MB limit to prevent DB bloat
- Added dashboard UI for observability performance and data management settings
- Integrated graceful shutdown handlers to prevent data loss
* fix(observability): resolve ReferenceError by declaring dbInstance
This commit is contained in:
@@ -50,7 +50,11 @@ const defaultData = {
|
||||
settings: {
|
||||
cloudEnabled: false,
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true
|
||||
requireLogin: true,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
observabilityMaxJsonSize: 1024
|
||||
},
|
||||
pricing: {} // NEW: pricing configuration
|
||||
};
|
||||
@@ -67,6 +71,10 @@ function cloneDefaultData() {
|
||||
cloudEnabled: false,
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
observabilityMaxJsonSize: 1024
|
||||
},
|
||||
pricing: {},
|
||||
};
|
||||
|
||||
499
src/lib/requestDetailsDb.js
Normal file
499
src/lib/requestDetailsDb.js
Normal file
@@ -0,0 +1,499 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
|
||||
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION: Batch Processing Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get observability configuration from settings.
|
||||
* Falls back to environment variables, then defaults.
|
||||
*/
|
||||
async function getObservabilityConfig() {
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const settings = await getSettings();
|
||||
|
||||
return {
|
||||
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || '1000', 10),
|
||||
batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || '20', 10),
|
||||
flushIntervalMs: settings.observabilityFlushIntervalMs || parseInt(process.env.OBSERVABILITY_FLUSH_INTERVAL_MS || '5000', 10),
|
||||
maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || '1024', 10)) * 1024
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[requestDetailsDb] Failed to load observability config:", error);
|
||||
return {
|
||||
maxRecords: 1000,
|
||||
batchSize: 20,
|
||||
flushIntervalMs: 5000,
|
||||
maxJsonSize: 1024 * 1024
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cache config to avoid repeated database reads
|
||||
let cachedConfig = null;
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
// Get app name
|
||||
function getAppName() {
|
||||
return "9router";
|
||||
}
|
||||
|
||||
// Get user data directory based on platform
|
||||
function getUserDataDir() {
|
||||
if (isCloud) return "/tmp";
|
||||
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const homeDir = os.homedir();
|
||||
const appName = getAppName();
|
||||
|
||||
if (platform === "win32") {
|
||||
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
|
||||
} else {
|
||||
return path.join(homeDir, `.${appName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[requestDetailsDb] Failed to get user data directory:", error.message);
|
||||
return path.join(process.cwd(), ".9router");
|
||||
}
|
||||
}
|
||||
|
||||
// Database file path
|
||||
const DATA_DIR = getUserDataDir();
|
||||
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.sqlite");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!isCloud && fs && typeof fs.existsSync === "function") {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[requestDetailsDb] Failed to create data directory:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BATCH WRITE QUEUE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* In-memory buffer for batch writes.
|
||||
* Accumulates request details before flushing to database in a transaction.
|
||||
* @type {Array<object>}
|
||||
*/
|
||||
let writeBuffer = [];
|
||||
|
||||
/**
|
||||
* Timer reference for auto-flush mechanism.
|
||||
* Ensures data is written even during low traffic periods.
|
||||
* @type {NodeJS.Timeout|null}
|
||||
*/
|
||||
let flushTimer = null;
|
||||
|
||||
/**
|
||||
* Flag indicating if a flush operation is currently in progress.
|
||||
* Prevents concurrent flushes.
|
||||
* @type {boolean}
|
||||
*/
|
||||
let isFlushing = false;
|
||||
|
||||
/**
|
||||
* Get SQLite database instance (singleton)
|
||||
*/
|
||||
export async function getRequestDetailsDb() {
|
||||
if (isCloud) {
|
||||
// In-memory mock for Workers
|
||||
if (!dbInstance) {
|
||||
dbInstance = {
|
||||
prepare: () => ({
|
||||
run: () => {},
|
||||
get: () => null,
|
||||
all: () => []
|
||||
}),
|
||||
exec: () => {},
|
||||
pragma: () => {}
|
||||
};
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (!dbInstance) {
|
||||
const db = new Database(DB_FILE);
|
||||
|
||||
// Configure for better concurrency
|
||||
db.pragma('journal_mode = WAL'); // Write-Ahead Logging for concurrent access
|
||||
db.pragma('synchronous = NORMAL'); // Faster than FULL, still safe
|
||||
db.pragma('cache_size = -64000'); // 64MB cache
|
||||
db.pragma('temp_store = MEMORY'); // Use memory for temp tables
|
||||
|
||||
// Create table with indexes
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS request_details (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
connection_id TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
latency TEXT,
|
||||
tokens TEXT,
|
||||
request TEXT,
|
||||
provider_request TEXT,
|
||||
provider_response TEXT,
|
||||
response TEXT
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp
|
||||
ON request_details(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider
|
||||
ON request_details(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_model
|
||||
ON request_details(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_connection
|
||||
ON request_details(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_status
|
||||
ON request_details(status);
|
||||
`);
|
||||
|
||||
dbInstance = db;
|
||||
|
||||
// Register shutdown handler on first database initialization
|
||||
ensureShutdownHandler();
|
||||
}
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID for request detail
|
||||
*/
|
||||
function generateDetailId(model) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const modelPart = model ? model.replace(/[^a-zA-Z0-9-]/g, '-') : 'unknown';
|
||||
return `${timestamp}-${random}-${modelPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all buffered items to database in a single transaction.
|
||||
* This function is called automatically when:
|
||||
* 1. Buffer size reaches OBSERVABILITY_BATCH_SIZE
|
||||
* 2. OBSERVABILITY_FLUSH_INTERVAL_MS elapses
|
||||
* 3. Process is shutting down (graceful shutdown)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async function flushToDatabase() {
|
||||
if (isCloud || isFlushing || writeBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushing = true;
|
||||
|
||||
try {
|
||||
// Take a snapshot of the buffer and clear it immediately
|
||||
const itemsToSave = [...writeBuffer];
|
||||
writeBuffer = [];
|
||||
|
||||
const db = await getRequestDetailsDb();
|
||||
const config = await getObservabilityConfig();
|
||||
|
||||
// Prepare statements outside transaction for better performance
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO request_details
|
||||
(id, provider, model, connection_id, timestamp, status, latency, tokens,
|
||||
request, provider_request, provider_response, response)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const deleteStmt = db.prepare(`
|
||||
DELETE FROM request_details
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM request_details
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`);
|
||||
|
||||
// Execute all writes in a single transaction for atomicity
|
||||
const transaction = db.transaction((items) => {
|
||||
const maxJsonSize = config.maxJsonSize;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.id) {
|
||||
item.id = generateDetailId(item.model);
|
||||
}
|
||||
|
||||
if (!item.timestamp) {
|
||||
item.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Sanitize headers if present
|
||||
if (item.request && item.request.headers) {
|
||||
item.request.headers = sanitizeHeaders(item.request.headers);
|
||||
}
|
||||
|
||||
insertStmt.run(
|
||||
item.id,
|
||||
item.provider || null,
|
||||
item.model || null,
|
||||
item.connectionId || null,
|
||||
new Date(item.timestamp).getTime(),
|
||||
item.status || null,
|
||||
JSON.stringify(item.latency || {}),
|
||||
JSON.stringify(item.tokens || {}),
|
||||
safeJsonStringify(item.request || {}, maxJsonSize),
|
||||
safeJsonStringify(item.providerRequest || {}, maxJsonSize),
|
||||
safeJsonStringify(item.providerResponse || {}, maxJsonSize),
|
||||
safeJsonStringify(item.response || {}, maxJsonSize)
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup old records once per batch (not per item)
|
||||
deleteStmt.run(config.maxRecords);
|
||||
});
|
||||
|
||||
transaction(itemsToSave);
|
||||
} catch (error) {
|
||||
console.error("[requestDetailsDb] Batch write failed:", error);
|
||||
} finally {
|
||||
isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stringify an object with a size limit.
|
||||
* Truncates the result if it exceeds the limit.
|
||||
* @param {object} obj - Object to stringify
|
||||
* @param {number} maxSize - Maximum string size in bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
function safeJsonStringify(obj, maxSize) {
|
||||
try {
|
||||
const str = JSON.stringify(obj);
|
||||
if (str.length > maxSize) {
|
||||
return str.substring(0, maxSize) + "... (truncated due to size limit)";
|
||||
}
|
||||
return str;
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: "Failed to stringify object", message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize sensitive headers from request
|
||||
*/
|
||||
function sanitizeHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') return {};
|
||||
|
||||
const sensitiveKeys = ['authorization', 'x-api-key', 'cookie', 'token', 'api-key'];
|
||||
const sanitized = { ...headers };
|
||||
|
||||
for (const key of Object.keys(sanitized)) {
|
||||
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save request detail to SQLite (batched for performance).
|
||||
* Details are accumulated in memory and flushed to database in batches.
|
||||
*
|
||||
* @param {object} detail - Request detail object
|
||||
* @see {@link flushToDatabase} for batch write implementation
|
||||
*/
|
||||
export async function saveRequestDetail(detail) {
|
||||
if (isCloud) return;
|
||||
|
||||
if (!cachedConfig) {
|
||||
cachedConfig = await getObservabilityConfig();
|
||||
}
|
||||
|
||||
writeBuffer.push(detail);
|
||||
|
||||
if (writeBuffer.length >= cachedConfig.batchSize) {
|
||||
await flushToDatabase();
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
} else if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushToDatabase().catch(() => {});
|
||||
flushTimer = null;
|
||||
}, cachedConfig.flushIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GRACEFUL SHUTDOWN HANDLER
|
||||
// ============================================================================
|
||||
|
||||
let shutdownHandlerRegistered = false;
|
||||
|
||||
/**
|
||||
* Register process shutdown handlers to flush remaining data before exit.
|
||||
* Should be called once when the module initializes.
|
||||
*/
|
||||
function ensureShutdownHandler() {
|
||||
if (shutdownHandlerRegistered || isCloud) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = async () => {
|
||||
// Clear timer to prevent any pending flush
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
|
||||
// Flush any remaining data in buffer
|
||||
if (writeBuffer.length > 0) {
|
||||
console.log(`[requestDetailsDb] Flushing ${writeBuffer.length} items before shutdown...`);
|
||||
await flushToDatabase();
|
||||
}
|
||||
};
|
||||
|
||||
// Register handlers for various termination signals
|
||||
process.on('beforeExit', handler);
|
||||
process.on('SIGINT', handler);
|
||||
process.on('SIGTERM', handler);
|
||||
process.on('exit', handler);
|
||||
|
||||
shutdownHandlerRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request details with filtering and pagination
|
||||
* @param {object} filter - Filter options
|
||||
* @returns {Promise<object>} Details with pagination info
|
||||
*/
|
||||
export async function getRequestDetails(filter = {}) {
|
||||
const db = await getRequestDetailsDb();
|
||||
|
||||
if (isCloud) {
|
||||
return { details: [], pagination: { page: 1, pageSize: filter.pageSize || 50, totalItems: 0, totalPages: 0, hasNext: false, hasPrev: false } };
|
||||
}
|
||||
|
||||
let query = 'SELECT * FROM request_details WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (filter.provider) {
|
||||
query += ' AND provider = ?';
|
||||
params.push(filter.provider);
|
||||
}
|
||||
|
||||
if (filter.model) {
|
||||
query += ' AND model = ?';
|
||||
params.push(filter.model);
|
||||
}
|
||||
|
||||
if (filter.connectionId) {
|
||||
query += ' AND connection_id = ?';
|
||||
params.push(filter.connectionId);
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(filter.status);
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
query += ' AND timestamp >= ?';
|
||||
params.push(new Date(filter.startDate).getTime());
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
query += ' AND timestamp <= ?';
|
||||
params.push(new Date(filter.endDate).getTime());
|
||||
}
|
||||
|
||||
// Get total count first
|
||||
const countQuery = query.replace('SELECT *', 'SELECT COUNT(*)');
|
||||
const countStmt = db.prepare(countQuery);
|
||||
const totalResult = countStmt.get(...params);
|
||||
const total = totalResult['COUNT(*)'];
|
||||
|
||||
// Add pagination
|
||||
query += ' ORDER BY timestamp DESC';
|
||||
const page = filter.page || 1;
|
||||
const pageSize = filter.pageSize || 50;
|
||||
query += ' LIMIT ? OFFSET ?';
|
||||
params.push(pageSize, (page - 1) * pageSize);
|
||||
|
||||
// Execute query
|
||||
const stmt = db.prepare(query);
|
||||
const rows = stmt.all(...params);
|
||||
|
||||
// Convert back to original format
|
||||
const details = rows.map(row => ({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
connectionId: row.connection_id,
|
||||
timestamp: new Date(row.timestamp).toISOString(),
|
||||
status: row.status,
|
||||
latency: JSON.parse(row.latency || '{}'),
|
||||
tokens: JSON.parse(row.tokens || '{}'),
|
||||
request: JSON.parse(row.request || '{}'),
|
||||
providerRequest: JSON.parse(row.provider_request || '{}'),
|
||||
providerResponse: JSON.parse(row.provider_response || '{}'),
|
||||
response: JSON.parse(row.response || '{}')
|
||||
}));
|
||||
|
||||
return {
|
||||
details,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
hasNext: page < Math.ceil(total / pageSize),
|
||||
hasPrev: page > 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single request detail by ID
|
||||
* @param {string} id - Request detail ID
|
||||
* @returns {Promise<object|null>} Request detail or null
|
||||
*/
|
||||
export async function getRequestDetailById(id) {
|
||||
const db = await getRequestDetailsDb();
|
||||
|
||||
if (isCloud) return null;
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM request_details WHERE id = ?');
|
||||
const row = stmt.get(id);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
connectionId: row.connection_id,
|
||||
timestamp: new Date(row.timestamp).toISOString(),
|
||||
status: row.status,
|
||||
latency: JSON.parse(row.latency || '{}'),
|
||||
tokens: JSON.parse(row.tokens || '{}'),
|
||||
request: JSON.parse(row.request || '{}'),
|
||||
providerRequest: JSON.parse(row.provider_request || '{}'),
|
||||
providerResponse: JSON.parse(row.provider_response || '{}'),
|
||||
response: JSON.parse(row.response || '{}')
|
||||
};
|
||||
}
|
||||
@@ -511,3 +511,6 @@ export async function getUsageStats() {
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Re-export request details functions from new SQLite-based module
|
||||
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";
|
||||
|
||||
Reference in New Issue
Block a user