Revert "feat(request-details): implement observability settings and enhance request detail tracking"

This reverts commit cbabf5547c.
This commit is contained in:
decolua
2026-02-09 10:29:38 +07:00
parent cbabf5547c
commit 388389c972
14 changed files with 40 additions and 1647 deletions

View File

@@ -10,7 +10,7 @@ import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerMo
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/constants.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { saveRequestUsage, trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
/**
@@ -225,38 +225,6 @@ function extractUsageFromResponse(responseBody, provider) {
return null;
}
/**
* Extract full request configuration from body
* Captures all relevant parameters for request details
*/
function extractRequestConfig(body, stream) {
const config = {
messages: body.messages || [],
model: body.model,
stream: stream
};
// Add all optional configuration parameters
const optionalParams = [
'temperature', 'top_p', 'top_k',
'max_tokens', 'max_completion_tokens',
'thinking', 'reasoning', 'enable_thinking',
'presence_penalty', 'frequency_penalty',
'seed', 'stop', 'tools', 'tool_choice',
'response_format', 'prediction', 'store', 'metadata',
'n', 'logprobs', 'top_logprobs', 'logit_bias',
'user', 'parallel_tool_calls'
];
for (const param of optionalParams) {
if (body[param] !== undefined) {
config[param] = body[param];
}
}
return config;
}
/**
* Convert OpenAI-style SSE chunks into a single non-streaming JSON response.
* Used as a fallback when upstream returns text/event-stream for stream=false.
@@ -347,7 +315,6 @@ function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
const sourceFormat = detectFormat(body);
@@ -440,26 +407,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
const errorDetail = {
provider: provider || "unknown",
model: model || "unknown",
connectionId: connectionId || undefined,
timestamp: new Date().toISOString(),
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: translatedBody || null,
providerResponse: null,
response: {
error: error.message || String(error),
status: error.name === "AbortError" ? 499 : 502,
thinking: null
},
status: "error"
};
saveRequestDetail(errorDetail).catch(() => {});
if (error.name === "AbortError") {
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
@@ -516,26 +463,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
trackPendingRequest(model, provider, connectionId, false);
const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
const errorDetail = {
provider: provider || "unknown",
model: model || "unknown",
connectionId: connectionId || undefined,
timestamp: new Date().toISOString(),
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: null,
response: {
error: message,
status: statusCode,
thinking: null
},
status: "error"
};
saveRequestDetail(errorDetail).catch(() => {});
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
@@ -604,37 +531,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
translatedResponse.usage = filterUsageForFormat(buffered, sourceFormat);
}
const totalLatency = Date.now() - requestStartTime;
const requestDetail = {
provider: provider || "unknown",
model: model || "unknown",
connectionId: connectionId || undefined,
timestamp: new Date().toISOString(),
latency: {
ttft: totalLatency,
total: totalLatency
},
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: responseBody || null,
response: {
content: translatedResponse?.choices?.[0]?.message?.content ||
translatedResponse?.content ||
null,
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content ||
translatedResponse?.reasoning_content ||
null,
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
},
status: "success"
};
// Async save (don't block response)
saveRequestDetail(requestDetail).catch(err => {
console.error("[RequestDetail] Failed to save:", err.message);
});
return {
success: true,
response: new Response(JSON.stringify(translatedResponse), {
@@ -660,103 +556,31 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
"Access-Control-Allow-Origin": "*"
};
let streamContent = "";
let streamUsage = null;
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const onStreamComplete = (contentObj, usage, ttftAt) => {
// contentObj is object { content, thinking }
streamUsage = usage;
const updatedDetail = {
provider: provider || "unknown",
model: model || "unknown",
connectionId: connectionId || undefined,
timestamp: new Date().toISOString(),
latency: {
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
total: Date.now() - requestStartTime
},
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: contentObj.content || "[Empty streaming response]",
response: {
content: contentObj.content || "[Empty streaming response]",
thinking: contentObj.thinking || null,
type: "streaming"
},
status: "success",
id: streamDetailId
};
saveRequestDetail(updatedDetail).catch(err => {
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
// Save usage stats for dashboard
if (usage && typeof usage === 'object') {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [STREAM USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
saveRequestUsage({
provider: provider || "unknown",
model: model || "unknown",
tokens: usage,
timestamp: new Date().toISOString(),
connectionId: connectionId || undefined
}).catch(err => {
console.error("Failed to save streaming usage stats:", err.message);
});
}
};
// Create transform stream with logger for streaming response
let transformStream;
// For Codex provider, translate response from openai-responses to openai (Chat Completions) format
// UNLESS client is Droid CLI which expects openai-responses format back
const isDroidCLI = userAgent?.toLowerCase().includes('droid') || userAgent?.toLowerCase().includes('codex-cli');
const needsCodexTranslation = provider === 'codex'
&& targetFormat === 'openai-responses'
&& !isDroidCLI;
if (needsCodexTranslation) {
// Codex returns openai-responses, translate to openai (Chat Completions) that clients expect
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete);
transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body);
} else if (needsTranslation(targetFormat, sourceFormat)) {
// Standard translation for other providers
log?.debug?.("STREAM", `Translation mode: ${targetFormat}${sourceFormat}`);
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete);
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body);
} else {
log?.debug?.("STREAM", `Standard passthrough mode`);
transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete);
transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body);
}
// Pipe response through transform with disconnect detection
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
const totalLatency = Date.now() - requestStartTime;
const streamingDetail = {
provider: provider || "unknown",
model: model || "unknown",
connectionId: connectionId || undefined,
timestamp: new Date().toISOString(),
latency: {
ttft: 0,
total: Date.now() - requestStartTime
},
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: "[Streaming - raw response not captured]",
response: {
content: "[Streaming in progress...]",
thinking: null,
type: "streaming"
},
status: "success",
id: streamDetailId
};
saveRequestDetail(streamingDetail).catch(err => {
console.error("[RequestDetail] Failed to save streaming request:", err.message);
});
return {
success: true,
response: new Response(transformedBody, {

View File

@@ -64,7 +64,7 @@ export function claudeToOpenAIResponse(chunk, state) {
if (delta?.type === "text_delta" && delta.text) {
results.push(createChunk(state, { content: delta.text }));
} else if (delta?.type === "thinking_delta" && delta.thinking) {
results.push(createChunk(state, { reasoning_content: delta.thinking }));
results.push(createChunk(state, { content: delta.thinking }));
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
const toolCall = state.toolCalls.get(chunk.index);
if (toolCall) {
@@ -83,7 +83,7 @@ export function claudeToOpenAIResponse(chunk, state) {
case "content_block_stop": {
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
results.push(createChunk(state, { reasoning_content: "" }));
results.push(createChunk(state, { content: "</think>" }));
state.inThinkingBlock = false;
}
state.textBlockStarted = false;

View File

@@ -28,7 +28,6 @@ const STREAM_MODE = {
* @param {string} options.model - Model name
* @param {string} options.connectionId - Connection ID for usage tracking
* @param {object} options.body - Request body (for input token estimation)
* @param {function} options.onStreamComplete - Callback when stream completes (content, usage)
*/
export function createSSEStream(options = {}) {
const {
@@ -40,25 +39,20 @@ export function createSSEStream(options = {}) {
toolNameMap = null,
model = null,
connectionId = null,
body = null,
onStreamComplete = null
body = null
} = options;
let buffer = "";
let usage = null;
// State for translate mode
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap } : null;
// Track content length for usage estimation (both modes)
let totalContentLength = 0;
let accumulatedContent = "";
let accumulatedThinking = "";
let ttftAt = null;
return new TransformStream({
transform(chunk, controller) {
if (!ttftAt) {
ttftAt = Date.now();
}
const text = sharedDecoder.decode(chunk, { stream: true });
buffer += text;
reqLogger?.appendProviderChunk?.(text);
@@ -85,15 +79,9 @@ export function createSSEStream(options = {}) {
}
const delta = parsed.choices?.[0]?.delta;
const content = delta?.content;
const reasoning = delta?.reasoning_content;
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
accumulatedContent += content;
}
if (reasoning && typeof reasoning === "string") {
totalContentLength += reasoning.length;
accumulatedThinking += reasoning;
}
const extracted = extractUsage(parsed);
@@ -146,39 +134,30 @@ export function createSSEStream(options = {}) {
continue;
}
// Claude format - content
// Track content length for estimation (from various formats)
// Include both regular content and reasoning/thinking content
// Claude format
if (parsed.delta?.text) {
totalContentLength += parsed.delta.text.length;
accumulatedContent += parsed.delta.text;
}
// Claude format - thinking
if (parsed.delta?.thinking) {
totalContentLength += parsed.delta.thinking.length;
accumulatedThinking += parsed.delta.thinking;
}
// OpenAI format - content
// OpenAI format
if (parsed.choices?.[0]?.delta?.content) {
totalContentLength += parsed.choices[0].delta.content.length;
accumulatedContent += parsed.choices[0].delta.content;
}
// OpenAI format - reasoning
if (parsed.choices?.[0]?.delta?.reasoning_content) {
totalContentLength += parsed.choices[0].delta.reasoning_content.length;
accumulatedThinking += parsed.choices[0].delta.reasoning_content;
}
// Gemini format
// Gemini format - may have multiple parts
if (parsed.candidates?.[0]?.content?.parts) {
for (const part of parsed.candidates[0].content.parts) {
if (part.text && typeof part.text === "string") {
totalContentLength += part.text.length;
// Check if this is thinking content
if (part.thought === true) {
accumulatedThinking += part.text;
} else {
accumulatedContent += part.text;
}
}
}
}
@@ -241,6 +220,7 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(output));
}
// Estimate usage if provider didn't return valid usage (PASSTHROUGH is always OpenAI format)
if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
}
@@ -250,21 +230,16 @@ export function createSSEStream(options = {}) {
} else {
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
}
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
thinking: accumulatedThinking
}, usage, ttftAt);
}
return;
}
// Translate mode: process remaining buffer
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
// Log OpenAI intermediate chunks
if (translated?._openaiIntermediate) {
for (const item of translated._openaiIntermediate) {
const openaiOutput = formatSSE(item, FORMATS.OPENAI);
@@ -282,8 +257,10 @@ export function createSSEStream(options = {}) {
}
}
// Flush remaining events (only once at stream end)
const flushed = translateResponse(targetFormat, sourceFormat, null, state);
// Log OpenAI intermediate chunks for flushed events
if (flushed?._openaiIntermediate) {
for (const item of flushed._openaiIntermediate) {
const openaiOutput = formatSSE(item, FORMATS.OPENAI);
@@ -299,10 +276,12 @@ export function createSSEStream(options = {}) {
}
}
// Send [DONE] and log usage
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
// Estimate usage if provider didn't return valid usage (for translate mode)
if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
}
@@ -312,13 +291,6 @@ export function createSSEStream(options = {}) {
} else {
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
}
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
thinking: accumulatedThinking
}, state?.usage, ttftAt);
}
} catch (error) {
console.log("Error in flush:", error);
}
@@ -326,7 +298,8 @@ export function createSSEStream(options = {}) {
});
}
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null) {
// Convenience functions for backward compatibility
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
targetFormat,
@@ -336,19 +309,17 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
toolNameMap,
model,
connectionId,
body,
onStreamComplete
body
});
}
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null) {
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
provider,
reqLogger,
model,
connectionId,
body,
onStreamComplete
body
});
}

View File

@@ -312,11 +312,11 @@ export function logUsage(provider, usage, model = null, connectionId = null) {
// Save to usage DB
const tokens = {
prompt_tokens: inTokens,
completion_tokens: outTokens,
cache_read_input_tokens: cacheRead || 0,
cache_creation_input_tokens: cacheCreation || 0,
reasoning_tokens: reasoning || 0
input: inTokens,
output: outTokens,
cacheRead: cacheRead || 0,
cacheCreation: cacheCreation || 0,
reasoning: reasoning || 0
};
saveRequestUsage({ model, provider, connectionId, tokens }).catch(() => { });
appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => { });

View File

@@ -110,24 +110,6 @@ export default function ProfilePage() {
}
};
const updateObservabilitySetting = async (key, value) => {
const numValue = parseInt(value);
if (isNaN(numValue) || numValue < 1) return;
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: numValue }),
});
if (res.ok) {
setSettings(prev => ({ ...prev, [key]: numValue }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
return (
<div className="max-w-2xl mx-auto">
<div className="flex flex-col gap-6">
@@ -311,7 +293,6 @@ export default function ProfilePage() {
{["light", "dark", "system"].map((option) => (
<button
key={option}
type="button"
onClick={() => setTheme(option)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all",
@@ -349,97 +330,6 @@ export default function ProfilePage() {
</div>
</Card>
{/* Observability Settings */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-500">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
</div>
<h3 className="text-lg font-semibold">Observability</h3>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Max Records</p>
<p className="text-sm text-text-muted">
Maximum request detail records to keep (older records are auto-deleted)
</p>
</div>
<Input
type="number"
min="100"
max="10000"
step="100"
value={settings.observabilityMaxRecords || 1000}
onChange={(e) => updateObservabilitySetting("observabilityMaxRecords", parseInt(e.target.value))}
disabled={loading}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Batch Size</p>
<p className="text-sm text-text-muted">
Number of items to accumulate before writing to database (higher = better performance)
</p>
</div>
<Input
type="number"
min="5"
max="100"
step="5"
value={settings.observabilityBatchSize || 20}
onChange={(e) => updateObservabilitySetting("observabilityBatchSize", parseInt(e.target.value))}
disabled={loading}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Flush Interval (ms)</p>
<p className="text-sm text-text-muted">
Maximum time to wait before flushing buffer (prevents data loss during low traffic)
</p>
</div>
<Input
type="number"
min="1000"
max="30000"
step="1000"
value={settings.observabilityFlushIntervalMs || 5000}
onChange={(e) => updateObservabilitySetting("observabilityFlushIntervalMs", parseInt(e.target.value))}
disabled={loading}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Max JSON Size (KB)</p>
<p className="text-sm text-text-muted">
Maximum size for each JSON field (request/response) before truncation
</p>
</div>
<Input
type="number"
min="100"
max="10240"
step="100"
value={settings.observabilityMaxJsonSize || 1024}
onChange={(e) => updateObservabilitySetting("observabilityMaxJsonSize", parseInt(e.target.value))}
disabled={loading}
className="w-28 text-center"
/>
</div>
<p className="text-xs text-text-muted italic pt-2 border-t border-border/50">
Current: Keeps {settings.observabilityMaxRecords || 1000} records, batches every {settings.observabilityBatchSize || 20} requests, max {settings.observabilityMaxJsonSize || 1024}KB per field
</p>
</div>
</Card>
{/* App Info */}
<div className="text-center text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>

View File

@@ -1,425 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import Drawer from "@/shared/components/Drawer";
import Pagination from "@/shared/components/Pagination";
import { cn } from "@/shared/utils/cn";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
let providerNameCache = null;
let providerNodesCache = null;
async function fetchProviderNames() {
if (providerNameCache && providerNodesCache) {
return { providerNameCache, providerNodesCache };
}
const nodesRes = await fetch("/api/provider-nodes");
const nodesData = await nodesRes.json();
const nodes = nodesData.nodes || [];
providerNodesCache = {};
for (const node of nodes) {
providerNodesCache[node.id] = node.name;
}
providerNameCache = {
...AI_PROVIDERS,
...providerNodesCache
};
return { providerNameCache, providerNodesCache };
}
function getProviderName(providerId, cache) {
if (!providerId) return providerId;
if (!cache) return providerId;
const cached = cache[providerId];
if (typeof cached === 'string') {
return cached;
}
if (cached?.name) {
return cached.name;
}
const providerConfig = getProviderByAlias(providerId) || AI_PROVIDERS[providerId];
return providerConfig?.name || providerId;
}
function CollapsibleSection({ title, children, defaultOpen = false, icon = null }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-black/5 dark:border-white/5 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-3 bg-black/[0.02] dark:bg-white/[0.02] hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors"
>
<div className="flex items-center gap-2">
{icon && <span className="material-symbols-outlined text-[18px] text-text-muted">{icon}</span>}
<span className="font-semibold text-sm text-text-main">{title}</span>
</div>
<span className={cn(
"material-symbols-outlined text-[20px] text-text-muted transition-transform duration-200",
isOpen ? "rotate-90" : ""
)}>
chevron_right
</span>
</button>
{isOpen && (
<div className="p-4 border-t border-black/5 dark:border-white/5">
{children}
</div>
)}
</div>
);
}
export default function RequestDetailsTab() {
const [details, setDetails] = useState([]);
const [pagination, setPagination] = useState({
page: 1,
pageSize: 20,
totalItems: 0,
totalPages: 0
});
const [loading, setLoading] = useState(false);
const [selectedDetail, setSelectedDetail] = useState(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [providers, setProviders] = useState([]);
const [providerNameCache, setProviderNameCache] = useState(null);
const [filters, setFilters] = useState({
provider: "",
startDate: "",
endDate: ""
});
const fetchProviders = useCallback(async () => {
try {
const res = await fetch("/api/usage/providers");
const data = await res.json();
setProviders(data.providers || []);
const cache = await fetchProviderNames();
setProviderNameCache(cache.providerNameCache);
} catch (error) {
console.error("Failed to fetch providers:", error);
}
}, []);
const fetchDetails = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
pageSize: pagination.pageSize.toString()
});
if (filters.provider) params.append("provider", filters.provider);
if (filters.startDate) params.append("startDate", filters.startDate);
if (filters.endDate) params.append("endDate", filters.endDate);
const res = await fetch(`/api/usage/request-details?${params}`);
const data = await res.json();
setDetails(data.details || []);
setPagination(prev => ({ ...prev, ...data.pagination }));
} catch (error) {
console.error("Failed to fetch request details:", error);
} finally {
setLoading(false);
}
}, [pagination.page, pagination.pageSize, filters]);
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
useEffect(() => {
fetchDetails();
}, [fetchDetails]);
const handleViewDetail = (detail) => {
setSelectedDetail(detail);
setIsDrawerOpen(true);
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handlePageSizeChange = (newPageSize) => {
setPagination(prev => ({ ...prev, pageSize: newPageSize, page: 1 }));
};
const handleClearFilters = () => {
setFilters({ provider: "", startDate: "", endDate: "" });
};
return (
<div className="flex flex-col gap-6">
<Card padding="md">
<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="provider-filter" className="text-sm font-medium text-text-main">Provider</label>
<select
id="provider-filter"
value={filters.provider}
onChange={(e) => setFilters({ ...filters, provider: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"cursor-pointer min-w-[150px]"
)}
>
<option value="">All Providers</option>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
<input
id="start-date-filter"
type="datetime-local"
value={filters.startDate}
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
)}
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="end-date-filter" className="text-sm font-medium text-text-main">End Date</label>
<input
id="end-date-filter"
type="datetime-local"
value={filters.endDate}
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
)}
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-text-main opacity-0" aria-hidden="true">Clear</span>
<Button
variant="ghost"
onClick={handleClearFilters}
disabled={!filters.provider && !filters.startDate && !filters.endDate}
>
Clear Filters
</Button>
</div>
</div>
</Card>
<Card padding="none">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-black/5 dark:border-white/5">
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Model</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Latency</th>
<th className="text-center p-4 text-sm font-semibold text-text-main">Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan="7" className="p-8 text-center text-text-muted">
<div className="flex items-center justify-center gap-2">
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
Loading...
</div>
</td>
</tr>
) : details.length === 0 ? (
<tr>
<td colSpan="7" className="p-8 text-center text-text-muted">
No request details found
</td>
</tr>
) : (
details.map((detail, index) => (
<tr
key={`${detail.id}-${index}`}
className="border-b border-black/5 dark:border-white/5 last:border-b-0 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="p-4 text-sm text-text-main">
{new Date(detail.timestamp).toLocaleString()}
</td>
<td className="p-4 text-sm text-text-main font-mono">
{detail.model}
</td>
<td className="p-4 text-sm text-text-main">
<span className="font-medium">
{getProviderName(detail.provider, providerNameCache)}
</span>
</td>
<td className="p-4 text-sm text-text-main text-right font-mono">
{detail.tokens?.prompt_tokens?.toLocaleString() || 0}
</td>
<td className="p-4 text-sm text-text-main text-right font-mono">
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
</td>
<td className="p-4 text-sm text-text-muted">
<div className="flex flex-col gap-0.5">
<div>TTFT: <span className="font-mono">{detail.latency?.ttft || 0}ms</span></div>
<div>Total: <span className="font-mono">{detail.latency?.total || 0}ms</span></div>
</div>
</td>
<td className="p-4 text-center">
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetail(detail)}
>
Detail
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!loading && details.length > 0 && (
<div className="border-t border-black/5 dark:border-white/5">
<Pagination
currentPage={pagination.page}
pageSize={pagination.pageSize}
totalItems={pagination.totalItems}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div>
)}
</Card>
<Drawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
title="Request Details"
width="lg"
>
{selectedDetail && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-muted">ID:</span>{" "}
<span className="text-text-main font-mono">{selectedDetail.id}</span>
</div>
<div>
<span className="text-text-muted">Timestamp:</span>{" "}
<span className="text-text-main">{new Date(selectedDetail.timestamp).toLocaleString()}</span>
</div>
<div>
<span className="text-text-muted">Provider:</span>{" "}
<span className="text-text-main font-medium">{getProviderName(selectedDetail.provider, providerNameCache)}</span>
</div>
<div>
<span className="text-text-muted">Model:</span>{" "}
<span className="text-text-main font-mono">{selectedDetail.model}</span>
</div>
<div>
<span className="text-text-muted">Status:</span>{" "}
<span className={cn(
"font-medium",
selectedDetail.status === "success" ? "text-green-600" : "text-red-600"
)}>
{selectedDetail.status}
</span>
</div>
<div>
<span className="text-text-muted">Latency:</span>{" "}
<span className="text-text-main font-mono">
TTFT {selectedDetail.latency?.ttft || 0}ms / Total {selectedDetail.latency?.total || 0}ms
</span>
</div>
<div>
<span className="text-text-muted">Input Tokens:</span>{" "}
<span className="text-text-main font-mono">
{selectedDetail.tokens?.prompt_tokens?.toLocaleString() || 0}
</span>
</div>
<div>
<span className="text-text-muted">Output Tokens:</span>{" "}
<span className="text-text-main font-mono">
{selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
</span>
</div>
</div>
<div className="space-y-4">
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
{JSON.stringify(selectedDetail.request, null, 2)}
</pre>
</CollapsibleSection>
{selectedDetail.providerRequest && (
<CollapsibleSection title="2. Provider Request (Translated)" icon="translate">
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
{JSON.stringify(selectedDetail.providerRequest, null, 2)}
</pre>
</CollapsibleSection>
)}
{selectedDetail.providerResponse && (
<CollapsibleSection title="3. Provider Response (Raw)" icon="data_object">
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
{typeof selectedDetail.providerResponse === 'object'
? JSON.stringify(selectedDetail.providerResponse, null, 2)
: selectedDetail.providerResponse
}
</pre>
</CollapsibleSection>
)}
<CollapsibleSection title="4. Client Response (Final)" defaultOpen={true} icon="output">
{selectedDetail.response?.thinking && (
<div className="mb-4">
<h4 className="font-semibold text-text-main mb-2 flex items-center gap-2 text-xs uppercase tracking-wide opacity-70">
<span className="material-symbols-outlined text-[16px]">psychology</span>
Thinking Process
</h4>
<pre className="bg-amber-50 dark:bg-amber-950/30 p-4 rounded-lg overflow-auto max-h-[200px] text-xs font-mono text-amber-900 dark:text-amber-100 border border-amber-200 dark:border-amber-800">
{selectedDetail.response.thinking}
</pre>
</div>
)}
<h4 className="font-semibold text-text-main mb-2 text-xs uppercase tracking-wide opacity-70">
Content
</h4>
<pre className="bg-black/5 dark:bg-white/5 p-4 rounded-lg overflow-auto max-h-[300px] text-xs font-mono text-text-main border border-black/5 dark:border-white/5">
{selectedDetail.response?.content || "[No content]"}
</pre>
</CollapsibleSection>
</div>
</div>
)}
</Drawer>
</div>
);
}

View File

@@ -3,7 +3,6 @@
import { useState, Suspense } from "react";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import ProviderLimits from "./components/ProviderLimits";
import RequestDetailsTab from "./components/RequestDetailsTab";
export default function UsagePage() {
const [activeTab, setActiveTab] = useState("overview");
@@ -15,7 +14,6 @@ export default function UsagePage() {
{ value: "overview", label: "Overview" },
{ value: "logs", label: "Logger" },
{ value: "limits", label: "Limits" },
{ value: "details", label: "Details" },
]}
value={activeTab}
onChange={setActiveTab}
@@ -33,7 +31,6 @@ export default function UsagePage() {
<ProviderLimits />
</Suspense>
)}
{activeTab === "details" && <RequestDetailsTab />}
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { NextResponse } from "next/server";
import { getRequestDetailsDb } from "@/lib/requestDetailsDb";
import { getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
/**
* GET /api/usage/providers
* Returns list of unique providers from request details
*/
export async function GET() {
try {
const db = await getRequestDetailsDb();
const stmt = db.prepare(`
SELECT DISTINCT provider
FROM request_details
WHERE provider IS NOT NULL AND provider != ''
ORDER BY provider ASC
`);
const rows = stmt.all();
// Fetch all provider nodes to get names for custom providers
const providerNodes = await getProviderNodes();
const nodeMap = {};
for (const node of providerNodes) {
nodeMap[node.id] = node.name;
}
const providers = rows.map(row => {
const providerId = row.provider;
// Try to find name from various sources
let name = providerId;
// 1. Check if it's a custom provider node
if (nodeMap[providerId]) {
name = nodeMap[providerId];
}
// 2. Check predefined providers
else {
const providerConfig = getProviderByAlias(providerId) || AI_PROVIDERS[providerId];
if (providerConfig?.name) {
name = providerConfig.name;
}
}
return {
id: providerId,
name
};
});
return NextResponse.json({ providers });
} catch (error) {
console.error("[API] Failed to get providers:", error);
return NextResponse.json(
{ error: "Failed to fetch providers" },
{ status: 500 }
);
}
}

View File

@@ -1,57 +0,0 @@
import { NextResponse } from "next/server";
import { getRequestDetails } from "@/lib/usageDb";
/**
* GET /api/usage/request-details
* Query parameters: page, pageSize (1-100), provider, model, connectionId, status, startDate, endDate
*/
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page")) || 1;
const pageSize = parseInt(searchParams.get("pageSize")) || 20;
const provider = searchParams.get("provider");
const model = searchParams.get("model");
const connectionId = searchParams.get("connectionId");
const status = searchParams.get("status");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
if (page < 1) {
return NextResponse.json(
{ error: "Page must be >= 1" },
{ status: 400 }
);
}
if (pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ error: "PageSize must be between 1 and 100" },
{ status: 400 }
);
}
const filter = {
page,
pageSize
};
if (provider) filter.provider = provider;
if (model) filter.model = model;
if (connectionId) filter.connectionId = connectionId;
if (status) filter.status = status;
if (startDate) filter.startDate = startDate;
if (endDate) filter.endDate = endDate;
const result = await getRequestDetails(filter);
return NextResponse.json(result);
} catch (error) {
console.error("[API] Failed to get request details:", error);
return NextResponse.json(
{ error: "Failed to fetch request details" },
{ status: 500 }
);
}
}

View File

@@ -50,11 +50,7 @@ const defaultData = {
settings: {
cloudEnabled: false,
stickyRoundRobinLimit: 3,
requireLogin: true,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
observabilityMaxJsonSize: 1024
requireLogin: true
},
pricing: {} // NEW: pricing configuration
};
@@ -71,10 +67,6 @@ function cloneDefaultData() {
cloudEnabled: false,
stickyRoundRobinLimit: 3,
requireLogin: true,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
observabilityMaxJsonSize: 1024
},
pricing: {},
};

View File

@@ -1,499 +0,0 @@
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 || '{}')
};
}

View File

@@ -511,6 +511,3 @@ export async function getUsageStats() {
return stats;
}
// Re-export request details functions from new SQLite-based module
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";

View File

@@ -1,89 +0,0 @@
"use client";
import { useEffect } from "react";
import { cn } from "@/shared/utils/cn";
export default function Drawer({
isOpen,
onClose,
title,
children,
width = "md",
className
}) {
const widths = {
sm: "w-[400px]",
md: "w-[500px]",
lg: "w-[600px]",
xl: "w-[800px]",
full: "w-full",
};
// Lock body scroll when drawer is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Handle escape key
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity cursor-pointer"
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer panel */}
<div className={cn(
"absolute right-0 top-0 h-full bg-surface shadow-2xl flex flex-col",
"animate-in slide-in-from-right duration-200",
"border-l border-black/10 dark:border-white/10",
widths[width] || widths.md,
className
)}>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5 flex-shrink-0">
<div className="flex items-center gap-3">
{title && (
<h2 className="text-lg font-semibold text-text-main">
{title}
</h2>
)}
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
</div>
</div>
);
}

View File

@@ -1,146 +0,0 @@
"use client";
import { cn } from "@/shared/utils/cn";
import Button from "./Button";
export default function Pagination({
currentPage,
pageSize,
totalItems,
onPageChange,
onPageSizeChange,
className,
}) {
const totalPages = Math.ceil(totalItems / pageSize);
const startItem = totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0;
const endItem = Math.min(currentPage * pageSize, totalItems);
const getPageNumbers = () => {
const pages = [];
const showMax = 5;
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, start + showMax - 1);
if (end - start + 1 < showMax) {
start = Math.max(1, end - showMax + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
const pageNumbers = getPageNumbers();
return (
<div
className={cn(
"flex flex-col sm:flex-row items-center justify-between gap-4 py-4",
className
)}
>
{/* Info text */}
{totalItems > 0 && (
<div className="text-sm text-text-muted">
Showing <span className="font-medium text-text-main">{startItem}</span> to{" "}
<span className="font-medium text-text-main">{endItem}</span> of{" "}
<span className="font-medium text-text-main">{totalItems}</span> results
</div>
)}
<div className="flex items-center gap-4">
{/* Page size selector */}
{onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-text-muted">Rows:</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className={cn(
"h-9 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"cursor-pointer"
)}
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="w-9 px-0"
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</Button>
{pageNumbers[0] > 1 && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => onPageChange(1)}
className="w-9 px-0"
>
1
</Button>
{pageNumbers[0] > 2 && (
<span className="text-text-muted px-1">...</span>
)}
</>
)}
{pageNumbers.map((page) => (
<Button
key={page}
variant={currentPage === page ? "primary" : "ghost"}
size="sm"
onClick={() => onPageChange(page)}
className="w-9 px-0"
>
{page}
</Button>
))}
{pageNumbers[pageNumbers.length - 1] < totalPages && (
<>
{pageNumbers[pageNumbers.length - 1] < totalPages - 1 && (
<span className="text-text-muted px-1">...</span>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onPageChange(totalPages)}
className="w-9 px-0"
>
{totalPages}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="w-9 px-0"
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</Button>
</div>
)}
</div>
</div>
);
}