From e470d412581b4df8243919d64f58f3eb7bab72df Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 3 May 2026 15:36:05 +0700 Subject: [PATCH 1/5] feat(proxy): move proxy configuration to dedicated system page - Create /dashboard/system/proxy with HTTP/MITM/1proxy sub-tabs - Add proxy entry to SYSTEM_SIDEBAR_ITEMS in sidebarVisibility.ts - Include proxy in HIDEABLE_SIDEBAR_ITEM_IDS type array - Remove ProxyTab and MitmProxyTab imports and rendering from Settings - Remove mitm tab from Settings tabs array; Advanced now only contains PayloadRules and Cliproxyapi - Reuse existing ProxyTab, MitmProxyTab, and OneproxyTab components without functional changes - No redirects from old Settings URLs; navigation via sidebar only - Closes #1905 --- .../(dashboard)/dashboard/settings/page.tsx | 6 -- .../dashboard/system/proxy/page.tsx | 79 +++++++++++++++++++ src/shared/constants/sidebarVisibility.ts | 2 + 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/system/proxy/page.tsx diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 4077e8a04e9c..c272d0a97be9 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -9,7 +9,6 @@ import SystemStorageTab from "./components/SystemStorageTab"; import SecurityTab from "./components/SecurityTab"; import RoutingTab from "./components/RoutingTab"; import ComboDefaultsTab from "./components/ComboDefaultsTab"; -import ProxyTab from "./components/ProxyTab"; import AppearanceTab from "./components/AppearanceTab"; import ThinkingBudgetTab from "./components/ThinkingBudgetTab"; import SystemPromptTab from "./components/SystemPromptTab"; @@ -23,7 +22,6 @@ import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab"; import PayloadRulesTab from "./components/PayloadRulesTab"; import CompressionSettingsTab from "./components/CompressionSettingsTab"; import VisionBridgeSettingsTab from "./components/VisionBridgeSettingsTab"; -import MitmProxyTab from "./components/MitmProxyTab"; import ModelRoutingSection from "@/shared/components/ModelRoutingSection"; const tabs = [ @@ -33,7 +31,6 @@ const tabs = [ { id: "security", labelKey: "security", icon: "shield" }, { id: "routing", labelKey: "routing", icon: "route" }, { id: "resilience", labelKey: "resilience", icon: "electrical_services" }, - { id: "mitm", labelKey: "mitmProxy", icon: "lan" }, { id: "advanced", labelKey: "advanced", icon: "tune" }, ]; @@ -120,12 +117,9 @@ export default function SettingsPage() { {activeTab === "resilience" && } - {activeTab === "mitm" && } - {activeTab === "advanced" && (
-
)} diff --git a/src/app/(dashboard)/dashboard/system/proxy/page.tsx b/src/app/(dashboard)/dashboard/system/proxy/page.tsx new file mode 100644 index 000000000000..9ddf0c390c36 --- /dev/null +++ b/src/app/(dashboard)/dashboard/system/proxy/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { cn } from "@/shared/utils/cn"; +import ProxyTab from "@/app/(dashboard)/dashboard/settings/components/ProxyTab"; +import MitmProxyTab from "@/app/(dashboard)/dashboard/settings/components/MitmProxyTab"; +import OneproxyTab from "@/app/(dashboard)/dashboard/settings/components/OneproxyTab"; + +const subTabs = [ + { id: "http", labelKey: "httpProxy", icon: "dns" }, + { id: "mitm", labelKey: "mitmProxy", icon: "lan" }, + { id: "oneproxy", labelKey: "1proxy", icon: "public" }, +]; + +export default function ProxyPage() { + const t = useTranslations("settings"); + const [activeSubTab, setActiveSubTab] = useState("http"); + + return ( +
+
+
+
+ {subTabs.map((subTab) => ( + + ))} +
+
+ +
t.id === activeSubTab)?.labelKey || "proxy")} + > + {activeSubTab === "http" && ( +
+ +
+ )} + + {activeSubTab === "mitm" && ( +
+ +
+ )} + + {activeSubTab === "oneproxy" && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 9593b053854f..c5c1af138c8d 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -21,6 +21,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "audit", "webhooks", "health", + "proxy", "settings", "docs", "issues", @@ -85,6 +86,7 @@ const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "policy" }, { id: "webhooks", href: "/dashboard/webhooks", i18nKey: "webhooks", icon: "webhook" }, { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, + { id: "proxy", href: "/dashboard/system/proxy", i18nKey: "proxy", icon: "dns" }, { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, ]; From e6c9b3d730aa6c63d62c9800cded473cfd1520d4 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 3 May 2026 15:51:58 +0700 Subject: [PATCH 2/5] Merge main into feature/proxy-page-reorganization - Accept upstream versions of compression analytics files (both-added conflicts) - Resolve modified analytics/page.tsx to include CompressionAnalyticsTab import - Bring in migration 032_compression_analytics.sql - Bring in new compression analytics DB module and routes --- .../analytics/CompressionAnalyticsTab.tsx | 329 +++--------------- .../(dashboard)/dashboard/analytics/page.tsx | 6 +- .../dashboard/compression/page.tsx | 15 +- src/app/api/analytics/compression/route.ts | 30 +- src/app/api/compression/preview/route.ts | 111 +++--- src/lib/db/compressionAnalytics.ts | 169 +++------ .../migrations/032_compression_analytics.sql | 13 + src/lib/localDb.ts | 8 +- 8 files changed, 177 insertions(+), 504 deletions(-) create mode 100644 src/lib/db/migrations/032_compression_analytics.sql diff --git a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx index 184eb936db5d..fa6ec794bcc1 100644 --- a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx @@ -1,299 +1,80 @@ -/** - * Compression Analytics Tab - * - * Shows compression request stats from call_logs (request_type = 'compression'), - * mode breakdown, provider breakdown, and cost/token savings summary. - */ - "use client"; -import { useEffect, useState } from "react"; - -interface CompressionAnalyticsSummary { - totalRequests: number; - totalTokensSaved: number; - avgSavingsPct: number; - avgDurationMs: number; - byMode: Record; - byProvider: Record; - last24h: Array<{ hour: string; count: number; tokensSaved: number }>; -} - -function StatCard({ - icon, - label, - value, - sub, -}: { - icon: string; - label: string; - value: string | number; - sub?: string; -}) { - return ( -
-
- {icon} - {label} -
-
{value}
- {sub &&
{sub}
} -
- ); -} - -function ModeBar({ - mode, - count, - total, - tokensSaved, -}: { - mode: string; - count: number; - total: number; - tokensSaved: number; -}) { - const pct = total > 0 ? Math.round((count / total) * 100) : 0; - return ( -
-
- {mode} - - {count} requests · {tokensSaved.toLocaleString()} tokens saved - -
-
-
-
-
{pct}%
-
- ); -} - -function ProviderBar({ - provider, - count, - total, - tokensSaved, -}: { - provider: string; - count: number; - total: number; - tokensSaved: number; -}) { - const pct = total > 0 ? Math.round((count / total) * 100) : 0; - return ( -
-
- {provider} - - {count} requests · {tokensSaved.toLocaleString()} tokens saved - -
-
-
-
-
{pct}%
-
- ); -} +import { useState, useEffect } from "react"; +import { Card, Button } from "@/shared/components"; +import { useTranslations } from "next-intl"; +import type { CompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; export default function CompressionAnalyticsTab() { - const [stats, setStats] = useState(null); + const t = useTranslations("analytics"); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [since, setSince] = useState<"24h" | "7d" | "30d" | "all">("24h"); + const [summary, setSummary] = useState(null); + const [range, setRange] = useState("24h"); useEffect(() => { - fetch(`/api/analytics/compression?since=${since}`) - .then((r) => r.json()) - .then((d) => { - setStats(d); + fetch(`/api/analytics/compression?since=${range}`) + .then((res) => res.json()) + .then((data) => { + setSummary(data); setLoading(false); }) - .catch((e) => { - setError(e.message); - setLoading(false); - }); - }, [since]); + .catch(() => setLoading(false)); + }, [range]); if (loading) { return ( -
- progress_activity - Loading compression analytics… +
+ {[1, 2, 3, 4].map((i) => ( + +
+
+
+ ))}
); } - if (error || !stats) { - return ( -
- compress - {error || "No compression data yet."} -

- Compression requests will appear here after the first request via /v1/chat/completions - with compression enabled. -

-
- ); + if (!summary) { + return

{t("noData")}

; } - const modes = Object.entries(stats.byMode).sort(([, a], [, b]) => b.count - a.count); - const providers = Object.entries(stats.byProvider).sort(([, a], [, b]) => b.count - a.count); - - // Calculate max tokens for hourly chart scaling - const maxTokensPerHour = Math.max(...stats.last24h.map((h) => h.tokensSaved), 1); - const maxCountPerHour = Math.max(...stats.last24h.map((h) => h.count), 1); - return ( -
- {/* Time Range Selector */} -
- {(["24h", "7d", "30d", "all"] as const).map((range) => ( - - ))} -
- - {/* KPI Cards */} -
- - - - +
+ {/* Stat Cards */} +
+ +

{t("compressionTotalRequests")}

+

{summary.totalRequests}

+
+ +

{t("compressionTokensSaved")}

+

{summary.totalTokensSaved}

+
+ +

{t("compressionAvgSavings")}

+

+ {summary.avgSavingsPct.toFixed(1)}% +

+
- {/* Mode Breakdown */} - {modes.length > 0 && ( -
-

- tune - Mode Breakdown -

-
- {modes.map(([mode, data]) => ( - - ))} -
-
- )} - - {/* Provider Breakdown */} - {providers.length > 0 && ( -
-

- hub - Provider Breakdown -

-
- {providers.map(([prov, data]) => ( - - ))} -
-
- )} - - {/* Last 24h Hourly Chart (CSS-only height-based bars) */} - {stats.last24h.length > 0 && ( -
-

- show_chart - Last 24 Hours (Activity) -

-
- {stats.last24h.map((entry, idx) => { - const countPct = (entry.count / maxCountPerHour) * 100; - const tokenPct = (entry.tokensSaved / maxTokensPerHour) * 100; - return ( -
-
-
- {entry.count} -
-
-
- {entry.hour.substring(11, 13)} -
-
- ); - })} -
-
-
Max requests/hour: {maxCountPerHour}
-
Max tokens/hour: {maxTokensPerHour.toLocaleString()}
-
+ {/* Mode Distribution */} + +

{t("compressionByMode")}

+
+ {Object.entries(summary.byMode).map(([mode, saved]) => ( +
+
+
+
+ {mode} +
+ ))}
- )} - - {/* Empty state */} - {stats.totalRequests === 0 && ( -
- - compress - -

No compression data yet

-

- Use POST /v1/chat/completions with - compression configuration to start tracking compression analytics. -

-
- )} - - {/* Info note */} -
- info - - Compression analytics: Token savings tracked per mode (off, lite, - standard, aggressive, ultra) and provider. Hover over charts for details. Use the time - selector to view different time periods. - -
+
); } diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index b900d4351c49..e3dee73ff624 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -4,10 +4,10 @@ import { useState, Suspense } from "react"; import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components"; import EvalsTab from "../usage/components/EvalsTab"; import SearchAnalyticsTab from "./SearchAnalyticsTab"; -import CompressionAnalyticsTab from "./CompressionAnalyticsTab"; import DiversityScoreCard from "./components/DiversityScoreCard"; import ProviderUtilizationTab from "./ProviderUtilizationTab"; import ComboHealthTab from "./ComboHealthTab"; +import CompressionAnalyticsTab from "./CompressionAnalyticsTab"; import { useTranslations } from "next-intl"; export default function AnalyticsPage() { @@ -15,12 +15,12 @@ export default function AnalyticsPage() { const t = useTranslations("analytics"); const tabDescriptions: Record = { + compression: t("compressionTabDescription"), overview: t("overviewDescription"), evals: t("evalsDescription"), search: "Search request analytics — provider breakdown, cache hit rate, and cost tracking.", utilization: t("utilizationDescription"), comboHealth: t("comboHealthDescription"), - compression: t("compressionAnalyticsDescription"), }; return ( @@ -41,7 +41,7 @@ export default function AnalyticsPage() { { value: "search", label: "Search" }, { value: "utilization", label: t("utilization") }, { value: "comboHealth", label: t("comboHealth") }, - { value: "compression", label: t("compressionAnalyticsTitle") }, + { value: "compression", label: t("compression") }, ]} value={activeTab} onChange={setActiveTab} diff --git a/src/app/(dashboard)/dashboard/compression/page.tsx b/src/app/(dashboard)/dashboard/compression/page.tsx index 7f4c6363b62d..58e04122d215 100644 --- a/src/app/(dashboard)/dashboard/compression/page.tsx +++ b/src/app/(dashboard)/dashboard/compression/page.tsx @@ -1,18 +1,23 @@ "use client"; +import { Metadata } from "next"; +import { generateMetadata } from "@/shared/utils/metadata"; +import { useTranslations } from "next-intl"; import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; +export const metadata = generateMetadata("compression"); + export default function CompressionPage() { + const t = useTranslations("compression"); + return (

- compress - Compression + compress + {t("settingsTitle")}

-

- Configure context compression settings to reduce token usage and costs. -

+

{t("settingsDescription")}

diff --git a/src/app/api/analytics/compression/route.ts b/src/app/api/analytics/compression/route.ts index a81e6fef804f..6553b1a6d2b2 100644 --- a/src/app/api/analytics/compression/route.ts +++ b/src/app/api/analytics/compression/route.ts @@ -1,29 +1,19 @@ -/** - * GET /api/analytics/compression - * - * Returns aggregated compression analytics from the compression_analytics table. - * Supports ?since=24h|7d|30d|all (default: 24h). - */ - -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; -import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -export async function GET(req: Request) { - const authError = await requireManagementAuth(req); +export async function GET(request: NextRequest) { + const authError = await enforceApiKeyPolicy(request, "analytics"); if (authError) return authError; - try { - const url = new URL(req.url); - const sinceParam = url.searchParams.get("since") ?? "24h"; - const validSince = ["24h", "7d", "30d", "all"].includes(sinceParam) ? sinceParam : "24h"; - - const summary = getCompressionAnalyticsSummary(validSince === "all" ? undefined : validSince); + const { searchParams } = new URL(request.url); + const since = searchParams.get("since") || "24h"; + try { + const summary = getCompressionAnalyticsSummary(since); return NextResponse.json(summary); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error("[/api/analytics/compression]", msg); + } catch (error) { + console.error("Compression analytics error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/compression/preview/route.ts b/src/app/api/compression/preview/route.ts index 5b966ec3f2ee..36a48b247d7b 100644 --- a/src/app/api/compression/preview/route.ts +++ b/src/app/api/compression/preview/route.ts @@ -1,86 +1,57 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { z } from "zod"; -import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { applyCompression } from "@omniroute/open-sse/services/compression/strategySelector"; -import type { CompressionMode } from "@omniroute/open-sse/services/compression/types"; -const PreviewRequestSchema = z.object({ - messages: z - .array( - z.object({ - role: z.string(), - content: z.union([z.string(), z.array(z.unknown())]), - }) - ) - .min(1), +const previewSchema = z.object({ + messages: z.array(z.object({ role: z.string(), content: z.string() })), mode: z.enum(["off", "lite", "standard", "aggressive", "ultra"]), }); -function countTokens(text: string): number { - return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.33); +function roughTokenCount(text: string): number { + return Math.ceil((text.match(/\s+/g) || []).length * 1.3); } -function messagesToText(messages: Array<{ role: string; content: unknown }>): string { - return messages - .map((m) => { - const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content); - return `${m.role}: ${content}`; - }) - .join("\n"); -} - -export async function POST(req: Request) { - const policy = await enforceApiKeyPolicy(req, "settings"); - if (policy.rejection) return policy.rejection; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const parsed = PreviewRequestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid request", details: parsed.error.issues }, - { status: 400 } - ); - } - - const { messages, mode } = parsed.data; - const originalText = messagesToText(messages); - const originalTokens = countTokens(originalText); - +export async function POST(request: NextRequest) { try { - const start = Date.now(); - const requestBody = { messages }; - const result = await applyCompression(requestBody as Record, mode); - const durationMs = Date.now() - start; - - const compressedMessages = (result.body.messages ?? messages) as Array<{ - role: string; - content: unknown; - }>; - const compressedText = messagesToText(compressedMessages); - const compressedTokens = countTokens(compressedText); - const tokensSaved = Math.max(0, originalTokens - compressedTokens); - const savingsPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0; - const techniquesUsed: string[] = result.stats?.techniquesUsed ?? []; + const body = await request.json(); + const validation = validateBody(previewSchema, body); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { messages, mode } = validation.data; + const original = messages.map((m) => m.content).join("\n"); + const originalTokens = roughTokenCount(original); + + if (mode === "off") { + return NextResponse.json({ + original, + compressed: original, + originalTokens, + compressedTokens: originalTokens, + tokensSaved: 0, + savingsPct: 0, + techniquesUsed: [], + durationMs: 0, + }); + } + + // Placeholder for actual compression engine call + const compressed = original; // TODO: call actual compression + const compressedTokens = roughTokenCount(compressed); + const tokensSaved = originalTokens - compressedTokens; return NextResponse.json({ - original: originalText, - compressed: compressedText, + original, + compressed, originalTokens, compressedTokens, tokensSaved, - savingsPct, - techniquesUsed, - durationMs, + savingsPct: originalTokens > 0 ? (tokensSaved / originalTokens) * 100 : 0, + techniquesUsed: [mode], + durationMs: 0, }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error("[/api/compression/preview]", msg); - return NextResponse.json({ error: "Compression failed", details: msg }, { status: 500 }); + } catch (error) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); } } diff --git a/src/lib/db/compressionAnalytics.ts b/src/lib/db/compressionAnalytics.ts index f587a915ae42..35b4ec656490 100644 --- a/src/lib/db/compressionAnalytics.ts +++ b/src/lib/db/compressionAnalytics.ts @@ -3,146 +3,57 @@ import { getDbInstance } from "./core"; export interface CompressionAnalyticsRow { id?: number; timestamp: string; - combo_id?: string | null; - provider?: string | null; + combo_id?: string; + provider?: string; mode: string; original_tokens: number; compressed_tokens: number; tokens_saved: number; - duration_ms?: number | null; - request_id?: string | null; -} - -export interface CompressionAnalyticsSummary { - totalRequests: number; - totalTokensSaved: number; - avgSavingsPct: number; - avgDurationMs: number; - byMode: Record; - byProvider: Record; - last24h: Array<{ hour: string; count: number; tokensSaved: number }>; + duration_ms?: number; + request_id?: string; } export function insertCompressionAnalyticsRow(row: CompressionAnalyticsRow): void { const db = getDbInstance(); db.prepare( - ` - INSERT INTO compression_analytics (timestamp, combo_id, provider, mode, original_tokens, compressed_tokens, tokens_saved, duration_ms, request_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - ).run( - row.timestamp, - row.combo_id ?? null, - row.provider ?? null, - row.mode, - row.original_tokens, - row.compressed_tokens, - row.tokens_saved, - row.duration_ms ?? null, - row.request_id ?? null - ); + `INSERT INTO compression_analytics (timestamp, combo_id, provider, mode, original_tokens, compressed_tokens, tokens_saved, duration_ms, request_id) + VALUES (@timestamp, @combo_id, @provider, @mode, @original_tokens, @compressed_tokens, @tokens_saved, @duration_ms, @request_id)` + ).run(row); } -export function getCompressionAnalyticsSummary(since?: string): CompressionAnalyticsSummary { +export function getCompressionAnalyticsSummary(since?: string): { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + byMode: Record; + byProvider: Record; + last24h: Array<{ hour: string; count: number; tokensSaved: number }>; +} { const db = getDbInstance(); - - let cutoff: string | null = null; - if (since === "24h") { - cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - } else if (since === "7d") { - cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - } else if (since === "30d") { - cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - } - - const whereClause = cutoff ? "WHERE timestamp >= ?" : ""; - const params = cutoff ? [cutoff] : []; - - type ScalarRow = { total: number; totalSaved: number; avgPct: number; avgDur: number }; - const scalar = db - .prepare( - ` - SELECT - COUNT(*) as total, - COALESCE(SUM(tokens_saved), 0) as totalSaved, - COALESCE(AVG(CASE WHEN original_tokens > 0 THEN CAST(tokens_saved AS REAL) / original_tokens * 100 ELSE 0 END), 0) as avgPct, - COALESCE(AVG(duration_ms), 0) as avgDur - FROM compression_analytics ${whereClause} - ` - ) - .get(...params) as ScalarRow | undefined; - - const modeRows = db - .prepare( - ` - SELECT mode, COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved, - COALESCE(AVG(CASE WHEN original_tokens > 0 THEN CAST(tokens_saved AS REAL) / original_tokens * 100 ELSE 0 END), 0) as avgPct - FROM compression_analytics ${whereClause} - GROUP BY mode - ` - ) - .all(...params) as Array<{ mode: string; cnt: number; saved: number; avgPct: number }>; - - const byMode: Record = {}; - for (const r of modeRows) { - byMode[r.mode] = { count: r.cnt, tokensSaved: r.saved, avgSavingsPct: Math.round(r.avgPct) }; - } - - const provRows = db - .prepare( - ` - SELECT provider, COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved - FROM compression_analytics ${whereClause} - GROUP BY provider ORDER BY cnt DESC - ` - ) - .all(...params) as Array<{ provider: string | null; cnt: number; saved: number }>; - - const byProvider: Record = {}; - for (const r of provRows) { - const key = r.provider ?? "unknown"; - byProvider[key] = { count: r.cnt, tokensSaved: r.saved }; - } - - const last24hMap = new Map(); - const now = new Date(); - for (let i = 23; i >= 0; i--) { - const d = new Date(now.getTime() - i * 60 * 60 * 1000); - const hourStr = d.toISOString().substring(0, 14) + "00:00Z"; - last24hMap.set(hourStr, { hour: hourStr, count: 0, tokensSaved: 0 }); - } - - const hourRows = db - .prepare( - ` - SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour, - COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved - FROM compression_analytics - WHERE timestamp >= ? - GROUP BY hour ORDER BY hour ASC - ` - ) - .all(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString()) as Array<{ - hour: string; - cnt: number; - saved: number; - }>; - - for (const r of hourRows) { - if (last24hMap.has(r.hour)) { - last24hMap.set(r.hour, { hour: r.hour, count: r.cnt, tokensSaved: r.saved }); - } + const sinceClause = since ? `WHERE timestamp >= datetime('now', '-${since}')` : ""; + const rows = db + .prepare(`SELECT * FROM compression_analytics ${sinceClause}`) + .all() as CompressionAnalyticsRow[]; + const totalRequests = rows.length; + const totalTokensSaved = rows.reduce( + (sum: number, r: CompressionAnalyticsRow) => sum + r.tokens_saved, + 0 + ); + const avgSavingsPct = + totalRequests > 0 + ? rows.reduce( + (sum: number, r: CompressionAnalyticsRow) => + sum + (r.tokens_saved / r.original_tokens) * 100, + 0 + ) / totalRequests + : 0; + const byMode: Record = {}; + const byProvider: Record = {}; + for (const r of rows) { + const mode = r.mode; + const prov = r.provider || "unknown"; + byMode[mode] = (byMode[mode] || 0) + r.tokens_saved; + byProvider[prov] = (byProvider[prov] || 0) + r.tokens_saved; } - - const last24h = Array.from(last24hMap.values()); - - return { - totalRequests: scalar?.total ?? 0, - totalTokensSaved: scalar?.totalSaved ?? 0, - avgSavingsPct: Math.round(scalar?.avgPct ?? 0), - avgDurationMs: Math.round(scalar?.avgDur ?? 0), - byMode, - byProvider, - last24h, - }; + return { totalRequests, totalTokensSaved, avgSavingsPct, byMode, byProvider, last24h: [] }; } diff --git a/src/lib/db/migrations/032_compression_analytics.sql b/src/lib/db/migrations/032_compression_analytics.sql new file mode 100644 index 000000000000..ad7bdf5ffd8f --- /dev/null +++ b/src/lib/db/migrations/032_compression_analytics.sql @@ -0,0 +1,13 @@ +SELECT 1; +CREATE TABLE IF NOT EXISTS compression_analytics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + combo_id TEXT, + provider TEXT, + mode TEXT NOT NULL, + original_tokens INTEGER NOT NULL, + compressed_tokens INTEGER NOT NULL, + tokens_saved INTEGER NOT NULL, + duration_ms INTEGER, + request_id TEXT +); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index df440bb2a2cd..a650af084c8b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -340,12 +340,14 @@ export { setReasoningCache, getReasoningCache, deleteReasoningCache, - cleanupExpiredReasoning, - getReasoningCacheStats, - getReasoningCacheEntries, clearAllReasoningCache, } from "./db/reasoningCache"; +export { + insertCompressionAnalyticsRow, + getCompressionAnalyticsSummary, +} from "./db/compressionAnalytics"; + export type { ReasoningCacheEntry, ReasoningCacheStats } from "./db/reasoningCache"; export { From 819469ef8a8869538905d71132a60bc5f7199597 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 3 May 2026 15:54:21 +0700 Subject: [PATCH 3/5] fix(localDb): remove duplicate compressionAnalytics exports from re-merge --- src/lib/localDb.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index a650af084c8b..769a78d455cd 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -343,11 +343,6 @@ export { clearAllReasoningCache, } from "./db/reasoningCache"; -export { - insertCompressionAnalyticsRow, - getCompressionAnalyticsSummary, -} from "./db/compressionAnalytics"; - export type { ReasoningCacheEntry, ReasoningCacheStats } from "./db/reasoningCache"; export { From 8ef26ea2586d36924a2f9822ff2a44c3f81e5db1 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 3 May 2026 21:02:58 +0700 Subject: [PATCH 4/5] fix(compression): restore correct upstream implementations to match main - compressionAnalytics.ts: safe SQL with positional params, COALESCE/CASE for null/div-zero protection, proper last24h aggregation, avgDurationMs - preview/route.ts: restore applyCompression integration (remove placeholder) - analytics/compression/route.ts: restore correct summary shape with avgDurationMs and proper groupings - CompressionAnalyticsTab.tsx: restore correct interface alignment (byMode with count/avgSavingsPct objects) - compression/page.tsx & analytics/page.tsx: restore upstream versions - Remove outdated migration 032 (upstream uses 038) - Ensures PR #1907 only contains proxy reorganization changes, not regressions in compression --- .../analytics/CompressionAnalyticsTab.tsx | 329 +++++++++++++++--- .../(dashboard)/dashboard/analytics/page.tsx | 6 +- .../dashboard/compression/page.tsx | 15 +- src/app/api/analytics/compression/route.ts | 30 +- src/app/api/compression/preview/route.ts | 111 +++--- src/lib/db/compressionAnalytics.ts | 169 ++++++--- .../migrations/032_compression_analytics.sql | 13 - 7 files changed, 501 insertions(+), 172 deletions(-) delete mode 100644 src/lib/db/migrations/032_compression_analytics.sql diff --git a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx index fa6ec794bcc1..184eb936db5d 100644 --- a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx @@ -1,80 +1,299 @@ +/** + * Compression Analytics Tab + * + * Shows compression request stats from call_logs (request_type = 'compression'), + * mode breakdown, provider breakdown, and cost/token savings summary. + */ + "use client"; -import { useState, useEffect } from "react"; -import { Card, Button } from "@/shared/components"; -import { useTranslations } from "next-intl"; -import type { CompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; +import { useEffect, useState } from "react"; + +interface CompressionAnalyticsSummary { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + avgDurationMs: number; + byMode: Record; + byProvider: Record; + last24h: Array<{ hour: string; count: number; tokensSaved: number }>; +} + +function StatCard({ + icon, + label, + value, + sub, +}: { + icon: string; + label: string; + value: string | number; + sub?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function ModeBar({ + mode, + count, + total, + tokensSaved, +}: { + mode: string; + count: number; + total: number; + tokensSaved: number; +}) { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {mode} + + {count} requests · {tokensSaved.toLocaleString()} tokens saved + +
+
+
+
+
{pct}%
+
+ ); +} + +function ProviderBar({ + provider, + count, + total, + tokensSaved, +}: { + provider: string; + count: number; + total: number; + tokensSaved: number; +}) { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {provider} + + {count} requests · {tokensSaved.toLocaleString()} tokens saved + +
+
+
+
+
{pct}%
+
+ ); +} export default function CompressionAnalyticsTab() { - const t = useTranslations("analytics"); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [summary, setSummary] = useState(null); - const [range, setRange] = useState("24h"); + const [error, setError] = useState(null); + const [since, setSince] = useState<"24h" | "7d" | "30d" | "all">("24h"); useEffect(() => { - fetch(`/api/analytics/compression?since=${range}`) - .then((res) => res.json()) - .then((data) => { - setSummary(data); + fetch(`/api/analytics/compression?since=${since}`) + .then((r) => r.json()) + .then((d) => { + setStats(d); setLoading(false); }) - .catch(() => setLoading(false)); - }, [range]); + .catch((e) => { + setError(e.message); + setLoading(false); + }); + }, [since]); if (loading) { return ( -
- {[1, 2, 3, 4].map((i) => ( - -
-
-
- ))} +
+ progress_activity + Loading compression analytics…
); } - if (!summary) { - return

{t("noData")}

; + if (error || !stats) { + return ( +
+ compress + {error || "No compression data yet."} +

+ Compression requests will appear here after the first request via /v1/chat/completions + with compression enabled. +

+
+ ); } + const modes = Object.entries(stats.byMode).sort(([, a], [, b]) => b.count - a.count); + const providers = Object.entries(stats.byProvider).sort(([, a], [, b]) => b.count - a.count); + + // Calculate max tokens for hourly chart scaling + const maxTokensPerHour = Math.max(...stats.last24h.map((h) => h.tokensSaved), 1); + const maxCountPerHour = Math.max(...stats.last24h.map((h) => h.count), 1); + return ( -
- {/* Stat Cards */} -
- -

{t("compressionTotalRequests")}

-

{summary.totalRequests}

-
- -

{t("compressionTokensSaved")}

-

{summary.totalTokensSaved}

-
- -

{t("compressionAvgSavings")}

-

- {summary.avgSavingsPct.toFixed(1)}% -

-
+
+ {/* Time Range Selector */} +
+ {(["24h", "7d", "30d", "all"] as const).map((range) => ( + + ))}
- {/* Mode Distribution */} - -

{t("compressionByMode")}

-
- {Object.entries(summary.byMode).map(([mode, saved]) => ( -
-
-
-
- {mode} -
- ))} + {/* KPI Cards */} +
+ + + + +
+ + {/* Mode Breakdown */} + {modes.length > 0 && ( +
+

+ tune + Mode Breakdown +

+
+ {modes.map(([mode, data]) => ( + + ))} +
+
+ )} + + {/* Provider Breakdown */} + {providers.length > 0 && ( +
+

+ hub + Provider Breakdown +

+
+ {providers.map(([prov, data]) => ( + + ))} +
+
+ )} + + {/* Last 24h Hourly Chart (CSS-only height-based bars) */} + {stats.last24h.length > 0 && ( +
+

+ show_chart + Last 24 Hours (Activity) +

+
+ {stats.last24h.map((entry, idx) => { + const countPct = (entry.count / maxCountPerHour) * 100; + const tokenPct = (entry.tokensSaved / maxTokensPerHour) * 100; + return ( +
+
+
+ {entry.count} +
+
+
+ {entry.hour.substring(11, 13)} +
+
+ ); + })} +
+
+
Max requests/hour: {maxCountPerHour}
+
Max tokens/hour: {maxTokensPerHour.toLocaleString()}
+
- + )} + + {/* Empty state */} + {stats.totalRequests === 0 && ( +
+ + compress + +

No compression data yet

+

+ Use POST /v1/chat/completions with + compression configuration to start tracking compression analytics. +

+
+ )} + + {/* Info note */} +
+ info + + Compression analytics: Token savings tracked per mode (off, lite, + standard, aggressive, ultra) and provider. Hover over charts for details. Use the time + selector to view different time periods. + +
); } diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index e3dee73ff624..b900d4351c49 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -4,10 +4,10 @@ import { useState, Suspense } from "react"; import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components"; import EvalsTab from "../usage/components/EvalsTab"; import SearchAnalyticsTab from "./SearchAnalyticsTab"; +import CompressionAnalyticsTab from "./CompressionAnalyticsTab"; import DiversityScoreCard from "./components/DiversityScoreCard"; import ProviderUtilizationTab from "./ProviderUtilizationTab"; import ComboHealthTab from "./ComboHealthTab"; -import CompressionAnalyticsTab from "./CompressionAnalyticsTab"; import { useTranslations } from "next-intl"; export default function AnalyticsPage() { @@ -15,12 +15,12 @@ export default function AnalyticsPage() { const t = useTranslations("analytics"); const tabDescriptions: Record = { - compression: t("compressionTabDescription"), overview: t("overviewDescription"), evals: t("evalsDescription"), search: "Search request analytics — provider breakdown, cache hit rate, and cost tracking.", utilization: t("utilizationDescription"), comboHealth: t("comboHealthDescription"), + compression: t("compressionAnalyticsDescription"), }; return ( @@ -41,7 +41,7 @@ export default function AnalyticsPage() { { value: "search", label: "Search" }, { value: "utilization", label: t("utilization") }, { value: "comboHealth", label: t("comboHealth") }, - { value: "compression", label: t("compression") }, + { value: "compression", label: t("compressionAnalyticsTitle") }, ]} value={activeTab} onChange={setActiveTab} diff --git a/src/app/(dashboard)/dashboard/compression/page.tsx b/src/app/(dashboard)/dashboard/compression/page.tsx index 58e04122d215..7f4c6363b62d 100644 --- a/src/app/(dashboard)/dashboard/compression/page.tsx +++ b/src/app/(dashboard)/dashboard/compression/page.tsx @@ -1,23 +1,18 @@ "use client"; -import { Metadata } from "next"; -import { generateMetadata } from "@/shared/utils/metadata"; -import { useTranslations } from "next-intl"; import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; -export const metadata = generateMetadata("compression"); - export default function CompressionPage() { - const t = useTranslations("compression"); - return (

- compress - {t("settingsTitle")} + compress + Compression

-

{t("settingsDescription")}

+

+ Configure context compression settings to reduce token usage and costs. +

diff --git a/src/app/api/analytics/compression/route.ts b/src/app/api/analytics/compression/route.ts index 6553b1a6d2b2..a81e6fef804f 100644 --- a/src/app/api/analytics/compression/route.ts +++ b/src/app/api/analytics/compression/route.ts @@ -1,19 +1,29 @@ -import { NextRequest, NextResponse } from "next/server"; +/** + * GET /api/analytics/compression + * + * Returns aggregated compression analytics from the compression_analytics table. + * Supports ?since=24h|7d|30d|all (default: 24h). + */ + +import { NextResponse } from "next/server"; import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; -import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET(request: NextRequest) { - const authError = await enforceApiKeyPolicy(request, "analytics"); +export async function GET(req: Request) { + const authError = await requireManagementAuth(req); if (authError) return authError; - const { searchParams } = new URL(request.url); - const since = searchParams.get("since") || "24h"; - try { - const summary = getCompressionAnalyticsSummary(since); + const url = new URL(req.url); + const sinceParam = url.searchParams.get("since") ?? "24h"; + const validSince = ["24h", "7d", "30d", "all"].includes(sinceParam) ? sinceParam : "24h"; + + const summary = getCompressionAnalyticsSummary(validSince === "all" ? undefined : validSince); + return NextResponse.json(summary); - } catch (error) { - console.error("Compression analytics error:", error); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[/api/analytics/compression]", msg); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/src/app/api/compression/preview/route.ts b/src/app/api/compression/preview/route.ts index 36a48b247d7b..5b966ec3f2ee 100644 --- a/src/app/api/compression/preview/route.ts +++ b/src/app/api/compression/preview/route.ts @@ -1,57 +1,86 @@ -import { NextRequest, NextResponse } from "next/server"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { NextResponse } from "next/server"; import { z } from "zod"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { applyCompression } from "@omniroute/open-sse/services/compression/strategySelector"; +import type { CompressionMode } from "@omniroute/open-sse/services/compression/types"; -const previewSchema = z.object({ - messages: z.array(z.object({ role: z.string(), content: z.string() })), +const PreviewRequestSchema = z.object({ + messages: z + .array( + z.object({ + role: z.string(), + content: z.union([z.string(), z.array(z.unknown())]), + }) + ) + .min(1), mode: z.enum(["off", "lite", "standard", "aggressive", "ultra"]), }); -function roughTokenCount(text: string): number { - return Math.ceil((text.match(/\s+/g) || []).length * 1.3); +function countTokens(text: string): number { + return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.33); } -export async function POST(request: NextRequest) { +function messagesToText(messages: Array<{ role: string; content: unknown }>): string { + return messages + .map((m) => { + const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content); + return `${m.role}: ${content}`; + }) + .join("\n"); +} + +export async function POST(req: Request) { + const policy = await enforceApiKeyPolicy(req, "settings"); + if (policy.rejection) return policy.rejection; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = PreviewRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.issues }, + { status: 400 } + ); + } + + const { messages, mode } = parsed.data; + const originalText = messagesToText(messages); + const originalTokens = countTokens(originalText); + try { - const body = await request.json(); - const validation = validateBody(previewSchema, body); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - - const { messages, mode } = validation.data; - const original = messages.map((m) => m.content).join("\n"); - const originalTokens = roughTokenCount(original); - - if (mode === "off") { - return NextResponse.json({ - original, - compressed: original, - originalTokens, - compressedTokens: originalTokens, - tokensSaved: 0, - savingsPct: 0, - techniquesUsed: [], - durationMs: 0, - }); - } - - // Placeholder for actual compression engine call - const compressed = original; // TODO: call actual compression - const compressedTokens = roughTokenCount(compressed); - const tokensSaved = originalTokens - compressedTokens; + const start = Date.now(); + const requestBody = { messages }; + const result = await applyCompression(requestBody as Record, mode); + const durationMs = Date.now() - start; + + const compressedMessages = (result.body.messages ?? messages) as Array<{ + role: string; + content: unknown; + }>; + const compressedText = messagesToText(compressedMessages); + const compressedTokens = countTokens(compressedText); + const tokensSaved = Math.max(0, originalTokens - compressedTokens); + const savingsPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0; + const techniquesUsed: string[] = result.stats?.techniquesUsed ?? []; return NextResponse.json({ - original, - compressed, + original: originalText, + compressed: compressedText, originalTokens, compressedTokens, tokensSaved, - savingsPct: originalTokens > 0 ? (tokensSaved / originalTokens) * 100 : 0, - techniquesUsed: [mode], - durationMs: 0, + savingsPct, + techniquesUsed, + durationMs, }); - } catch (error) { - return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[/api/compression/preview]", msg); + return NextResponse.json({ error: "Compression failed", details: msg }, { status: 500 }); } } diff --git a/src/lib/db/compressionAnalytics.ts b/src/lib/db/compressionAnalytics.ts index 35b4ec656490..f587a915ae42 100644 --- a/src/lib/db/compressionAnalytics.ts +++ b/src/lib/db/compressionAnalytics.ts @@ -3,57 +3,146 @@ import { getDbInstance } from "./core"; export interface CompressionAnalyticsRow { id?: number; timestamp: string; - combo_id?: string; - provider?: string; + combo_id?: string | null; + provider?: string | null; mode: string; original_tokens: number; compressed_tokens: number; tokens_saved: number; - duration_ms?: number; - request_id?: string; + duration_ms?: number | null; + request_id?: string | null; } -export function insertCompressionAnalyticsRow(row: CompressionAnalyticsRow): void { - const db = getDbInstance(); - db.prepare( - `INSERT INTO compression_analytics (timestamp, combo_id, provider, mode, original_tokens, compressed_tokens, tokens_saved, duration_ms, request_id) - VALUES (@timestamp, @combo_id, @provider, @mode, @original_tokens, @compressed_tokens, @tokens_saved, @duration_ms, @request_id)` - ).run(row); -} - -export function getCompressionAnalyticsSummary(since?: string): { +export interface CompressionAnalyticsSummary { totalRequests: number; totalTokensSaved: number; avgSavingsPct: number; - byMode: Record; - byProvider: Record; + avgDurationMs: number; + byMode: Record; + byProvider: Record; last24h: Array<{ hour: string; count: number; tokensSaved: number }>; -} { +} + +export function insertCompressionAnalyticsRow(row: CompressionAnalyticsRow): void { const db = getDbInstance(); - const sinceClause = since ? `WHERE timestamp >= datetime('now', '-${since}')` : ""; - const rows = db - .prepare(`SELECT * FROM compression_analytics ${sinceClause}`) - .all() as CompressionAnalyticsRow[]; - const totalRequests = rows.length; - const totalTokensSaved = rows.reduce( - (sum: number, r: CompressionAnalyticsRow) => sum + r.tokens_saved, - 0 + db.prepare( + ` + INSERT INTO compression_analytics (timestamp, combo_id, provider, mode, original_tokens, compressed_tokens, tokens_saved, duration_ms, request_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + row.timestamp, + row.combo_id ?? null, + row.provider ?? null, + row.mode, + row.original_tokens, + row.compressed_tokens, + row.tokens_saved, + row.duration_ms ?? null, + row.request_id ?? null ); - const avgSavingsPct = - totalRequests > 0 - ? rows.reduce( - (sum: number, r: CompressionAnalyticsRow) => - sum + (r.tokens_saved / r.original_tokens) * 100, - 0 - ) / totalRequests - : 0; - const byMode: Record = {}; - const byProvider: Record = {}; - for (const r of rows) { - const mode = r.mode; - const prov = r.provider || "unknown"; - byMode[mode] = (byMode[mode] || 0) + r.tokens_saved; - byProvider[prov] = (byProvider[prov] || 0) + r.tokens_saved; +} + +export function getCompressionAnalyticsSummary(since?: string): CompressionAnalyticsSummary { + const db = getDbInstance(); + + let cutoff: string | null = null; + if (since === "24h") { + cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + } else if (since === "7d") { + cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + } else if (since === "30d") { + cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + } + + const whereClause = cutoff ? "WHERE timestamp >= ?" : ""; + const params = cutoff ? [cutoff] : []; + + type ScalarRow = { total: number; totalSaved: number; avgPct: number; avgDur: number }; + const scalar = db + .prepare( + ` + SELECT + COUNT(*) as total, + COALESCE(SUM(tokens_saved), 0) as totalSaved, + COALESCE(AVG(CASE WHEN original_tokens > 0 THEN CAST(tokens_saved AS REAL) / original_tokens * 100 ELSE 0 END), 0) as avgPct, + COALESCE(AVG(duration_ms), 0) as avgDur + FROM compression_analytics ${whereClause} + ` + ) + .get(...params) as ScalarRow | undefined; + + const modeRows = db + .prepare( + ` + SELECT mode, COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved, + COALESCE(AVG(CASE WHEN original_tokens > 0 THEN CAST(tokens_saved AS REAL) / original_tokens * 100 ELSE 0 END), 0) as avgPct + FROM compression_analytics ${whereClause} + GROUP BY mode + ` + ) + .all(...params) as Array<{ mode: string; cnt: number; saved: number; avgPct: number }>; + + const byMode: Record = {}; + for (const r of modeRows) { + byMode[r.mode] = { count: r.cnt, tokensSaved: r.saved, avgSavingsPct: Math.round(r.avgPct) }; + } + + const provRows = db + .prepare( + ` + SELECT provider, COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved + FROM compression_analytics ${whereClause} + GROUP BY provider ORDER BY cnt DESC + ` + ) + .all(...params) as Array<{ provider: string | null; cnt: number; saved: number }>; + + const byProvider: Record = {}; + for (const r of provRows) { + const key = r.provider ?? "unknown"; + byProvider[key] = { count: r.cnt, tokensSaved: r.saved }; } - return { totalRequests, totalTokensSaved, avgSavingsPct, byMode, byProvider, last24h: [] }; + + const last24hMap = new Map(); + const now = new Date(); + for (let i = 23; i >= 0; i--) { + const d = new Date(now.getTime() - i * 60 * 60 * 1000); + const hourStr = d.toISOString().substring(0, 14) + "00:00Z"; + last24hMap.set(hourStr, { hour: hourStr, count: 0, tokensSaved: 0 }); + } + + const hourRows = db + .prepare( + ` + SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour, + COUNT(*) as cnt, COALESCE(SUM(tokens_saved), 0) as saved + FROM compression_analytics + WHERE timestamp >= ? + GROUP BY hour ORDER BY hour ASC + ` + ) + .all(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString()) as Array<{ + hour: string; + cnt: number; + saved: number; + }>; + + for (const r of hourRows) { + if (last24hMap.has(r.hour)) { + last24hMap.set(r.hour, { hour: r.hour, count: r.cnt, tokensSaved: r.saved }); + } + } + + const last24h = Array.from(last24hMap.values()); + + return { + totalRequests: scalar?.total ?? 0, + totalTokensSaved: scalar?.totalSaved ?? 0, + avgSavingsPct: Math.round(scalar?.avgPct ?? 0), + avgDurationMs: Math.round(scalar?.avgDur ?? 0), + byMode, + byProvider, + last24h, + }; } diff --git a/src/lib/db/migrations/032_compression_analytics.sql b/src/lib/db/migrations/032_compression_analytics.sql deleted file mode 100644 index ad7bdf5ffd8f..000000000000 --- a/src/lib/db/migrations/032_compression_analytics.sql +++ /dev/null @@ -1,13 +0,0 @@ -SELECT 1; -CREATE TABLE IF NOT EXISTS compression_analytics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL, - combo_id TEXT, - provider TEXT, - mode TEXT NOT NULL, - original_tokens INTEGER NOT NULL, - compressed_tokens INTEGER NOT NULL, - tokens_saved INTEGER NOT NULL, - duration_ms INTEGER, - request_id TEXT -); From 1f7a0f1c34b33b5971e2c5fa3cad398a6c22b23f Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 3 May 2026 21:19:12 +0700 Subject: [PATCH 5/5] fix(compression): convert page to server component with explicit metadata export - Remove 'use client' directive (page has no client-side logic) - Add explicit metadata export for Next.js server component compliance - Resolves 'export metadata from use client component' build error in CI --- src/app/(dashboard)/dashboard/compression/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/(dashboard)/dashboard/compression/page.tsx b/src/app/(dashboard)/dashboard/compression/page.tsx index 7f4c6363b62d..979ecfad5555 100644 --- a/src/app/(dashboard)/dashboard/compression/page.tsx +++ b/src/app/(dashboard)/dashboard/compression/page.tsx @@ -1,7 +1,10 @@ -"use client"; - import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; +export const metadata = { + title: "Compression", + description: "Configure context compression settings to reduce token usage and costs.", +}; + export default function CompressionPage() { return (