diff --git a/AGENTS.md b/AGENTS.md index 6fa58c42f5..b120f30bcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ All persistence uses SQLite through domain-specific modules: `backup.ts`, `proxies.ts`, `prompts.ts`, `webhooks.ts`, `detailedLogs.ts`, `domainState.ts`, `registeredKeys.ts`, `quotaSnapshots.ts`, `modelComboMappings.ts`, `cliToolState.ts`, `encryption.ts`, `readCache.ts`, `secrets.ts`, `stateReset.ts`, -`contextHandoffs.ts`. +`contextHandoffs.ts`, `compression.ts`. Schema migrations live in `db/migrations/` and run via `migrationRunner.ts`. `src/lib/localDb.ts` is a **re-export layer only** — never add logic there. @@ -142,7 +142,7 @@ Schema migrations live in `db/migrations/` and run via `migrationRunner.ts`. journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`. - **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions. Tracks applied migrations in `_omniroute_migrations` table. -- **Migrations**: 21 files (`001_initial_schema.sql` → `021_combo_call_log_targets.sql`). +- **Migrations**: 22 files (`001_initial_schema.sql` → `022_compression_settings.sql`). Each migration is idempotent and runs in a transaction. - **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations. Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`, @@ -285,7 +285,24 @@ Includes request/response translators with helpers for image handling. `autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`, `contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`, `emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`, -`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, and more. +`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, `compression/` (prompt +compression pipeline), and more. + +#### Prompt Compression Pipeline (`compression/`) + +Modular prompt compression that runs proactively before the existing reactive context manager. + +- **`strategySelector.ts`**: Selects compression mode based on config, combo overrides, auto-trigger + thresholds. Priority: combo override > auto-trigger > default mode > off. +- **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`, + `compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at + <1ms latency. +- **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens, + savings %, techniques used). +- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra), `CompressionConfig`, + `CompressionStats`, `CompressionResult`. +- DB settings in `src/lib/db/compression.ts`, API route at `src/app/api/settings/compression/`. +- Phase 1 implements lite mode only; standard/aggressive/ultra are placeholders for Phase 2. #### Combo Routing Engine (`combo.ts`) diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 301053cb04..69c591652d 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -53,7 +53,9 @@ function getRuntimePlatform(): string { } function getRuntimeArch(): string { - return typeof process !== "undefined" && typeof process.arch === "string" ? process.arch : "unknown"; + return typeof process !== "undefined" && typeof process.arch === "string" + ? process.arch + : "unknown"; } function getRuntimeVersion(): string { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9317c55d98..cf9bd81634 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1560,7 +1560,38 @@ export async function handleChatCore({ const allMessages = body?.messages || body?.input || body?.contents || body?.request?.contents || []; if (body && Array.isArray(allMessages) && allMessages.length > 0) { - const estimatedTokens = estimateTokens(JSON.stringify(allMessages)); + let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); + + // --- Modular Compression Pipeline (Phase 1: Lite mode) --- + // Runs BEFORE the existing reactive compressContext() to proactively reduce tokens. + try { + const { getCompressionSettings } = await import("../../src/lib/db/compression.ts"); + const { selectCompressionStrategy, applyCompression } = + await import("../services/compression/strategySelector.ts"); + const { trackCompressionStats } = await import("../services/compression/stats.ts"); + const config = await getCompressionSettings(); + const mode = selectCompressionStrategy(config, comboName ?? null, estimatedTokens); + if (mode !== "off") { + const result = applyCompression(body, mode, { model: effectiveModel }); + if (result.compressed && result.stats) { + body = result.body as typeof body; + estimatedTokens = result.stats.compressedTokens; + trackCompressionStats(result.stats); + log?.info?.( + "COMPRESSION", + `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` + ); + } + } + } catch (err) { + log?.warn?.( + "COMPRESSION", + "Compression pipeline error (non-fatal): " + + (err instanceof Error ? err.message : String(err)) + ); + } + // --- End Modular Compression Pipeline --- + let contextLimit = getTokenLimit(provider, effectiveModel); if (isCombo && comboName) { diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index 9dd5092ef7..b7b99308d0 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -48,6 +48,16 @@ - **`volumeDetector.ts`** — Detects request volume spikes; triggers rate-limit escalation or load-shedding. - **`contextHandoff.ts`** — Serializes/restores session context for agent handoff (A2A protocol). +### Prompt Compression Pipeline + +- **`compression/`** — Modular prompt compression running proactively before `contextManager.ts`. + - `strategySelector.ts` — Selects mode (off/lite/standard/aggressive/ultra) with combo overrides and auto-trigger. + - `lite.ts` — 5 lite techniques: whitespace collapse, system prompt dedup, tool result truncation, redundant removal, image URL placeholder. + - `stats.ts` — Per-request compression stats (original/compressed tokens, savings %, techniques). + - `types.ts` — Shared types (`CompressionMode`, `CompressionConfig`, `CompressionStats`, `CompressionResult`). + - `index.ts` — Barrel re-exports. + - Phase 1: lite mode only. Standard/aggressive/ultra = Phase 2. + ### Auto-Routing & Adaptive - **`autoCombo/`** — Auto-generates combo configs based on historical performance, cost, and latency. diff --git a/open-sse/services/chatgptImageCache.ts b/open-sse/services/chatgptImageCache.ts index 77f6759182..e6b5f8b874 100644 --- a/open-sse/services/chatgptImageCache.ts +++ b/open-sse/services/chatgptImageCache.ts @@ -67,10 +67,7 @@ function evictExpired(now = Date.now()): void { function evictUntilWithinLimits(maxBytes: number, incomingBytes: number): void { // Drop oldest until both the entry-count and total-byte caps are satisfied. // Map iteration is insertion-ordered so the first key is the oldest entry. - while ( - (cache.size >= MAX_ENTRIES || cacheBytes + incomingBytes > maxBytes) && - cache.size > 0 - ) { + while ((cache.size >= MAX_ENTRIES || cacheBytes + incomingBytes > maxBytes) && cache.size > 0) { const firstKey = cache.keys().next().value; if (!firstKey) break; deleteEntry(firstKey); @@ -122,9 +119,7 @@ export function getChatGptImageConversationContext( * saved chatgpt.com conversation node and actually edit the image instead * of generating an unrelated one from scratch. */ -export function findChatGptImageBySha256( - hash: string -): { id: string; entry: CachedImage } | null { +export function findChatGptImageBySha256(hash: string): { id: string; entry: CachedImage } | null { evictExpired(); const target = hash.toLowerCase(); for (const [id, entry] of cache.entries()) { diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts new file mode 100644 index 0000000000..d1f69eb6af --- /dev/null +++ b/open-sse/services/compression/index.ts @@ -0,0 +1,31 @@ +/** + * Compression Pipeline — Phase 1 (Lite mode) + * + * Modular prompt compression that runs BEFORE the existing reactive context manager. + * Lite mode: 5 techniques, ~10-15% token savings, <1ms latency. + */ + +// Types +export type { + CompressionMode, + CompressionConfig, + CompressionStats, + CompressionResult, +} from "./types"; +export { DEFAULT_COMPRESSION_CONFIG } from "./types"; + +// Lite compression techniques +export { + applyLiteCompression, + collapseWhitespace, + dedupSystemPrompt, + compressToolResults, + removeRedundantContent, + replaceImageUrls, +} from "./lite"; + +// Strategy selector +export { selectCompressionStrategy, getEffectiveMode, applyCompression } from "./strategySelector"; + +// Stats tracking +export { estimateCompressionTokens, createCompressionStats, trackCompressionStats } from "./stats"; diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts new file mode 100644 index 0000000000..bc64e13ef6 --- /dev/null +++ b/open-sse/services/compression/lite.ts @@ -0,0 +1,170 @@ +import type { CompressionResult, CompressionMode } from "./types"; +import { createCompressionStats } from "./stats"; + +interface Message { + role: string; + content: string | Array>; + [key: string]: unknown; +} + +interface ChatBody { + messages?: Message[]; + [key: string]: unknown; +} + +export function collapseWhitespace(body: ChatBody): { + body: ChatBody; + applied: boolean; +} { + if (!body.messages) return { body, applied: false }; + let applied = false; + const messages = body.messages.map((msg) => { + if (typeof msg.content !== "string") return msg; + const normalized = msg.content.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, ""); + if (normalized !== msg.content) applied = true; + return { ...msg, content: normalized }; + }); + return { body: { ...body, messages }, applied }; +} + +export function dedupSystemPrompt(body: ChatBody): { + body: ChatBody; + applied: boolean; +} { + if (!body.messages) return { body, applied: false }; + const seen = new Set(); + let applied = false; + const messages = body.messages.filter((msg) => { + if (msg.role !== "system" || typeof msg.content !== "string") return true; + const key = msg.content.trim().slice(0, 200); + if (seen.has(key)) { + applied = true; + return false; + } + seen.add(key); + return true; + }); + return { body: { ...body, messages }, applied }; +} + +export function compressToolResults(body: ChatBody): { + body: ChatBody; + applied: boolean; +} { + if (!body.messages) return { body, applied: false }; + const MAX_TOOL_LENGTH = 2000; + let applied = false; + const messages = body.messages.map((msg) => { + if (msg.role !== "tool" || typeof msg.content !== "string") return msg; + if (msg.content.length <= MAX_TOOL_LENGTH) return msg; + applied = true; + return { + ...msg, + content: msg.content.slice(0, MAX_TOOL_LENGTH) + "\n...[truncated]", + }; + }); + return { body: { ...body, messages }, applied }; +} + +export function removeRedundantContent(body: ChatBody): { + body: ChatBody; + applied: boolean; +} { + if (!body.messages) return { body, applied: false }; + let applied = false; + const messages: Message[] = []; + for (let i = 0; i < body.messages.length; i++) { + const msg = body.messages[i]; + const contentStr = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); + if ( + i > 0 && + body.messages[i - 1].role === msg.role && + typeof body.messages[i - 1].content === "string" && + body.messages[i - 1].content === contentStr + ) { + applied = true; + continue; + } + messages.push(msg); + } + return { body: { ...body, messages }, applied }; +} + +export function replaceImageUrls( + body: ChatBody, + model?: string +): { body: ChatBody; applied: boolean } { + if (!body.messages) return { body, applied: false }; + const isVisionModel = model && /\b(vision|gpt-4o|claude-3|gemini-1\.5|gemini-2)\b/i.test(model); + if (isVisionModel) return { body, applied: false }; + let applied = false; + const messages = body.messages.map((msg) => { + if (!Array.isArray(msg.content)) return msg; + const newContent = msg.content.map((part) => { + if ( + typeof part === "object" && + part !== null && + part.type === "image_url" && + typeof (part as Record).image_url === "object" && + ((part as Record).image_url as Record)?.url + ) { + const url = String( + ((part as Record).image_url as Record).url + ); + if (url.startsWith("data:image/")) { + applied = true; + const format = url.slice(url.indexOf("/") + 1, url.indexOf(";")) || "unknown"; + return { type: "text", text: `[image: ${format}]` }; + } + } + return part; + }); + return { ...msg, content: newContent }; + }); + return { body: { ...body, messages }, applied }; +} + +export function applyLiteCompression( + body: Record, + options?: { model?: string } +): CompressionResult { + const originalBody = body; + let current = body as ChatBody; + const techniquesApplied: string[] = []; + + const r1 = collapseWhitespace(current); + current = r1.body; + if (r1.applied) techniquesApplied.push("whitespace"); + + const r2 = dedupSystemPrompt(current); + current = r2.body; + if (r2.applied) techniquesApplied.push("system-dedup"); + + const r3 = compressToolResults(current); + current = r3.body; + if (r3.applied) techniquesApplied.push("tool-compress"); + + const r4 = removeRedundantContent(current); + current = r4.body; + if (r4.applied) techniquesApplied.push("redundant-remove"); + + const r5 = replaceImageUrls(current, options?.model); + current = r5.body; + if (r5.applied) techniquesApplied.push("image-placeholder"); + + const compressed = techniquesApplied.length > 0; + const stats = compressed + ? createCompressionStats( + originalBody, + current as Record, + "lite", + techniquesApplied + ) + : null; + + return { + body: current as Record, + compressed, + stats, + }; +} diff --git a/open-sse/services/compression/stats.ts b/open-sse/services/compression/stats.ts new file mode 100644 index 0000000000..5e84f0b22e --- /dev/null +++ b/open-sse/services/compression/stats.ts @@ -0,0 +1,38 @@ +import type { CompressionStats, CompressionMode } from "./types"; + +const CHARS_PER_TOKEN = 4; + +export function estimateCompressionTokens(text: string | object | null | undefined): number { + if (!text) return 0; + const str = typeof text === "string" ? text : JSON.stringify(text); + return Math.ceil(str.length / CHARS_PER_TOKEN); +} + +export function createCompressionStats( + originalBody: Record, + compressedBody: Record, + mode: CompressionMode, + techniquesUsed: string[] +): CompressionStats { + const originalTokens = estimateCompressionTokens(originalBody); + const compressedTokens = estimateCompressionTokens(compressedBody); + const savingsPercent = + originalTokens > 0 + ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 10000) / 100 + : 0; + return { + originalTokens, + compressedTokens, + savingsPercent, + techniquesUsed, + mode, + timestamp: Date.now(), + }; +} + +export function trackCompressionStats(stats: CompressionStats): void { + if (stats.originalTokens <= 0) return; + console.log( + `[COMPRESSION] mode=${stats.mode} tokens=${stats.originalTokens}->${stats.compressedTokens} savings=${stats.savingsPercent}% techniques=${stats.techniquesUsed.join(",")}` + ); +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts new file mode 100644 index 0000000000..0b89a32651 --- /dev/null +++ b/open-sse/services/compression/strategySelector.ts @@ -0,0 +1,51 @@ +import type { CompressionConfig, CompressionMode, CompressionResult } from "./types"; +import { applyLiteCompression } from "./lite"; + +export function checkComboOverride( + config: CompressionConfig, + comboId: string | null +): CompressionMode | null { + if (!comboId || !config.comboOverrides) return null; + return config.comboOverrides[comboId] ?? null; +} + +export function shouldAutoTrigger(config: CompressionConfig, estimatedTokens: number): boolean { + return config.autoTriggerTokens > 0 && estimatedTokens >= config.autoTriggerTokens; +} + +export function getEffectiveMode( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number +): CompressionMode { + if (!config.enabled) return "off"; + + const comboMode = checkComboOverride(config, comboId); + if (comboMode) return comboMode; + + if (shouldAutoTrigger(config, estimatedTokens)) return "lite"; + + return config.defaultMode; +} + +export function selectCompressionStrategy( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number +): CompressionMode { + return getEffectiveMode(config, comboId, estimatedTokens); +} + +export function applyCompression( + body: Record, + mode: CompressionMode, + options?: { model?: string } +): CompressionResult { + if (mode === "off") { + return { body, compressed: false, stats: null }; + } + if (mode === "lite") { + return applyLiteCompression(body, options); + } + return { body, compressed: false, stats: null }; +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts new file mode 100644 index 0000000000..4d37d2f9a9 --- /dev/null +++ b/open-sse/services/compression/types.ts @@ -0,0 +1,45 @@ +/** + * Compression Pipeline Types — Phase 1 (Lite mode) + * + * Modular prompt compression that runs BEFORE the existing reactive context manager. + */ + +/** Compression mode levels (only 'off' and 'lite' are active in Phase 1) */ +export type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; + +/** Compression configuration stored in DB */ +export interface CompressionConfig { + enabled: boolean; + defaultMode: CompressionMode; + autoTriggerTokens: number; + cacheMinutes: number; + preserveSystemPrompt: boolean; + comboOverrides: Record; +} + +/** Per-request compression statistics */ +export interface CompressionStats { + originalTokens: number; + compressedTokens: number; + savingsPercent: number; + techniquesUsed: string[]; + mode: CompressionMode; + timestamp: number; +} + +/** Result of a compression operation */ +export interface CompressionResult { + body: Record; + compressed: boolean; + stats: CompressionStats | null; +} + +/** Default compression config values */ +export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { + enabled: false, + defaultMode: "off", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 527774b13c..8c5ef39627 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1230,7 +1230,11 @@ async function fetchAntigravityAvailableModelsCached( const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId); const cached = _antigravityAvailableModelsCache.get(cacheKey); - if (!options.forceRefresh && cached && Date.now() - cached.fetchedAt < ANTIGRAVITY_MODELS_CACHE_TTL_MS) { + if ( + !options.forceRefresh && + cached && + Date.now() - cached.fetchedAt < ANTIGRAVITY_MODELS_CACHE_TTL_MS + ) { return cached.data; } @@ -1361,25 +1365,31 @@ async function probeAntigravityCreditBalance( const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId || accountId); const cached = _antigravityCreditProbeCache.get(cacheKey); - if (!options.forceRefresh && cached && Date.now() - cached.fetchedAt < ANTIGRAVITY_CREDIT_PROBE_TTL_MS) { + if ( + !options.forceRefresh && + cached && + Date.now() - cached.fetchedAt < ANTIGRAVITY_CREDIT_PROBE_TTL_MS + ) { return cached.data; } const inflight = _antigravityCreditProbeInflight.get(cacheKey); if (inflight) return inflight; - const promise = probeAntigravityCreditBalanceUncached(accessToken, accountId, projectId).then( - (data) => { - _antigravityCreditProbeCache.set(cacheKey, { data, fetchedAt: Date.now() }); - return data; - }, - (error) => { - _antigravityCreditProbeCache.set(cacheKey, { data: null, fetchedAt: Date.now() }); - throw error; - } - ).finally(() => { - _antigravityCreditProbeInflight.delete(cacheKey); - }); + const promise = probeAntigravityCreditBalanceUncached(accessToken, accountId, projectId) + .then( + (data) => { + _antigravityCreditProbeCache.set(cacheKey, { data, fetchedAt: Date.now() }); + return data; + }, + (error) => { + _antigravityCreditProbeCache.set(cacheKey, { data: null, fetchedAt: Date.now() }); + throw error; + } + ) + .finally(() => { + _antigravityCreditProbeInflight.delete(cacheKey); + }); _antigravityCreditProbeInflight.set(cacheKey, promise); return promise; @@ -1501,7 +1511,12 @@ async function getAntigravityUsage( // If no cached balance and credits mode is enabled, fire a minimal probe const creditsMode = getCreditsMode(); if ((options.forceRefresh || creditBalance === null) && creditsMode !== "off") { - creditBalance = await probeAntigravityCreditBalance(accessToken, accountId, projectId, options); + creditBalance = await probeAntigravityCreditBalance( + accessToken, + accountId, + projectId, + options + ); } const data = await fetchAntigravityAvailableModelsCached(accessToken, projectId, options); diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index fff2b31ec3..e2c71e54d6 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -183,15 +183,12 @@ export default function ApiManagerPageClient() { for (const key of apiKeys) { // Match analytics entry by key name (reliable across both systems) - const analyticsMatch = byApiKey.find( - (entry: any) => entry.apiKeyName === key.name - ); + const analyticsMatch = byApiKey.find((entry: any) => entry.apiKeyName === key.name); // The call-logs endpoint returns entries sorted by timestamp DESC, // so the first match is the most recent one. - const lastUsed = (logs || []).find( - (log: any) => log.apiKeyName === key.name - )?.timestamp || null; + const lastUsed = + (logs || []).find((log: any) => log.apiKeyName === key.name)?.timestamp || null; stats[key.id] = { totalRequests: analyticsMatch?.requests ?? 0, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index b5dbf270d0..396fdc63da 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -10,11 +10,7 @@ import { } from "@/lib/auth/managementPassword"; import { loginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { - checkLoginGuard, - clearLoginAttempts, - recordLoginFailure, -} from "@/server/auth/loginGuard"; +import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; // SECURITY: No hardcoded fallback — JWT_SECRET must be configured. if (!process.env.JWT_SECRET) { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 124c32ee4b..61dfe76020 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -65,7 +65,10 @@ import { type JsonRecord = Record; -const antigravityDiscoveryInflight = new Map>>(); +const antigravityDiscoveryInflight = new Map< + string, + Promise> +>(); function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -1636,7 +1639,11 @@ export async function GET( }); } - const remoteModels = await fetchAntigravityDiscoveryModelsCached(accessToken, connectionId, proxy); + const remoteModels = await fetchAntigravityDiscoveryModelsCached( + accessToken, + connectionId, + proxy + ); if (remoteModels.length > 0) { return buildApiDiscoveryResponse(remoteModels); } diff --git a/src/app/api/settings/compression/route.ts b/src/app/api/settings/compression/route.ts new file mode 100644 index 0000000000..7bc9ead411 --- /dev/null +++ b/src/app/api/settings/compression/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { getCompressionSettings, updateCompressionSettings } from "@/lib/db/compression"; + +const compressionModeSchema = z.enum(["off", "lite", "standard", "aggressive", "ultra"]); + +const compressionSettingsUpdateSchema = z + .object({ + enabled: z.boolean().optional(), + defaultMode: compressionModeSchema.optional(), + autoTriggerTokens: z.number().int().min(0).optional(), + cacheMinutes: z.number().int().min(1).max(60).optional(), + preserveSystemPrompt: z.boolean().optional(), + comboOverrides: z.record(z.string(), compressionModeSchema).optional(), + }) + .strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const settings = await getCompressionSettings(); + return NextResponse.json(settings); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(compressionSettingsUpdateSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const settings = await updateCompressionSettings( + validation.data as Parameters[0] + ); + return NextResponse.json(settings); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 1d14c1e14d..2057c7bf43 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -140,10 +140,15 @@ export async function GET(request: Request) { // ── Enrich entries with missing apiKeyName ────────────────────────── try { // Only run enrichment if there are actually NULL entries - const hasNull = db.prepare("SELECT 1 FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL LIMIT 1").get(); + const hasNull = db + .prepare( + "SELECT 1 FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL LIMIT 1" + ) + .get(); if (hasNull) { // Step 1: dominant key per connectionId from existing usage data - db.prepare(` + db.prepare( + ` UPDATE usage_history SET api_key_name = ( @@ -171,22 +176,29 @@ export async function GET(request: Request) { WHERE uh3.connection_id = usage_history.connection_id AND uh3.api_key_name IS NOT NULL AND uh3.api_key_name != '' ) - `).run(); + ` + ).run(); // Step 2 & 3: For still unresolved connections, check apiKeys config - const stillNull = db.prepare("SELECT DISTINCT connection_id FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL").all(); + const stillNull = db + .prepare( + "SELECT DISTINCT connection_id FROM usage_history WHERE (api_key_name IS NULL OR api_key_name = '') AND connection_id IS NOT NULL" + ) + .all(); if (stillNull.length > 0) { const { getApiKeys } = await import("@/lib/localDb"); const apiKeys = (await getApiKeys()) as any[]; - - const updateStmt = db.prepare("UPDATE usage_history SET api_key_name = ?, api_key_id = ? WHERE connection_id = ? AND (api_key_name IS NULL OR api_key_name = '')"); + + const updateStmt = db.prepare( + "UPDATE usage_history SET api_key_name = ?, api_key_id = ? WHERE connection_id = ? AND (api_key_name IS NULL OR api_key_name = '')" + ); const updateMany = db.transaction((updates: any[]) => { for (const u of updates) updateStmt.run(u.name, u.id, u.cid); }); - + const updates = []; const orphanIds = new Set(stillNull.map((r: any) => r.connection_id)); - + for (const ak of apiKeys) { const allowed = Array.isArray(ak.allowedConnections) ? ak.allowedConnections : []; const keyName = ak.name || ak.id; @@ -198,17 +210,23 @@ export async function GET(request: Request) { } } } - + if (orphanIds.size > 0) { const unrestrictedKeys = apiKeys.filter( - (ak: any) => !Array.isArray(ak.allowedConnections) || ak.allowedConnections.length === 0 + (ak: any) => + !Array.isArray(ak.allowedConnections) || ak.allowedConnections.length === 0 ); if (unrestrictedKeys.length > 0) { let bestKey = unrestrictedKeys[0]; let bestCount = -1; for (const uk of unrestrictedKeys) { - const countRow = db.prepare("SELECT COUNT(*) as c FROM usage_history WHERE api_key_name = ?").get(uk.name || uk.id) as any; - if (countRow.c > bestCount) { bestCount = countRow.c; bestKey = uk; } + const countRow = db + .prepare("SELECT COUNT(*) as c FROM usage_history WHERE api_key_name = ?") + .get(uk.name || uk.id) as any; + if (countRow.c > bestCount) { + bestCount = countRow.c; + bestKey = uk; + } } const fallbackName = bestKey.name || bestKey.id; const fallbackId = bestKey.id || null; @@ -217,11 +235,11 @@ export async function GET(request: Request) { } } } - + if (updates.length > 0) updateMany(updates); } } - } catch(e) { + } catch (e) { console.error("Failed to backfill missing api_key_name:", e); } @@ -322,13 +340,15 @@ export async function GET(request: Request) { heatmapStart.setTime(customStart.getTime()); } } - + // Heatmap needs its own whereClause if api keys are filtered const heatmapConditions = ["timestamp >= @heatmapStart"]; if (apiKeyWhere) heatmapConditions.push(apiKeyWhere); const heatmapParams: Record = { heatmapStart: heatmapStart.toISOString() }; if (apiKeyIds.length > 0) { - apiKeyIds.forEach((key, i) => { heatmapParams[`apiKey${i}`] = key; }); + apiKeyIds.forEach((key, i) => { + heatmapParams[`apiKey${i}`] = key; + }); } const heatmapRows = db @@ -535,7 +555,7 @@ export async function GET(request: Request) { ) : 0, avgLatencyMs: Math.round(Number(summaryRow?.avgLatencyMs || 0)), - totalCost: 0, + totalCost: 0, firstRequest: summaryRow?.firstRequest || "", lastRequest: summaryRow?.lastRequest || "", fallbackCount: Number(fallbackRow?.fallbacks || 0), @@ -772,10 +792,17 @@ export async function GET(request: Request) { const presetSinceIso = getRangeStartIso(presetRange); const presetConditions = []; const presetParams: Record = {}; - if (presetSinceIso) { presetConditions.push("timestamp >= @presetSince"); presetParams.presetSince = presetSinceIso; } - if (apiKeyWhere) { presetConditions.push(apiKeyWhere); Object.assign(presetParams, params); } + if (presetSinceIso) { + presetConditions.push("timestamp >= @presetSince"); + presetParams.presetSince = presetSinceIso; + } + if (apiKeyWhere) { + presetConditions.push(apiKeyWhere); + Object.assign(presetParams, params); + } - const presetWhere = presetConditions.length > 0 ? `WHERE ${presetConditions.join(" AND ")}` : ""; + const presetWhere = + presetConditions.length > 0 ? `WHERE ${presetConditions.join(" AND ")}` : ""; const presetModelRows = db .prepare( diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts new file mode 100644 index 0000000000..facaab214a --- /dev/null +++ b/src/lib/db/compression.ts @@ -0,0 +1,50 @@ +import { getDbInstance } from "./core"; +import { backupDbFile } from "./backup"; + +const DEFAULT_COMPRESSION_CONFIG = { + enabled: false, + defaultMode: "off" as const, + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; + +type CompressionConfig = typeof DEFAULT_COMPRESSION_CONFIG & { + comboOverrides: Record; +}; + +export async function getCompressionSettings(): Promise { + const db = getDbInstance(); + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'compression'").all(); + const settings: Record = { ...DEFAULT_COMPRESSION_CONFIG }; + for (const row of rows) { + const record = row as Record; + const key = typeof record.key === "string" ? record.key : null; + const rawValue = typeof record.value === "string" ? record.value : null; + if (!key || rawValue === null) continue; + try { + settings[key] = JSON.parse(rawValue); + } catch { + // skip malformed JSON + } + } + return settings as CompressionConfig; +} + +export async function updateCompressionSettings( + updates: Partial +): Promise { + const db = getDbInstance(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('compression', ?, ?)" + ); + const tx = db.transaction(() => { + for (const [key, value] of Object.entries(updates)) { + insert.run(key, JSON.stringify(value)); + } + }); + tx(); + backupDbFile("pre-write"); + return getCompressionSettings(); +} diff --git a/src/lib/db/migrations/034_compression_settings.sql b/src/lib/db/migrations/034_compression_settings.sql new file mode 100644 index 0000000000..1a63062c32 --- /dev/null +++ b/src/lib/db/migrations/034_compression_settings.sql @@ -0,0 +1,10 @@ +-- 034_compression_settings.sql +-- Insert default compression settings into key_value table (namespace='compression') +-- Uses INSERT OR IGNORE so existing user settings are never overwritten by migration replay. + +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'enabled', 'false'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'defaultMode', '"off"'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'autoTriggerTokens', '0'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'cacheMinutes', '5'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'preserveSystemPrompt', 'true'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'comboOverrides', '{}'); diff --git a/tests/integration/compression-pipeline.test.ts b/tests/integration/compression-pipeline.test.ts new file mode 100644 index 0000000000..99b4702458 --- /dev/null +++ b/tests/integration/compression-pipeline.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + getEffectiveMode, + applyCompression, +} from "../../open-sse/services/compression/strategySelector.ts"; +import { applyLiteCompression } from "../../open-sse/services/compression/lite.ts"; +import { + createCompressionStats, + estimateCompressionTokens, +} from "../../open-sse/services/compression/stats.ts"; +import type { CompressionConfig } from "../../open-sse/services/compression/types.ts"; + +const baseConfig: CompressionConfig = { + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; + +describe("Full Compression Pipeline", () => { + it("end-to-end: lite mode compresses whitespace and tracks stats", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + + // Step 1: Select strategy + const mode = selectCompressionStrategy(baseConfig, null, 100); + assert.equal(mode, "lite"); + + // Step 2: Apply compression + const result = applyCompression(body, mode); + assert.equal(result.compressed, true); + assert.ok(result.stats); + + // Step 3: Verify stats + assert.ok(result.stats!.originalTokens > 0); + assert.ok(result.stats!.compressedTokens > 0); + assert.ok(result.stats!.compressedTokens <= result.stats!.originalTokens); + assert.ok(result.stats!.savingsPercent >= 0); + assert.equal(result.stats!.mode, "lite"); + }); + + it("end-to-end: off mode returns unchanged body", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const offConfig = { ...baseConfig, enabled: false }; + const mode = selectCompressionStrategy(offConfig, null, 100); + assert.equal(mode, "off"); + + const result = applyCompression(body, mode); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + }); + + it("end-to-end: combo override selects lite for specific combo", () => { + const config: CompressionConfig = { + ...baseConfig, + defaultMode: "off" as const, + comboOverrides: { "my-combo": "lite" as const }, + }; + const body = { messages: [{ role: "user", content: "test\n\n\n\nmessage" }] }; + + const mode = selectCompressionStrategy(config, "my-combo", 100); + assert.equal(mode, "lite"); + + const result = applyCompression(body, mode); + assert.equal(result.compressed, true); + }); + + it("end-to-end: auto-trigger activates compression", () => { + const config: CompressionConfig = { + ...baseConfig, + defaultMode: "off" as const, + autoTriggerTokens: 50, + }; + const body = { messages: [{ role: "user", content: "x".repeat(1000) }] }; + + // Below threshold → off + const mode1 = getEffectiveMode(config, null, 10); + assert.equal(mode1, "off"); + + // Above threshold → lite + const mode2 = getEffectiveMode(config, null, 100); + assert.equal(mode2, "lite"); + }); + + it("lite compression + stats pipeline works together", () => { + const body = { + messages: [ + { role: "system", content: "Be helpful." }, + { role: "system", content: "Be helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + + const result = applyLiteCompression(body); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.ok(result.stats.savingsPercent > 0); + assert.ok(result.stats.techniquesUsed.length >= 1); + }); + + it("token estimation is consistent", () => { + const text = "hello world this is a test"; + const tokens = estimateCompressionTokens(text); + assert.equal(tokens, Math.ceil(text.length / 4)); + + const obj = { messages: [{ role: "user", content: text }] }; + const objTokens = estimateCompressionTokens(obj); + assert.equal(objTokens, Math.ceil(JSON.stringify(obj).length / 4)); + }); +}); diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 171a0d2b46..fadd60e8a0 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -8,12 +8,10 @@ const { CLI_COMPAT_TOGGLE_IDS, IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS, normalizeCliCompatProviderId, -} = - await import("../../src/shared/constants/cliCompatProviders.ts"); +} = await import("../../src/shared/constants/cliCompatProviders.ts"); const { CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts"); -const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } = await import( - "../../open-sse/config/cliFingerprints.ts" -); +const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } = + await import("../../open-sse/config/cliFingerprints.ts"); test("Amp CLI is registered as a guide-based CLI tool with shorthand mapping guidance", () => { const amp = CLI_TOOLS.amp; diff --git a/tests/unit/combo-stream-readiness-fallback.test.ts b/tests/unit/combo-stream-readiness-fallback.test.ts index ed8cdc1e28..7f1122a24e 100644 --- a/tests/unit/combo-stream-readiness-fallback.test.ts +++ b/tests/unit/combo-stream-readiness-fallback.test.ts @@ -71,10 +71,10 @@ async function applyStreamReadiness(response: Response): Promise { } function errorResponse(status: number, message: string): Response { - return new Response( - JSON.stringify({ error: { message, type: "upstream_error" } }), - { status, headers: { "Content-Type": "application/json" } } - ); + return new Response(JSON.stringify({ error: { message, type: "upstream_error" } }), { + status, + headers: { "Content-Type": "application/json" }, + }); } test("combo falls back when first model returns HTTP 200 zombie SSE stream", async () => { @@ -113,10 +113,7 @@ test("combo falls back when first model returns HTTP 200 zombie SSE stream", asy assert.deepEqual(calls, ["glm/zombie-model", "openai/gpt-5.4-mini"]); assert.ok( log.entries.some( - (e) => - e.level === "warn" && - e.tag === "COMBO" && - String(e.msg).includes("glm/zombie-model") + (e) => e.level === "warn" && e.tag === "COMBO" && String(e.msg).includes("glm/zombie-model") ), "combo should log warning for the failed model" ); @@ -190,11 +187,7 @@ test("combo retries 504 on same model before falling through (transient retry)", assert.equal(result.ok, true, "combo should succeed via fallback after retries"); const zombieCalls = calls.filter((c) => c === "glm/zombie"); - assert.equal( - zombieCalls.length, - 2, - "combo should retry zombie once before falling through" - ); + assert.equal(zombieCalls.length, 2, "combo should retry zombie once before falling through"); assert.ok(calls.includes("openai/gpt-5.4-mini"), "combo should reach fallback model"); }); diff --git a/tests/unit/compression/db.test.ts b/tests/unit/compression/db.test.ts new file mode 100644 index 0000000000..4d78a929c3 --- /dev/null +++ b/tests/unit/compression/db.test.ts @@ -0,0 +1,113 @@ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-compression-db-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = + await import("../../../src/lib/db/compression.ts"); + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterEach(() => { + core.resetDbInstance(); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +describe("getCompressionSettings", () => { + it("returns default settings structure", async () => { + const settings = await getCompressionSettings(); + assert.equal(typeof settings.enabled, "boolean"); + assert.equal(typeof settings.defaultMode, "string"); + assert.equal(typeof settings.autoTriggerTokens, "number"); + assert.equal(typeof settings.cacheMinutes, "number"); + assert.equal(typeof settings.preserveSystemPrompt, "boolean"); + assert.equal(typeof settings.comboOverrides, "object"); + }); + + it("has correct default values", async () => { + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, false); + assert.equal(settings.defaultMode, "off"); + assert.equal(settings.autoTriggerTokens, 0); + assert.equal(settings.cacheMinutes, 5); + assert.equal(settings.preserveSystemPrompt, true); + assert.deepEqual(settings.comboOverrides, {}); + }); +}); + +describe("updateCompressionSettings", () => { + it("updates enabled flag", async () => { + await updateCompressionSettings({ enabled: true } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, true); + // Reset + await updateCompressionSettings({ enabled: false } as any); + }); + + it("updates defaultMode", async () => { + await updateCompressionSettings({ defaultMode: "lite" } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.defaultMode, "lite"); + // Reset + await updateCompressionSettings({ defaultMode: "off" } as any); + }); + + it("updates autoTriggerTokens", async () => { + await updateCompressionSettings({ autoTriggerTokens: 5000 } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.autoTriggerTokens, 5000); + // Reset + await updateCompressionSettings({ autoTriggerTokens: 0 } as any); + }); + + it("updates multiple settings at once", async () => { + await updateCompressionSettings({ + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 1000, + cacheMinutes: 10, + } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, true); + assert.equal(settings.defaultMode, "lite"); + assert.equal(settings.autoTriggerTokens, 1000); + assert.equal(settings.cacheMinutes, 10); + // Reset all + await updateCompressionSettings({ + enabled: false, + defaultMode: "off", + autoTriggerTokens: 0, + cacheMinutes: 5, + } as any); + }); + + it("preserves unmodified settings", async () => { + const before = await getCompressionSettings(); + await updateCompressionSettings({ enabled: true } as any); + const after = await getCompressionSettings(); + assert.equal(after.enabled, true); + assert.equal(after.defaultMode, before.defaultMode); + assert.equal(after.cacheMinutes, before.cacheMinutes); + // Reset + await updateCompressionSettings({ enabled: false } as any); + }); +}); diff --git a/tests/unit/compression/lite.test.ts b/tests/unit/compression/lite.test.ts new file mode 100644 index 0000000000..c3119f2c5e --- /dev/null +++ b/tests/unit/compression/lite.test.ts @@ -0,0 +1,209 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyLiteCompression, + collapseWhitespace, + dedupSystemPrompt, + compressToolResults, + removeRedundantContent, + replaceImageUrls, +} from "../../../open-sse/services/compression/lite.ts"; + +describe("collapseWhitespace", () => { + it("collapses 3+ newlines to 2", () => { + const body = { messages: [{ role: "user", content: "hello\n\n\n\nworld" }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages![0].content as string, "hello\n\nworld"); + }); + + it("does not modify already-normal whitespace", () => { + const body = { messages: [{ role: "user", content: "hello\n\nworld" }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); + + it("trims trailing spaces", () => { + const body = { messages: [{ role: "user", content: "hello " }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages![0].content as string, "hello"); + }); + + it("returns unchanged when no messages", () => { + const body = {}; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); + + it("skips non-string content", () => { + const body = { messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); +}); + +describe("dedupSystemPrompt", () => { + it("removes duplicate system prompts", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hi" }, + ], + }; + const result = dedupSystemPrompt(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages!.length, 2); + }); + + it("keeps different system prompts", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "Be concise." }, + ], + }; + const result = dedupSystemPrompt(body); + assert.equal(result.applied, false); + assert.equal(result.body.messages!.length, 2); + }); + + it("returns unchanged when no messages", () => { + const result = dedupSystemPrompt({}); + assert.equal(result.applied, false); + }); +}); + +describe("compressToolResults", () => { + it("truncates long tool results", () => { + const longContent = "x".repeat(3000); + const body = { messages: [{ role: "tool", content: longContent }] }; + const result = compressToolResults(body); + assert.equal(result.applied, true); + const content = result.body.messages![0].content as string; + assert.ok(content.length < 3000); + assert.ok(content.includes("[truncated]")); + }); + + it("keeps short tool results unchanged", () => { + const body = { messages: [{ role: "tool", content: "short result" }] }; + const result = compressToolResults(body); + assert.equal(result.applied, false); + }); + + it("skips non-tool messages", () => { + const body = { messages: [{ role: "user", content: "x".repeat(3000) }] }; + const result = compressToolResults(body); + assert.equal(result.applied, false); + }); +}); + +describe("removeRedundantContent", () => { + it("removes consecutive duplicate messages", () => { + const body = { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: "hello" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages!.length, 1); + }); + + it("keeps non-duplicate messages", () => { + const body = { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: "world" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, false); + assert.equal(result.body.messages!.length, 2); + }); + + it("only removes same-role consecutive duplicates", () => { + const body = { + messages: [ + { role: "system", content: "hello" }, + { role: "user", content: "hello" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, false); + }); +}); + +describe("replaceImageUrls", () => { + it("replaces base64 images for non-vision models", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBOR" } }], + }, + ], + }; + const result = replaceImageUrls(body, "gpt-3.5-turbo"); + assert.equal(result.applied, true); + const content = result.body.messages![0].content as Array>; + assert.equal(content[0].type, "text"); + assert.ok((content[0].text as string).includes("[image:")); + }); + + it("keeps images for vision models", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBOR" } }], + }, + ], + }; + const result = replaceImageUrls(body, "gpt-4o"); + assert.equal(result.applied, false); + }); + + it("skips non-image content", () => { + const body = { + messages: [{ role: "user", content: "just text" }], + }; + const result = replaceImageUrls(body, "gpt-3.5-turbo"); + assert.equal(result.applied, false); + }); +}); + +describe("applyLiteCompression", () => { + it("applies all techniques that match", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.ok(result.stats.techniquesUsed.length >= 2); + assert.ok(result.stats.savingsPercent > 0); + }); + + it("returns no compression for clean input", () => { + const body = { + messages: [{ role: "user", content: "clean message" }], + }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + }); + + it("handles empty messages array", () => { + const body = { messages: [] }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, false); + }); +}); diff --git a/tests/unit/compression/stats.test.ts b/tests/unit/compression/stats.test.ts new file mode 100644 index 0000000000..203e766af3 --- /dev/null +++ b/tests/unit/compression/stats.test.ts @@ -0,0 +1,92 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + estimateCompressionTokens, + createCompressionStats, + trackCompressionStats, +} from "../../../open-sse/services/compression/stats.ts"; + +describe("estimateCompressionTokens", () => { + it("returns 0 for null", () => { + assert.equal(estimateCompressionTokens(null), 0); + }); + + it("returns 0 for undefined", () => { + assert.equal(estimateCompressionTokens(undefined), 0); + }); + + it("returns 0 for empty string", () => { + assert.equal(estimateCompressionTokens(""), 0); + }); + + it("estimates tokens from text (chars/4)", () => { + assert.equal(estimateCompressionTokens("hello world"), 3); + }); + + it("estimates tokens from object", () => { + const tokens = estimateCompressionTokens({ messages: [{ role: "user", content: "test" }] }); + assert.ok(tokens > 0); + }); + + it("handles long strings", () => { + const tokens = estimateCompressionTokens("x".repeat(400)); + assert.equal(tokens, 100); + }); +}); + +describe("createCompressionStats", () => { + it("calculates savings correctly", () => { + const original = { messages: [{ role: "user", content: "x".repeat(100) }] }; + const compressed = { messages: [{ role: "user", content: "x".repeat(80) }] }; + const origTokens = Math.ceil(JSON.stringify(original).length / 4); + const compTokens = Math.ceil(JSON.stringify(compressed).length / 4); + const expectedSavings = Math.round(((origTokens - compTokens) / origTokens) * 10000) / 100; + const stats = createCompressionStats(original, compressed, "lite", ["whitespace"]); + assert.equal(stats.originalTokens, origTokens); + assert.equal(stats.compressedTokens, compTokens); + assert.equal(stats.savingsPercent, expectedSavings); + assert.deepEqual(stats.techniquesUsed, ["whitespace"]); + assert.equal(stats.mode, "lite"); + assert.ok(stats.timestamp > 0); + }); + + it("handles zero original tokens", () => { + const original = {}; + const compressed = {}; + const stats = createCompressionStats(original, compressed, "off", []); + assert.equal(stats.savingsPercent, 0); + }); + + it("rounds savings to 2 decimal places", () => { + const original = { messages: [{ role: "user", content: "x".repeat(97) }] }; + const compressed = { messages: [{ role: "user", content: "x".repeat(80) }] }; + const stats = createCompressionStats(original, compressed, "lite", ["test"]); + assert.ok(Number.isFinite(stats.savingsPercent)); + }); +}); + +describe("trackCompressionStats", () => { + it("does not throw for zero tokens", () => { + const stats = { + originalTokens: 0, + compressedTokens: 0, + savingsPercent: 0, + techniquesUsed: [], + mode: "off" as const, + timestamp: Date.now(), + }; + assert.doesNotThrow(() => trackCompressionStats(stats)); + }); + + it("logs compression stats", () => { + const stats = { + originalTokens: 100, + compressedTokens: 80, + savingsPercent: 20, + techniquesUsed: ["whitespace"], + mode: "lite" as const, + timestamp: Date.now(), + }; + assert.doesNotThrow(() => trackCompressionStats(stats)); + }); +}); diff --git a/tests/unit/compression/strategySelector.test.ts b/tests/unit/compression/strategySelector.test.ts new file mode 100644 index 0000000000..510d27f827 --- /dev/null +++ b/tests/unit/compression/strategySelector.test.ts @@ -0,0 +1,125 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + getEffectiveMode, + applyCompression, + checkComboOverride, + shouldAutoTrigger, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts"; + +const baseConfig: CompressionConfig = { + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; + +describe("checkComboOverride", () => { + it("returns null when comboId is null", () => { + assert.equal(checkComboOverride(baseConfig, null), null); + }); + + it("returns null when comboOverrides is empty", () => { + assert.equal(checkComboOverride(baseConfig, "my-combo"), null); + }); + + it("returns mode when combo override exists", () => { + const config = { ...baseConfig, comboOverrides: { "my-combo": "off" as const } }; + assert.equal(checkComboOverride(config, "my-combo"), "off"); + }); + + it("returns null for non-existent combo", () => { + const config = { ...baseConfig, comboOverrides: { "other-combo": "lite" as const } }; + assert.equal(checkComboOverride(config, "my-combo"), null); + }); +}); + +describe("shouldAutoTrigger", () => { + it("returns false when autoTriggerTokens is 0", () => { + assert.equal(shouldAutoTrigger(baseConfig, 5000), false); + }); + + it("returns false when tokens below threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 500), false); + }); + + it("returns true when tokens at threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 1000), true); + }); + + it("returns true when tokens above threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 1500), true); + }); +}); + +describe("getEffectiveMode", () => { + it("returns off when not enabled", () => { + const config = { ...baseConfig, enabled: false }; + assert.equal(getEffectiveMode(config, null, 100), "off"); + }); + + it("returns default mode when no overrides", () => { + assert.equal(getEffectiveMode(baseConfig, null, 100), "lite"); + }); + + it("returns combo override mode when present", () => { + const config = { + ...baseConfig, + defaultMode: "off" as const, + comboOverrides: { "my-combo": "lite" as const }, + }; + assert.equal(getEffectiveMode(config, "my-combo", 100), "lite"); + }); + + it("returns lite when auto-trigger threshold reached", () => { + const config = { ...baseConfig, defaultMode: "off" as const, autoTriggerTokens: 1000 }; + assert.equal(getEffectiveMode(config, null, 1500), "lite"); + }); + + it("combo override takes precedence over auto-trigger", () => { + const config = { + ...baseConfig, + defaultMode: "off" as const, + autoTriggerTokens: 100, + comboOverrides: { "my-combo": "off" as const }, + }; + assert.equal(getEffectiveMode(config, "my-combo", 500), "off"); + }); +}); + +describe("selectCompressionStrategy", () => { + it("returns effective mode", () => { + assert.equal(selectCompressionStrategy(baseConfig, null, 100), "lite"); + }); +}); + +describe("applyCompression", () => { + it("returns unchanged body for off mode", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const result = applyCompression(body, "off"); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + assert.deepEqual(result.body, body); + }); + + it("applies lite compression for lite mode", () => { + const body = { messages: [{ role: "user", content: "test\n\n\n\nmessage" }] }; + const result = applyCompression(body, "lite"); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.equal(result.stats.mode, "lite"); + }); + + it("returns unchanged body for standard mode (Phase 2)", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const result = applyCompression(body, "standard"); + assert.equal(result.compressed, false); + }); +}); diff --git a/tests/unit/provider-header-profiles.test.ts b/tests/unit/provider-header-profiles.test.ts index 4383d5a262..df7e79b340 100644 --- a/tests/unit/provider-header-profiles.test.ts +++ b/tests/unit/provider-header-profiles.test.ts @@ -48,7 +48,10 @@ test("provider header profiles expose dedicated refresh, qwen, qoder, kiro and c const qwenHeaders = getQwenOauthHeaders(); assert.equal(qwenHeaders["User-Agent"], getQwenCliUserAgent()); - assert.equal(qwenHeaders["User-Agent"], `QwenCode/${QWEN_CLI_VERSION} (${process.platform}; ${process.arch})`); + assert.equal( + qwenHeaders["User-Agent"], + `QwenCode/${QWEN_CLI_VERSION} (${process.platform}; ${process.arch})` + ); assert.notEqual(qwenHeaders["User-Agent"], "QwenCode/0.15.3 (linux; x64)"); assert.equal(qwenHeaders["X-Dashscope-UserAgent"], getQwenCliUserAgent()); assert.equal(qwenHeaders["X-Stainless-Package-Version"], "5.11.0"); diff --git a/tests/unit/stream-readiness.test.ts b/tests/unit/stream-readiness.test.ts index 2e429921d9..a86c37dd31 100644 --- a/tests/unit/stream-readiness.test.ts +++ b/tests/unit/stream-readiness.test.ts @@ -100,7 +100,10 @@ test("ensureStreamReadiness preserves buffered chunks when stream starts", async test("ensureStreamReadiness returns 504 when no useful content arrives before timeout", async () => { const response = new Response( - streamFromChunks([": keepalive\n\n", `data: ${JSON.stringify({ type: "response.created" })}\n\n`], 20), + streamFromChunks( + [": keepalive\n\n", `data: ${JSON.stringify({ type: "response.created" })}\n\n`], + 20 + ), { status: 200, headers: { "Content-Type": "text/event-stream" } } ); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index bf17bb6546..7253465568 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -421,7 +421,9 @@ test("usage service manual Antigravity refresh bypasses usage TTL caches", async globalThis.fetch = async (url) => { const urlStr = String(url); if (urlStr.includes("loadCodeAssist")) { - return new Response(JSON.stringify({ cloudaicompanionProject: "ag-project" }), { status: 200 }); + return new Response(JSON.stringify({ cloudaicompanionProject: "ag-project" }), { + status: 200, + }); } if (urlStr.includes("streamGenerateContent")) {