feat(dashboard): add model/status/account/api-key filters to usage details tab
- Add apiKey column to requestDetails (schema v2 + migration 002) - Persist masked API key via buildRequestDetail across chatCore handlers - Add getRequestDetails apiKey filter + distinct models/apiKeys/statuses helpers - New /api/usage/filters endpoint returning grouped connections per provider - Group account dropdown by provider using <optgroup>; add Status column with success/error badge to the details table Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -538,6 +538,7 @@ export async function handleChatCore({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
@@ -676,6 +677,7 @@ export async function handleChatCore({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
|
||||
@@ -286,7 +286,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
provider, model, connectionId, apiKey,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
|
||||
@@ -57,11 +57,21 @@ export function extractUsageFromResponse(responseBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mask API keys before they reach the requestDetails data blob / DB column.
|
||||
// Only the prefix is kept — enough to distinguish keys without leaking them.
|
||||
export function maskApiKey(key) {
|
||||
if (!key || typeof key !== "string") return undefined;
|
||||
const trimmed = key.trim();
|
||||
if (trimmed.length <= 8) return trimmed.charAt(0) + "***";
|
||||
return trimmed.slice(0, 8) + "***";
|
||||
}
|
||||
|
||||
export function buildRequestDetail(base, overrides = {}) {
|
||||
return {
|
||||
provider: base.provider || "unknown",
|
||||
model: base.model || "unknown",
|
||||
connectionId: base.connectionId || undefined,
|
||||
apiKey: maskApiKey(base.apiKey),
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: base.latency || { ttft: 0, total: 0 },
|
||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
|
||||
@@ -230,6 +230,7 @@ export async function handleStreamingResponse({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
@@ -293,6 +294,7 @@ export function buildOnStreamComplete({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency,
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
|
||||
@@ -114,9 +114,19 @@ export default function RequestDetailsTab() {
|
||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
provider: "",
|
||||
model: "",
|
||||
status: "",
|
||||
connectionId: "",
|
||||
apiKey: "",
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState({
|
||||
models: [],
|
||||
apiKeys: [],
|
||||
statuses: [],
|
||||
connections: []
|
||||
});
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
@@ -131,6 +141,21 @@ export default function RequestDetailsTab() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFilterOptions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/filters");
|
||||
const data = await res.json();
|
||||
setFilterOptions({
|
||||
models: data.models || [],
|
||||
apiKeys: data.apiKeys || [],
|
||||
statuses: data.statuses || [],
|
||||
connections: data.connections || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch filter options:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDetails = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -139,6 +164,10 @@ export default function RequestDetailsTab() {
|
||||
pageSize: pagination.pageSize.toString()
|
||||
});
|
||||
if (filters.provider) params.append("provider", filters.provider);
|
||||
if (filters.model) params.append("model", filters.model);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.connectionId) params.append("connectionId", filters.connectionId);
|
||||
if (filters.apiKey) params.append("apiKey", filters.apiKey);
|
||||
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||
|
||||
@@ -156,7 +185,8 @@ export default function RequestDetailsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, [fetchProviders]);
|
||||
fetchFilterOptions();
|
||||
}, [fetchProviders, fetchFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
@@ -176,7 +206,7 @@ export default function RequestDetailsTab() {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({ provider: "", startDate: "", endDate: "" });
|
||||
setFilters({ provider: "", model: "", status: "", connectionId: "", apiKey: "", startDate: "", endDate: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -204,6 +234,98 @@ export default function RequestDetailsTab() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="model-filter" className="text-sm font-medium text-text-main">Model</label>
|
||||
<select
|
||||
id="model-filter"
|
||||
value={filters.model}
|
||||
onChange={(e) => setFilters({ ...filters, model: 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",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Models</option>
|
||||
{filterOptions.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="status-filter" className="text-sm font-medium text-text-main">Status</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: 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",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{filterOptions.statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="connection-filter" className="text-sm font-medium text-text-main">Account</label>
|
||||
<select
|
||||
id="connection-filter"
|
||||
value={filters.connectionId}
|
||||
onChange={(e) => setFilters({ ...filters, connectionId: 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",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Accounts</option>
|
||||
{filterOptions.connections.map((group) => (
|
||||
<optgroup key={group.provider} label={group.name}>
|
||||
{group.accounts.map((conn) => (
|
||||
<option key={conn.id} value={conn.id}>
|
||||
{conn.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="apikey-filter" className="text-sm font-medium text-text-main">API Key</label>
|
||||
<select
|
||||
id="apikey-filter"
|
||||
value={filters.apiKey}
|
||||
onChange={(e) => setFilters({ ...filters, apiKey: 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",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All API Keys</option>
|
||||
{filterOptions.apiKeys.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
|
||||
@@ -238,7 +360,7 @@ export default function RequestDetailsTab() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!filters.provider && !filters.startDate && !filters.endDate}
|
||||
disabled={!filters.provider && !filters.model && !filters.status && !filters.connectionId && !filters.apiKey && !filters.startDate && !filters.endDate}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
@@ -255,6 +377,7 @@ export default function RequestDetailsTab() {
|
||||
<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-left p-4 text-sm font-semibold text-text-main">Status</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">Cached</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</th>
|
||||
@@ -296,6 +419,20 @@ export default function RequestDetailsTab() {
|
||||
{getProviderName(detail.provider, providerNameCache)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
detail.status === "error"
|
||||
? "bg-red-500/10 text-red-600"
|
||||
: "bg-green-500/10 text-green-600"
|
||||
)}>
|
||||
<span className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
detail.status === "error" ? "bg-red-500" : "bg-green-500"
|
||||
)} />
|
||||
{detail.status === "error" ? "Error" : "Success"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getInputTokens(detail.tokens).toLocaleString()}
|
||||
</td>
|
||||
|
||||
66
src/app/api/usage/filters/route.js
Normal file
66
src/app/api/usage/filters/route.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getDistinctModels,
|
||||
getDistinctApiKeys,
|
||||
getDistinctStatuses,
|
||||
} from "@/lib/requestDetailsDb";
|
||||
import { getProviderConnections, getProviderNodes } from "@/lib/localDb";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
/**
|
||||
* GET /api/usage/filters
|
||||
* Returns option lists for the Usage → Details filter bar:
|
||||
* models: distinct model ids seen in requestDetails
|
||||
* apiKeys: distinct (masked) api keys seen in requestDetails
|
||||
* statuses: distinct status values (success/error)
|
||||
* connections: provider connections grouped by provider (for <optgroup>)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const [models, apiKeys, statuses, connections, nodes] = await Promise.all([
|
||||
getDistinctModels(),
|
||||
getDistinctApiKeys(),
|
||||
getDistinctStatuses(),
|
||||
getProviderConnections(),
|
||||
getProviderNodes(),
|
||||
]);
|
||||
|
||||
const nodeNameMap = {};
|
||||
for (const node of nodes || []) {
|
||||
if (node.id && node.name) nodeNameMap[node.id] = node.name;
|
||||
}
|
||||
|
||||
const byProvider = {};
|
||||
for (const c of connections) {
|
||||
const isCompatible =
|
||||
isOpenAICompatibleProvider(c.provider) ||
|
||||
isAnthropicCompatibleProvider(c.provider);
|
||||
const baseName = isCompatible
|
||||
? c.name || nodeNameMap[c.provider] || c.providerSpecificData?.nodeName
|
||||
: c.name;
|
||||
const providerName =
|
||||
getProviderByAlias(c.provider)?.name || AI_PROVIDERS[c.provider]?.name || c.provider || "unknown";
|
||||
const key = c.provider || "unknown";
|
||||
if (!byProvider[key]) {
|
||||
byProvider[key] = { provider: key, name: providerName, accounts: [] };
|
||||
}
|
||||
byProvider[key].accounts.push({
|
||||
id: c.id,
|
||||
label: baseName || providerName,
|
||||
});
|
||||
}
|
||||
|
||||
const connectionsList = Object.values(byProvider).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
return NextResponse.json({ models, apiKeys, statuses, connections: connectionsList });
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get usage filters:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch usage filters" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export async function GET(request) {
|
||||
const model = searchParams.get("model");
|
||||
const connectionId = searchParams.get("connectionId");
|
||||
const status = searchParams.get("status");
|
||||
const apiKey = searchParams.get("apiKey");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
@@ -43,6 +44,7 @@ export async function GET(request) {
|
||||
if (model) filter.model = model;
|
||||
if (connectionId) filter.connectionId = connectionId;
|
||||
if (status) filter.status = status;
|
||||
if (apiKey) filter.apiKey = apiKey;
|
||||
if (startDate) filter.startDate = startDate;
|
||||
if (endDate) filter.endDate = endDate;
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export {
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
|
||||
14
src/lib/db/migrations/002-request-details-apikey.js
Normal file
14
src/lib/db/migrations/002-request-details-apikey.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Adds an indexed apiKey column to requestDetails so the Details tab can
|
||||
// filter by (masked) API key without parsing the JSON data blob.
|
||||
export default {
|
||||
version: 2,
|
||||
name: "request-details-apikey",
|
||||
up(db) {
|
||||
// Idempotent: fresh DBs already get the column from TABLES schema.
|
||||
const existing = db.all(`PRAGMA table_info(requestDetails)`) || [];
|
||||
if (!existing.some((c) => c.name === "apiKey")) {
|
||||
db.exec(`ALTER TABLE requestDetails ADD COLUMN apiKey TEXT`);
|
||||
}
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)`);
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,9 @@
|
||||
// Each migration: { version: number, name: string, up(db): void }
|
||||
// Versions MUST be unique and monotonically increasing.
|
||||
import m001 from "./001-initial.js";
|
||||
import m002 from "./002-request-details-apikey.js";
|
||||
|
||||
export const MIGRATIONS = [m001].sort((a, b) => a.version - b.version);
|
||||
export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version);
|
||||
|
||||
export function latestVersion() {
|
||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||
|
||||
@@ -92,6 +92,7 @@ async function flushToDatabase() {
|
||||
connectionId: item.connectionId || null,
|
||||
timestamp: item.timestamp,
|
||||
status: item.status || null,
|
||||
apiKey: item.apiKey || null,
|
||||
latency: item.latency || {},
|
||||
tokens: item.tokens || {},
|
||||
request: truncateField(item.request, config.maxJsonSize),
|
||||
@@ -102,8 +103,8 @@ async function flushToDatabase() {
|
||||
};
|
||||
|
||||
db.run(
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, data = excluded.data`,
|
||||
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, stringifyJson(record)]
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, apiKey, data) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, apiKey = excluded.apiKey, data = excluded.data`,
|
||||
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, record.apiKey, stringifyJson(record)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ export async function getRequestDetails(filter = {}) {
|
||||
if (filter.model) { conds.push("model = ?"); params.push(filter.model); }
|
||||
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
||||
if (filter.apiKey) { conds.push("apiKey = ?"); params.push(filter.apiKey); }
|
||||
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
||||
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
|
||||
|
||||
@@ -181,6 +183,24 @@ export async function getDistinctProviders() {
|
||||
return rows.map((r) => r.provider);
|
||||
}
|
||||
|
||||
export async function getDistinctModels() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT model FROM requestDetails WHERE model IS NOT NULL AND model != '' ORDER BY model ASC`);
|
||||
return rows.map((r) => r.model);
|
||||
}
|
||||
|
||||
export async function getDistinctApiKeys() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT apiKey FROM requestDetails WHERE apiKey IS NOT NULL AND apiKey != '' ORDER BY apiKey ASC`);
|
||||
return rows.map((r) => r.apiKey);
|
||||
}
|
||||
|
||||
export async function getDistinctStatuses() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT status FROM requestDetails WHERE status IS NOT NULL AND status != '' ORDER BY status ASC`);
|
||||
return rows.map((r) => r.status);
|
||||
}
|
||||
|
||||
export async function getRequestDetailById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 2;
|
||||
|
||||
export const PRAGMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
@@ -144,6 +144,7 @@ export const TABLES = {
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
status: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
@@ -151,6 +152,7 @@ export const TABLES = {
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
@@ -4,4 +4,5 @@ export {
|
||||
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
||||
appendRequestLog, getRecentLogs,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
Reference in New Issue
Block a user