diff --git a/.agents/workflows/generate-release.md b/.agents/workflows/generate-release.md index 6a4d723208..3747c54f84 100644 --- a/.agents/workflows/generate-release.md +++ b/.agents/workflows/generate-release.md @@ -96,7 +96,18 @@ Keep an empty `## [Unreleased]` section above it. // turbo ```bash -VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION" +VERSION=$(node -p "require('./package.json').version") +sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml +echo "✓ openapi.yaml → $VERSION" + +for dir in electron open-sse; do + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + (cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null) + echo "✓ $dir/package.json → $VERSION" + fi +done +# Re-run install to assert the workspace lockfile is updated +npm install ``` ### 6. Update README.md and i18n docs diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 46ae68bbb8..b3d0abdab9 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -57,17 +57,18 @@ jobs: - name: Resolve version and dist-tag id: resolve run: | - case "${{ github.event_name }}" in - workflow_dispatch|workflow_call) - VERSION="${{ inputs.version }}" - TAG="${{ inputs.tag }}" - ;; - release) + VERSION="${{ inputs.version }}" + TAG="${{ inputs.tag }}" + + if [ -z "$VERSION" ]; then + if [ "${{ github.event_name }}" = "release" ]; then VERSION="${GITHUB_REF_NAME}" - ;; - esac + fi + fi + # Strip v prefix if present VERSION="${VERSION#v}" + # Default dist-tag logic if [ -z "$TAG" ]; then if [[ "$VERSION" == *-* ]]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index fcbc2fa784..46196d0690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ ## [Unreleased] --- + +## [3.3.5] - 2026-03-30 + +### ✨ New Features + +- **Gemini Quota Tracking:** Added real-time Gemini CLI quota tracking via the `retrieveUserQuota` API (PR #825) +- **Cache Dashboard:** Enhanced the Cache Dashboard to display prompt cache metrics, 24h trends, and estimated cost savings (PR #824) + +### 🐛 Bug Fixes + +- **Token Accounting:** Included prompt cache tokens safely in historical usage inputs calculations for correct quota deductions (PR #822) +- **User Experience:** Removed invasive auto-opening OAuth modal loops on barren provider detailed pages (PR #820) +- **Dependency Updates:** Bumped and locked down dependencies for development and production trees including Next.js 16.2.1, Recharts, and TailwindCSS 4.2.2 (PR #826, #827) + +--- + ## [3.3.4] - 2026-03-30 ### ✨ New Features diff --git a/Dockerfile b/Dockerfile index f7ad521d4b..974b11dbf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-bookworm-slim AS builder WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends libsecret-1-0 \ + && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY package*.json ./ @@ -30,7 +30,7 @@ ENV NODE_OPTIONS="--max-old-space-size=256" # Data directory inside Docker — must match the volume mount in docker-compose.yml ENV DATA_DIR=/app/data RUN apt-get update \ - && apt-get install -y --no-install-recommends libsecret-1-0 \ + && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /app/data diff --git a/README.md b/README.md index 3b2fe3a90d..86d15526b5 100644 --- a/README.md +++ b/README.md @@ -882,6 +882,7 @@ Notes: - Quick Tunnel URLs are temporary and change after every restart. - Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`. +- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container. - Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one. **Using Docker Compose with Caddy (HTTPS Auto-TLS):** diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e637d8451d..4d8f27b1db 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.3.3 + version: 3.3.5 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package.json b/electron/package.json index 5b3a4ddc32..86095302fa 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "2.3.13", + "version": "3.3.5", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 7be61c8f6c..0228412b9f 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -226,23 +226,18 @@ export const REGISTRY: Record = { oauth: { clientIdEnv: "GEMINI_CLI_OAUTH_CLIENT_ID", clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", - clientSecretEnv: "GEMINI_CLI_OAUTH_CLIENT_SECRET", + clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", clientSecretDefault: "", }, models: [ - { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" }, + { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, - { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, + { id: "gemini-3.1-pro-preview-customtools", name: "Gemini 3.1 Pro Preview Custom Tools" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, - { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, - { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, ], }, diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index 99a947f3ff..ed1a5c9ba6 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -60,6 +60,12 @@ export { getSessionSnapshotInput, getSessionSnapshotOutput, getSessionSnapshotTool, + cacheStatsInput, + cacheStatsOutput, + cacheStatsTool, + cacheFlushInput, + cacheFlushOutput, + cacheFlushTool, } from "./tools.ts"; // A2A schemas diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 1557c0a136..46d03f325a 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -806,11 +806,73 @@ export const syncPricingTool: McpToolDefinition = { + name: "omniroute_cache_stats", + description: + "Returns cache statistics including semantic cache hit rate, prompt cache metrics by provider, and idempotency layer stats.", + inputSchema: cacheStatsInput, + outputSchema: cacheStatsOutput, + scopes: ["read:cache"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/cache"], +}; + +export const cacheFlushInput = z.object({ + signature: z.string().optional().describe("Specific cache signature to invalidate"), + model: z.string().optional().describe("Invalidate all entries for a specific model"), +}); + +export const cacheFlushOutput = z.object({ + ok: z.boolean(), + invalidated: z.number().optional(), + scope: z.string().optional(), +}); + +export const cacheFlushTool: McpToolDefinition = { + name: "omniroute_cache_flush", + description: + "Flush cache entries. Provide signature to invalidate a single entry, model to invalidate all entries for a model, or omit both to clear all.", + inputSchema: cacheFlushInput, + outputSchema: cacheFlushOutput, + scopes: ["write:cache"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/cache"], +}; + // ============ Tool Registry ============ /** All MCP tool definitions, ordered by phase then name */ export const MCP_TOOLS = [ - // Phase 1: Essential getHealthTool, listCombosTool, getComboMetricsTool, @@ -819,7 +881,6 @@ export const MCP_TOOLS = [ routeRequestTool, costReportTool, listModelsCatalogTool, - // Phase 2: Advanced simulateRouteTool, setBudgetGuardTool, setRoutingStrategyTool, @@ -830,6 +891,8 @@ export const MCP_TOOLS = [ explainRouteTool, getSessionSnapshotTool, syncPricingTool, + cacheStatsTool, + cacheFlushTool, ] as const; /** Essential tools only (Phase 1) */ diff --git a/open-sse/package.json b/open-sse/package.json index e1e62f47b7..ebff3729bd 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "0.0.1", + "version": "3.3.5", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/__tests__/volumeDetector.test.ts b/open-sse/services/__tests__/volumeDetector.test.ts index e01607399a..e29684f912 100644 --- a/open-sse/services/__tests__/volumeDetector.test.ts +++ b/open-sse/services/__tests__/volumeDetector.test.ts @@ -2,9 +2,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { detectVolumeSignals, recommendStrategyOverride } from "../volumeDetector"; -describe("volumeDetector", () => { - describe("detectVolumeSignals", () => { - it("detects simple single-message request", () => { +describe("volumeDetector", async () => { + describe("detectVolumeSignals", async () => { + it("detects simple single-message request", async () => { const body = { messages: [{ role: "user", content: "Hello" }], }; @@ -16,7 +16,7 @@ describe("volumeDetector", () => { assert.equal(signals.complexity, "trivial"); }); - it("detects tool-heavy request as high complexity", () => { + it("detects tool-heavy request as high complexity", async () => { const body = { messages: [{ role: "user", content: "Deploy the app to production" }], tools: [ @@ -31,17 +31,15 @@ describe("volumeDetector", () => { assert.equal(signals.complexity, "critical"); }); - it("detects browser keywords", () => { + it("detects browser keywords", async () => { const body = { - messages: [ - { role: "user", content: "Navigate to the page and take a screenshot" }, - ], + messages: [{ role: "user", content: "Navigate to the page and take a screenshot" }], }; const signals = detectVolumeSignals(body); assert.equal(signals.hasBrowser, true); }); - it("detects batch from multi-part content", () => { + it("detects batch from multi-part content", async () => { const parts = Array.from({ length: 20 }, (_, i) => ({ type: "text", text: `Item ${i}`, @@ -53,11 +51,9 @@ describe("volumeDetector", () => { assert.equal(signals.batchSize, 20); }); - it("detects security keywords as high complexity", () => { + it("detects security keywords as high complexity", async () => { const body = { - messages: [ - { role: "user", content: "Refactor the authentication module for production" }, - ], + messages: [{ role: "user", content: "Refactor the authentication module for production" }], }; const signals = detectVolumeSignals(body); assert.ok( @@ -67,16 +63,16 @@ describe("volumeDetector", () => { }); }); - describe("recommendStrategyOverride", () => { - it("recommends round-robin for large batches", () => { + describe("recommendStrategyOverride", async () => { + it("recommends round-robin for large batches", async () => { const signals = detectVolumeSignals({ input: Array(60).fill("item") }); - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, true); assert.equal(override.strategy, "round-robin"); assert.equal(override.preferEconomy, true); }); - it("recommends premium-first for browser tasks", () => { + it("recommends premium-first for browser tasks", async () => { const signals = { batchSize: 1, estimatedTokens: 500, @@ -85,13 +81,13 @@ describe("volumeDetector", () => { hasImages: false, complexity: "high" as const, }; - const override = recommendStrategyOverride(signals, "round-robin"); + const override = await recommendStrategyOverride(signals, "round-robin"); assert.equal(override.shouldOverride, true); assert.equal(override.strategy, "priority"); assert.equal(override.forcePremium, true); }); - it("flags economy for tiny requests without changing strategy", () => { + it("flags economy for tiny requests without changing strategy", async () => { const signals = { batchSize: 1, estimatedTokens: 100, @@ -100,12 +96,12 @@ describe("volumeDetector", () => { hasImages: false, complexity: "trivial" as const, }; - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, false); assert.equal(override.preferEconomy, true); }); - it("no override for normal medium requests", () => { + it("no override for normal medium requests", async () => { const signals = { batchSize: 1, estimatedTokens: 1000, @@ -114,7 +110,7 @@ describe("volumeDetector", () => { hasImages: false, complexity: "low" as const, }; - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, false); assert.equal(override.preferEconomy, false); }); diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index c1f1114a2e..43024f627d 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -159,13 +159,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record} Usage data with quotas */ export async function getUsageForProvider(connection) { - const { provider, accessToken, apiKey, providerSpecificData } = connection; + const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData); case "gemini-cli": - return await getGeminiUsage(accessToken); + return await getGeminiUsage(accessToken, providerSpecificData, projectId); case "antigravity": return await getAntigravityUsage(accessToken, undefined); case "claude": @@ -195,24 +195,22 @@ function parseResetTime(resetValue) { if (!resetValue) return null; try { - // If it's already a Date object + let date; if (resetValue instanceof Date) { - return resetValue.toISOString(); + date = resetValue; + } else if (typeof resetValue === "number") { + date = new Date(resetValue); + } else if (typeof resetValue === "string") { + date = new Date(resetValue); + } else { + return null; } - // If it's a number (Unix timestamp in milliseconds) - if (typeof resetValue === "number") { - return new Date(resetValue).toISOString(); - } + // Epoch-zero (1970-01-01) means no scheduled reset — treat as null + if (date.getTime() <= 0) return null; - // If it's a string (ISO date or parseable date string) - if (typeof resetValue === "string") { - return new Date(resetValue).toISOString(); - } - - return null; + return date.toISOString(); } catch (error) { - console.warn(`Failed to parse reset time: ${resetValue}`, error); return null; } } @@ -417,36 +415,180 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): return "GitHub Copilot"; } +// ── Gemini CLI subscription info cache ────────────────────────────────────── +// Prevents duplicate loadCodeAssist calls within the same quota cycle. +// Key: accessToken → { data, fetchedAt } +const _geminiCliSubCache = new Map(); +const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + /** - * Gemini CLI Usage (Google Cloud) + * Gemini CLI Usage — fetch per-model quota from Cloud Code Assist API. + * Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com), + * so this follows the same pattern as getAntigravityUsage(). */ -async function getGeminiUsage(accessToken) { +async function getGeminiUsage(accessToken, providerSpecificData?, connectionProjectId?) { + if (!accessToken) { + return { plan: "Free", message: "Gemini CLI access token not available." }; + } + try { - // Gemini CLI uses Google Cloud quotas - // Try to get quota info from Cloud Resource Manager + const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); + const projectId = + connectionProjectId || + providerSpecificData?.projectId || + subscriptionInfo?.cloudaicompanionProject || + null; + + const plan = getGeminiCliPlanLabel(subscriptionInfo); + + if (!projectId) { + return { plan, message: "Gemini CLI project ID not available." }; + } + + // Use retrieveUserQuota (same endpoint as Gemini CLI /stats command). + // Returns per-model buckets with remainingFraction and resetTime. const response = await fetch( - "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { + method: "POST", headers: { Authorization: `Bearer ${accessToken}`, - Accept: "application/json", + "Content-Type": "application/json", }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), } ); if (!response.ok) { - // Quota API may not be accessible, return generic message - return { - message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details.", - }; + return { plan, message: `Gemini CLI quota error (${response.status}).` }; } - return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." }; + const data = await response.json(); + const quotas: Record = {}; + + if (Array.isArray(data.buckets)) { + for (const bucket of data.buckets) { + if (!bucket.modelId || bucket.remainingFraction == null) continue; + + const remainingFraction = toNumber(bucket.remainingFraction, 0); + const remainingPercentage = remainingFraction * 100; + const QUOTA_NORMALIZED_BASE = 1000; + const total = QUOTA_NORMALIZED_BASE; + const remaining = Math.round(total * remainingFraction); + const used = Math.max(0, total - remaining); + + quotas[bucket.modelId] = { + used, + total, + resetAt: parseResetTime(bucket.resetTime), + remainingPercentage, + unlimited: false, + }; + } + } + + return { plan, quotas }; } catch (error) { - return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." }; + return { message: `Gemini CLI error: ${(error as Error).message}` }; } } +/** + * Get Gemini CLI subscription info (cached, 5 min TTL) + */ +async function getGeminiCliSubscriptionInfoCached(accessToken) { + const cacheKey = accessToken; + const cached = _geminiCliSubCache.get(cacheKey); + + if (cached && Date.now() - cached.fetchedAt < GEMINI_CLI_CACHE_TTL_MS) { + return cached.data; + } + + const data = await getGeminiCliSubscriptionInfo(accessToken); + _geminiCliSubCache.set(cacheKey, { data, fetchedAt: Date.now() }); + return data; +} + +/** + * Get Gemini CLI subscription info using correct headers. + */ +async function getGeminiCliSubscriptionInfo(accessToken) { + try { + const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + }); + + if (!response.ok) return null; + + return await response.json(); + } catch { + return null; + } +} + +/** + * Map Gemini CLI subscription tier to display label (same tiers as Antigravity). + */ +function getGeminiCliPlanLabel(subscriptionInfo) { + if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free"; + + let tierId = ""; + if (Array.isArray(subscriptionInfo.allowedTiers)) { + for (const tier of subscriptionInfo.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim().toUpperCase(); + break; + } + } + } + + if (!tierId) { + tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase(); + } + + if (tierId) { + if (tierId.includes("ULTRA")) return "Ultra"; + if (tierId.includes("PRO")) return "Pro"; + if (tierId.includes("ENTERPRISE")) return "Enterprise"; + if (tierId.includes("BUSINESS") || tierId.includes("STANDARD")) return "Business"; + if (tierId.includes("FREE") || tierId.includes("INDIVIDUAL") || tierId.includes("LEGACY")) + return "Free"; + } + + const tierName = + subscriptionInfo.currentTier?.name || + subscriptionInfo.currentTier?.displayName || + subscriptionInfo.subscriptionType || + subscriptionInfo.tier || + ""; + const upper = tierName.toUpperCase(); + + if (upper.includes("ULTRA")) return "Ultra"; + if (upper.includes("PRO")) return "Pro"; + if (upper.includes("ENTERPRISE")) return "Enterprise"; + if (upper.includes("STANDARD") || upper.includes("BUSINESS")) return "Business"; + if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free"; + + if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free"; + if (tierName) { + return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase(); + } + + return "Free"; +} + // ── Antigravity subscription info cache ────────────────────────────────────── // Prevents duplicate loadCodeAssist calls within the same quota cycle. // Key: truncated accessToken → { data, fetchedAt } diff --git a/open-sse/services/volumeDetector.ts b/open-sse/services/volumeDetector.ts index 1bdd806783..56a1e78450 100644 --- a/open-sse/services/volumeDetector.ts +++ b/open-sse/services/volumeDetector.ts @@ -141,10 +141,10 @@ export function detectVolumeSignals(body: Record): VolumeSignal * @param currentStrategy - The combo's configured strategy * @returns Override recommendation */ -export function recommendStrategyOverride( +export async function recommendStrategyOverride( signals: VolumeSignals, currentStrategy: string -): StrategyOverride { +): Promise { const noOverride: StrategyOverride = { shouldOverride: false, strategy: null, @@ -153,6 +153,18 @@ export function recommendStrategyOverride( reason: "no override needed", }; + // Check if adaptive routing is enabled globally + try { + const { getSettings } = await import("@/lib/localDb"); + const settings = await getSettings(); + if (!settings.adaptiveVolumeRouting) { + return noOverride; + } + } catch (error) { + console.error("Failed to check adaptiveVolumeRouting setting:", error); + return noOverride; + } + // Rule 1: Large batch → round-robin to distribute load if (signals.batchSize >= 50) { return { diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index af501d7495..3f18e2cf6d 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -72,12 +72,7 @@ const DETERMINISTIC_STRATEGIES: Set = new Set(["priority", /** * Providers that support prompt caching */ -const CACHING_PROVIDERS = new Set([ - "claude", - "anthropic", - "zai", - "qwen", // Alibaba Qwen Coding Plan International -]); +const CACHING_PROVIDERS = new Set(["claude", "anthropic", "zai", "qwen", "deepseek"]); /** * Detect if the client is Claude Code or another caching-aware client diff --git a/package-lock.json b/package-lock.json index 5bfe7593fc..d159aa3e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.3.4", + "version": "3.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.3.4", + "version": "3.3.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -20324,7 +20324,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "0.0.1" + "version": "3.3.5" } } } diff --git a/package.json b/package.json index d4574106a1..c884cf219f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.3.4", + "version": "3.3.5", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/a2a/page.tsx b/src/app/(dashboard)/dashboard/a2a/page.tsx index d0853252a9..521bb922ae 100644 --- a/src/app/(dashboard)/dashboard/a2a/page.tsx +++ b/src/app/(dashboard)/dashboard/a2a/page.tsx @@ -433,43 +433,78 @@ export default function A2ADashboardPage() { {t("tableTask")} {t("tableSkill")} {t("tableState")} + {t("tablePhase") || "FSM Status"} {t("tableUpdated")} {t("tableActions")} - {tasksData.tasks.map((task) => ( - - {task.id} - {task.skill} - - - {t(`state.${task.state}`)} - - - - {new Date(task.updatedAt).toLocaleString()} - - - - - - - ))} + {tasksData.tasks.map((task) => { + const fsmPhase = + task.metadata?.fsmPhase || (task.metadata?.workflowFSM as any)?.currentPhase; + let fsmBadgeColor = "bg-gray-500/15 text-gray-500"; + if (fsmPhase === "plan" || fsmPhase === "plan_review") + fsmBadgeColor = "bg-purple-500/15 text-purple-500"; + else if (fsmPhase === "execute") fsmBadgeColor = "bg-blue-500/15 text-blue-500"; + else if ( + ["code_review", "quality_review", "security", "test", "output_review"].includes( + fsmPhase + ) + ) + fsmBadgeColor = "bg-amber-500/15 text-amber-500"; + else if (fsmPhase === "done") fsmBadgeColor = "bg-green-500/15 text-green-500"; + else if (fsmPhase === "failed") fsmBadgeColor = "bg-red-500/15 text-red-500"; + + return ( + + {task.id} + {task.skill} + + + {t(`state.${task.state}`)} + + + + {fsmPhase ? ( + + {fsmPhase} + + ) : ( + + )} + + + {new Date(task.updatedAt).toLocaleString()} + + + + + + + ); + })} diff --git a/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx new file mode 100644 index 0000000000..de4aa724c6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +export default function DiversityScoreCard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const t = useTranslations("analytics"); + + useEffect(() => { + fetch("/api/analytics/diversity") + .then((res) => res.json()) + .then((json) => { + setData(json); + setLoading(false); + }) + .catch((err) => { + console.error(err); + setLoading(false); + }); + }, []); + + if (loading || !data) { + return ( + +
+
+ ); + } + + const scorePercentage = Math.round((data.score || 0) * 100); + + let riskColor = "text-green-500"; + let gaugeColor = "bg-green-500"; + let riskLabel = "Healthy Distribution"; + + if (scorePercentage < 40) { + riskColor = "text-red-500"; + gaugeColor = "bg-red-500"; + riskLabel = "High Vendor Lock-in Risk"; + } else if (scorePercentage < 70) { + riskColor = "text-amber-500"; + gaugeColor = "bg-amber-500"; + riskLabel = "Moderate Distribution"; + } + + return ( + +
+ pie_chart +

+ Provider Diversity Score +

+ + Shannon Entropy + +
+ +
+
+ + {scorePercentage}% + + {riskLabel} +
+ + {/* Simple CSS Donut */} +
+ + + + +
+
+ +
+

+ Provider Share +

+ + {Object.keys(data.providers || {}).length === 0 ? ( +
+ No recent usage data available. +
+ ) : ( +
+ {Object.entries(data.providers) + .sort(([, a]: any, [, b]: any) => b.share - a.share) + .slice(0, 4) // Top 4 providers + .map(([provider, stat]: [string, any]) => ( +
+
+ + {provider} + + + {Math.round(stat.share * 100)}% + +
+
+
+
+
+ ))} +
+ )} +
+ +
+ Window: {data.windowSize} reqs + Based on Last {Math.round(data.ttlMs / 60000)} mins +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index 3f97518466..87aa0f69e6 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -4,6 +4,7 @@ import { useState, Suspense } from "react"; import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components"; import EvalsTab from "../usage/components/EvalsTab"; import SearchAnalyticsTab from "./SearchAnalyticsTab"; +import DiversityScoreCard from "./components/DiversityScoreCard"; import { useTranslations } from "next-intl"; export default function AnalyticsPage() { @@ -38,9 +39,14 @@ export default function AnalyticsPage() { /> {activeTab === "overview" && ( - }> - - +
+
+ +
+ }> + + +
)} {activeTab === "evals" && } {activeTab === "search" && } diff --git a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx new file mode 100644 index 0000000000..728897e1a7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; + +interface ConfigDiff { + added: string[]; + removed: string[]; + changed: Array<{ key: string; from: any; to: any }>; + isEmpty: boolean; +} + +interface AuditEntry { + id: string; + timestamp: string; + action: string; + target: string; + targetId: string; + targetName: string; + source: string; + before: any; + after: any; + diff: ConfigDiff; + note: string | null; +} + +export default function ConfigAuditViewer() { + const t = useTranslations("logs"); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedEntry, setSelectedEntry] = useState(null); + + useEffect(() => { + fetchLogs(); + }, []); + + const fetchLogs = async () => { + setLoading(true); + try { + const res = await fetch("/api/audit"); + const data = await res.json(); + setEntries(data.entries || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const getActionColor = (action: string) => { + switch (action) { + case "create": + return "text-green-400 bg-green-400/10 border-green-500/20"; + case "update": + return "text-blue-400 bg-blue-400/10 border-blue-500/20"; + case "delete": + return "text-red-400 bg-red-400/10 border-red-500/20"; + default: + return "text-gray-400 bg-gray-400/10 border-gray-500/20"; + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (entries.length === 0) { + return ( +
+ + + +

No Configuration Audit Logs found.

+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
TimestampActionTargetResourceSourceDetails
+ {new Date(entry.timestamp).toLocaleString()} + + + {entry.action} + + + {entry.target} + + {entry.targetName} + + {entry.source} + + +
+
+ + {selectedEntry && ( +
+
+ {/* Modal Header */} +
+
+

+ {selectedEntry.action} {selectedEntry.target} +

+

+ ID: {selectedEntry.targetId} • {selectedEntry.targetName} +

+
+ +
+ + {/* Modal Content */} +
+ {selectedEntry.note && ( +
+ 📝 {selectedEntry.note} +
+ )} + + {selectedEntry.diff?.isEmpty ? ( +
+ No changes detected in Diff +
+ ) : ( +
+ {/* Added Keys */} + {selectedEntry.diff?.added?.length > 0 && ( +
+

+ ++ Added Properties +

+
+                        {JSON.stringify(
+                          selectedEntry.diff.added.reduce(
+                            (acc, key) => ({ ...acc, [key]: selectedEntry.after?.[key] }),
+                            {}
+                          ),
+                          null,
+                          2
+                        )}
+                      
+
+ )} + + {/* Removed Keys */} + {selectedEntry.diff?.removed?.length > 0 && ( +
+

+ -- Removed Properties +

+
+                        {JSON.stringify(
+                          selectedEntry.diff.removed.reduce(
+                            (acc, key) => ({ ...acc, [key]: selectedEntry.before?.[key] }),
+                            {}
+                          ),
+                          null,
+                          2
+                        )}
+                      
+
+ )} + + {/* Changed Keys */} + {selectedEntry.diff?.changed?.length > 0 && ( +
+

+ ~ Modified Properties +

+
+ {selectedEntry.diff.changed.map((change, idx) => ( +
+
+ {change.key} +
+
+
+
+ Before +
+
+                                  {JSON.stringify(change.from, null, 2)}
+                                
+
+
+
+ After +
+
+                                  {JSON.stringify(change.to, null, 2)}
+                                
+
+
+
+ ))} +
+
+ )} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx new file mode 100644 index 0000000000..1c52420069 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState, useEffect } from "react"; +import ConfigAuditViewer from "./ConfigAuditViewer"; + +export default function ConfigAuditPage() { + const [summary, setSummary] = useState(null); + + useEffect(() => { + fetch("/api/audit?summary=true") + .then((res) => res.json()) + .then((data) => setSummary(data)) + .catch((err) => console.error(err)); + }, []); + + return ( +
+
+
+

+ Configuration Audit +

+

+ Track and diff changes made to routing policies, combos, and connections. +

+
+ {summary && ( +
+
+ Total Audits + + {summary.totalEntries} + +
+
+ )} +
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx b/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx new file mode 100644 index 0000000000..66792dcbfa --- /dev/null +++ b/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface CacheEntry { + id: string; + signature: string; + model: string; + hit_count: number; + tokens_saved: number; + created_at: string; + expires_at: string; +} + +interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export default function CacheEntriesTab() { + const t = useTranslations("cache"); + const [entries, setEntries] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: 20, + total: 0, + totalPages: 0, + }); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [deleting, setDeleting] = useState(null); + + const fetchEntries = useCallback( + async (page = 1) => { + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), limit: String(pagination.limit) }); + if (search) params.set("search", search); + + const res = await fetch(`/api/cache/entries?${params}`); + if (res.ok) { + const data = await res.json(); + setEntries(data.entries); + setPagination(data.pagination); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, + [search, pagination.limit] + ); + + useEffect(() => { + fetchEntries(); + }, [fetchEntries]); + + const handleDelete = async (signature: string) => { + setDeleting(signature); + try { + await fetch(`/api/cache/entries?signature=${encodeURIComponent(signature)}`, { + method: "DELETE", + }); + await fetchEntries(pagination.page); + } finally { + setDeleting(null); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(); + }; + + return ( +
+
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchEntries()} + className="flex-1 px-3 py-2 text-sm rounded-lg border border-border bg-surface text-text-main placeholder:text-text-muted" + /> + +
+ + {loading ? ( +
{t("loading")}
+ ) : entries.length === 0 ? ( +
{t("noEntries")}
+ ) : ( + <> +
+ + + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + + ))} + +
{t("signature")}{t("model")}{t("hits")}{t("tokensSaved")}{t("created")}{t("expires")}{t("actions")}
+ {entry.signature.slice(0, 12)}... + {entry.model}{entry.hit_count} + {entry.tokens_saved.toLocaleString()} + + {formatDate(entry.created_at)} + + {formatDate(entry.expires_at)} + + +
+
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + + {pagination.page} / {pagination.totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index 04434d0595..24064e8403 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, Button, EmptyState } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; -import CacheStatsCard from "../settings/components/CacheStatsCard"; +import CacheEntriesTab from "./components/CacheEntriesTab"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -17,13 +17,44 @@ interface SemanticCacheStats { tokensSaved: number; } +interface PromptCacheProviderStats { + requests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; +} + +interface PromptCacheMetrics { + totalRequests: number; + requestsWithCacheControl: number; + totalInputTokens: number; + totalCachedTokens: number; + totalCacheCreationTokens: number; + tokensSaved: number; + estimatedCostSaved: number; + byProvider: Record; + byStrategy: Record; + lastUpdated: string; +} + interface IdempotencyStats { activeKeys: number; windowMs: number; } +interface CacheTrendPoint { + timestamp: string; + requests: number; + cachedRequests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; +} + interface CacheStats { semanticCache: SemanticCacheStats; + promptCache: PromptCacheMetrics | null; + trend: CacheTrendPoint[]; idempotency: IdempotencyStats; } @@ -108,6 +139,7 @@ export default function CachePage() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [clearing, setClearing] = useState(false); + const [activeTab, setActiveTab] = useState<"overview" | "entries">("overview"); const notify = useNotificationStore(); const fetchStats = useCallback(async () => { @@ -137,27 +169,32 @@ export default function CachePage() { const res = await fetch("/api/cache", { method: "DELETE" }); if (res.ok) { const data = await res.json(); - notify.add({ - type: "success", - message: t("clearSuccess", { count: data.expiredRemoved ?? 0 }), - }); + notify.success(t("clearSuccess", { count: data.expiredRemoved ?? 0 })); await fetchStats(); } else { - notify.add({ type: "error", message: t("clearError") }); + notify.error(t("clearError")); } } catch (error) { console.error("[CachePage] Failed to clear cache:", error); - notify.add({ type: "error", message: t("clearError") }); + notify.error(t("clearError")); } finally { setClearing(false); } }; const sc = stats?.semanticCache; + const pc = stats?.promptCache; + const trend = stats?.trend ?? []; const idp = stats?.idempotency; const hitRate = sc ? parseFloat(sc.hitRate) : 0; const totalRequests = sc ? sc.hits + sc.misses : 0; + const promptCacheHitRate = + pc && pc.totalRequests > 0 ? (pc.requestsWithCacheControl / pc.totalRequests) * 100 : 0; + const providerEntries = pc ? Object.entries(pc.byProvider) : []; + + const maxTrendRequests = Math.max(1, ...trend.map((p) => p.requests)); + return (
{/* Header */} @@ -191,152 +228,334 @@ export default function CachePage() {
- {/* Loading skeleton */} - {loading && ( -
+ + +
- {/* Error / empty state */} - {!loading && !stats && ( - void fetchStats()} - /> - )} + {/* Entries tab */} + {activeTab === "entries" && } - {/* Main content */} - {!loading && stats && ( + {/* Overview tab content */} + {activeTab === "overview" && ( <> - {/* Stats grid */} -
- - - - -
- - {/* Hit rate + breakdown */} - -
-
-

{t("performance")}

- - {t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })} - -
- -
-
-
- {sc?.hits ?? 0} -
-
{t("hits")}
-
-
-
- {sc?.misses ?? 0} -
-
{t("misses")}
-
-
-
{totalRequests}
-
{t("total")}
-
-
+ {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))}
- + )} - {/* Cache behavior */} - -
-

{t("behavior")}

-
- {t("behaviorDeterministic")} - - {t.rich("behaviorBypass", { - header: (chunks) => ( - - {chunks} - - ), - })} - - {t("behaviorTwoTier")} - - {t.rich("behaviorTtl", { - envVar: (chunks) => ( - - {chunks} - - ), - })} - -
-
-
+ {/* Error / empty state */} + {!loading && !stats && ( + void fetchStats()} + /> + )} - {/* Idempotency */} - -
-
- -

{t("idempotency")}

+ {/* Main content */} + {!loading && stats && ( + <> + {/* Stats grid */} +
+ + + +
-
-
-
{idp?.activeKeys ?? 0}
-
{t("activeDedupKeys")}
-
-
-
- {idp ? `${(idp.windowMs / 1000).toFixed(0)}s` : "—"} + + {/* Hit rate + breakdown */} + +
+
+

{t("performance")}

+ + {t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })} + +
+ +
+
+
+ {sc?.hits ?? 0} +
+
{t("hits")}
+
+
+
+ {sc?.misses ?? 0} +
+
{t("misses")}
+
+
+
{totalRequests}
+
{t("total")}
+
-
{t("dedupWindow")}
-
-
- + - {/* Claude Prompt Cache Metrics */} - + {/* Prompt Cache Stats */} + {pc && ( + +
+
+ +

{t("promptCache")}

+
+ +
+
+
+ {pc.requestsWithCacheControl.toLocaleString()} +
+
{t("cachedRequests")}
+
+
+
+ {promptCacheHitRate.toFixed(1)}% +
+
{t("cacheHitRate")}
+
+
+
+ {pc.totalCachedTokens.toLocaleString()} +
+
{t("cachedTokens")}
+
+
+
+ {pc.totalCacheCreationTokens.toLocaleString()} +
+
+ {t("cacheCreationTokens")} +
+
+
+ + {providerEntries.length > 0 && ( +
+

+ {t("byProvider")} +

+
+ + + + + + + + + + + + {providerEntries.map(([provider, data]) => ( + + + + + + + + ))} + +
{t("provider")}{t("requests")}{t("inputTokens")}{t("cachedTokensCol")}{t("cacheCreation")}
{provider} + {data.requests.toLocaleString()} + + {data.inputTokens.toLocaleString()} + + {data.cachedTokens.toLocaleString()} + + {data.cacheCreationTokens.toLocaleString()} +
+
+
+ )} +
+
+ )} + + {/* Cache Trend (24h) */} + {trend.length > 0 && ( + +
+
+ +

{t("trend24h")}

+
+
+ {trend.map((point) => { + const height = Math.max(4, (point.requests / maxTrendRequests) * 100); + const cachedHeight = + point.requests > 0 + ? Math.max(2, (point.cachedRequests / point.requests) * height) + : 0; + const hour = new Date(point.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + return ( +
+
+ {hour}: {point.requests} {t("requests").toLowerCase()},{" "} + {point.cachedRequests} {t("cached").toLowerCase()} +
+
+
+
+
+ + {hour.split(":")[0]} + +
+ ); + })} +
+
+
+
+ {t("total")} +
+
+
+ {t("cached")} +
+
+
+ + )} + + {/* Cache behavior */} + +
+

{t("behavior")}

+
+ {t("behaviorDeterministic")} + + {t.rich("behaviorBypass", { + header: () => ( + + X-OmniRoute-No-Cache: true + + ), + })} + + {t("behaviorTwoTier")} + + {t.rich("behaviorTtl", { + envVar: () => ( + + SEMANTIC_CACHE_TTL_MS + + ), + })} + +
+
+
+ + {/* Idempotency */} + +
+
+ +

{t("idempotency")}

+
+
+
+
+ {idp?.activeKeys ?? 0} +
+
{t("activeDedupKeys")}
+
+
+
+ {idp ? `${(idp.windowMs / 1000).toFixed(0)}s` : "—"} +
+
{t("dedupWindow")}
+
+
+
+
+ + )} )}
diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index bf25185f62..804d0c9214 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -48,6 +48,7 @@ export default function HealthPage() { const [telemetry, setTelemetry] = useState(null); const [cache, setCache] = useState(null); const [signatureCache, setSignatureCache] = useState(null); + const [degradation, setDegradation] = useState(null); const [resetting, setResetting] = useState(false); const fetchHealth = useCallback(async () => { @@ -69,12 +70,14 @@ export default function HealthPage() { fetch("/api/telemetry/summary").then((r) => r.json()), fetch("/api/cache/stats").then((r) => r.json()), fetch("/api/rate-limits").then((r) => r.json()), + fetch("/api/health/degradation").then((r) => r.json()), ]); if (results[0].status === "fulfilled") setTelemetry(results[0].value); if (results[1].status === "fulfilled") setCache(results[1].value); if (results[2].status === "fulfilled" && results[2].value.cacheStats) { setSignatureCache(results[2].value.cacheStats); } + if (results[3].status === "fulfilled") setDegradation(results[3].value); }, []); useEffect(() => { @@ -270,6 +273,80 @@ export default function HealthPage() {
+ {/* Graceful Degradation Status */} + {degradation && degradation.features && degradation.features.length > 0 && ( + +
+

+ healing + Graceful Degradation Status +

+
+ + Full: {degradation.summary.full} + + + Reduced: {degradation.summary.reduced} + + + Minimal: {degradation.summary.minimal} + + + Default: {degradation.summary.default} + +
+
+
+ {degradation.features.map((feat: any) => { + const bg = + feat.level === "full" + ? "bg-green-500/5 border-green-500/10" + : feat.level === "reduced" + ? "bg-amber-500/5 border-amber-500/20" + : feat.level === "minimal" + ? "bg-orange-500/5 border-orange-500/20" + : "bg-red-500/5 border-red-500/20"; + const dot = + feat.level === "full" + ? "bg-green-500" + : feat.level === "reduced" + ? "bg-amber-500" + : feat.level === "minimal" + ? "bg-orange-500" + : "bg-red-500"; + return ( +
+
+ + + {feat.feature} + + + {feat.level} + +
+
{feat.capability}
+ {feat.reason && ( +
+ {feat.reason.length > 80 ? feat.reason.substring(0, 80) + "..." : feat.reason} +
+ )} +
+ Since {new Date(feat.since).toLocaleTimeString()} +
+
+ ); + })} +
+
+ )} + {/* Telemetry Cards — Latency & Prompt Cache */}
{/* Latency Card */} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 66488b657e..c7fd8c35eb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -802,8 +802,6 @@ export default function ProviderDetailPage() { const { copied, copy } = useCopyToClipboard(); const t = useTranslations("providers"); const notify = useNotificationStore(); - const hasAutoOpened = useRef(false); - const userDismissed = useRef(false); const [proxyTarget, setProxyTarget] = useState(null); const [proxyConfig, setProxyConfig] = useState(null); const [connProxyMap, setConnProxyMap] = useState< @@ -989,26 +987,6 @@ export default function ProviderDetailPage() { } }, [loading, connections, loadConnProxies]); - // Auto-open Add Connection modal when no connections exist (better UX) - // Only fires once on initial load, not on HMR remounts or after user dismissal - useEffect(() => { - if ( - !loading && - connections.length === 0 && - providerInfo && - !isCompatible && - !hasAutoOpened.current && - !userDismissed.current - ) { - hasAutoOpened.current = true; - if (isOAuth) { - setShowOAuthModal(true); - } else { - setShowAddApiKeyModal(true); - } - } - }, [loading]); // eslint-disable-line react-hooks/exhaustive-deps - const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { const fullModel = `${providerAliasOverride}/${modelId}`; try { @@ -1490,7 +1468,10 @@ export default function ProviderDetailPage() { logs: [ t("foundModelsStartingImport", { count: newModels.length }), ...(newModels.length < fetchedModels.length - ? [t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || `Skipping ${fetchedModels.length - newModels.length} existing models`] + ? [ + t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || + `Skipping ${fetchedModels.length - newModels.length} existing models`, + ] : []), ], })); @@ -2428,7 +2409,6 @@ export default function ProviderDetailPage() { providerInfo={providerInfo} onSuccess={handleOAuthSuccess} onClose={() => { - userDismissed.current = true; setShowOAuthModal(false); }} /> @@ -2437,7 +2417,6 @@ export default function ProviderDetailPage() { isOpen={showOAuthModal} onSuccess={handleOAuthSuccess} onClose={() => { - userDismissed.current = true; setShowOAuthModal(false); }} /> @@ -2448,7 +2427,6 @@ export default function ProviderDetailPage() { providerInfo={providerInfo} onSuccess={handleOAuthSuccess} onClose={() => { - userDismissed.current = true; setShowOAuthModal(false); }} /> diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index ee53158198..7ea88f92ef 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -95,6 +95,7 @@ function getConnectionErrorTag(connection) { export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); + const [expirations, setExpirations] = useState(null); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); @@ -108,14 +109,17 @@ export default function ProvidersPage() { useEffect(() => { const fetchData = async () => { try { - const [connectionsRes, nodesRes] = await Promise.all([ + const [connectionsRes, nodesRes, expirationsRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/provider-nodes"), + fetch("/api/providers/expiration"), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); + const expirationsData = await expirationsRes.json(); if (connectionsRes.ok) setConnections(connectionsData.connections || []); if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); + if (expirationsRes.ok && expirationsData) setExpirations(expirationsData); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -188,7 +192,16 @@ export default function ProvidersPage() { const errorCode = latestError ? getConnectionErrorTag(latestError) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; - return { connected, error, total, errorCode, errorTime, allDisabled }; + // Check expirations + const providerExpirations = + expirations?.list?.filter((e: any) => e.provider === providerId) || []; + const hasExpired = providerExpirations.some((e: any) => e.status === "expired"); + const hasExpiringSoon = providerExpirations.some((e: any) => e.status === "expiring_soon"); + let expiryStatus = null; + if (hasExpired) expiryStatus = "expired"; + else if (hasExpiringSoon) expiryStatus = "expiring_soon"; + + return { connected, error, total, errorCode, errorTime, allDisabled, expiryStatus }; }; // Toggle all connections for a provider on/off @@ -289,6 +302,40 @@ export default function ProvidersPage() { return (
+ {/* Expiration Banner */} + {expirations?.summary && + (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( +
0 + ? "bg-red-500/10 border-red-500/20" + : "bg-amber-500/10 border-amber-500/20" + }`} + > + 0 ? "text-red-500" : "text-amber-500" + }`} + > + {expirations.summary.expired > 0 ? "error" : "warning"} + +
+

0 ? "text-red-500" : "text-amber-500"}`} + > + {expirations.summary.expired > 0 + ? `${expirations.summary.expired} Provider connection(s) expired` + : `${expirations.summary.expiringSoon} Provider connection(s) expiring soon`} +

+

+ {expirations.summary.expired > 0 + ? "Immediate action required. Expired connections will permanently fail." + : "Please review and renew expiring connections to avoid disruption."} +

+
+
+ )} + {/* OAuth Providers */}
@@ -582,6 +629,16 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) { ) : ( <> {getStatusDisplay(connected, error, errorCode, t)} + {stats.expiryStatus === "expired" && ( + + Expired + + )} + {stats.expiryStatus === "expiring_soon" && ( + + Expiring Soon + + )} {errorTime && • {errorTime}} )} @@ -709,6 +766,16 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) ) : ( <> {getStatusDisplay(connected, error, errorCode, t)} + {stats.expiryStatus === "expired" && ( + + Expired + + )} + {stats.expiryStatus === "expiring_soon" && ( + + Expiring Soon + + )} {isCompatible && ( {provider.apiType === "responses" ? t("responses") : t("chat")} diff --git a/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx new file mode 100644 index 0000000000..633592a462 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CacheSettingsTab.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface CacheConfig { + semanticCacheEnabled: boolean; + semanticCacheMaxSize: number; + semanticCacheTTL: number; + promptCacheEnabled: boolean; + promptCacheStrategy: "auto" | "system-only" | "manual"; + alwaysPreserveClientCache: "auto" | "always" | "never"; +} + +export default function CacheSettingsTab() { + const t = useTranslations("settings"); + const [config, setConfig] = useState({ + semanticCacheEnabled: true, + semanticCacheMaxSize: 100, + semanticCacheTTL: 1800000, + promptCacheEnabled: true, + promptCacheStrategy: "auto", + alwaysPreserveClientCache: "auto", + }); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/settings/cache-config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) setConfig(data); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + await fetch("/api/settings/cache-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + +

{t("loading")}

+
+ ); + } + + return ( + +

+ cached + {t("cacheSettings")} +

+ +
+ {/* Semantic Cache */} +
+

{t("semanticCache")}

+ + + + + + +
+ + {/* Prompt Cache */} +
+

{t("promptCache")}

+ + + + + + +
+ + {/* Save */} +
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 697a69eb06..774552da93 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -152,6 +152,40 @@ export default function RoutingTab() {

+ {/* Adaptive Volume Routing */} + +
+
+
+ +
+
+

+ {t("adaptiveVolumeRouting") || "Adaptive Volume Routing"} +

+

+ {t("adaptiveVolumeRoutingDesc") || + "Automatically adjusts traffic volume between providers based on real-time latency and error rates."} +

+
+
+
+ +
+
+
+ {/* Wildcard Aliases */}
diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 445964dc08..a6abd5e861 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -16,6 +16,7 @@ import CodexServiceTierTab from "./components/CodexServiceTierTab"; import SystemPromptTab from "./components/SystemPromptTab"; import ModelAliasesTab from "./components/ModelAliasesTab"; import BackgroundDegradationTab from "./components/BackgroundDegradationTab"; +import CacheSettingsTab from "./components/CacheSettingsTab"; import ResilienceTab from "./components/ResilienceTab"; const tabs = [ @@ -86,6 +87,7 @@ export default function SettingsPage() { +
)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 4075c078e9..a0a450c127 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -28,6 +28,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20; // Provider display config const PROVIDER_CONFIG = { antigravity: { label: "Antigravity", color: "#F59E0B" }, + "gemini-cli": { label: "Gemini CLI", color: "#4285F4" }, github: { label: "GitHub Copilot", color: "#333" }, kiro: { label: "Kiro AI", color: "#FF6B35" }, codex: { label: "OpenAI Codex", color: "#10A37F" }, @@ -279,12 +280,13 @@ export default function ProviderLimits() { const sortedConnections = useMemo(() => { const priority = { antigravity: 1, - github: 2, - codex: 3, - claude: 4, - kiro: 5, - glm: 6, - "kimi-coding": 7, + "gemini-cli": 2, + github: 3, + codex: 4, + claude: 5, + kiro: 6, + glm: 7, + "kimi-coding": 8, }; return [...filteredConnections].sort( (a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9) @@ -624,6 +626,7 @@ export default function ProviderLimits() { > {/* Model label */} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index a14643b785..b01a36514b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -11,15 +11,6 @@ const PROVIDER_PLAN_FALLBACKS = new Set([ ]); const QUOTA_LABEL_MAP: Record = { - "gemini-3-pro-high": "G3 Pro", - "gemini-3-pro-low": "G3 Pro Low", - "gemini-3-flash": "G3 Flash", - "gemini-2.5-flash": "G2.5 Flash", - "claude-opus-4-6-thinking": "Opus 4.6 Tk", - "claude-opus-4-5-thinking": "Opus 4.5 Tk", - "claude-opus-4-5": "Opus 4.5", - "claude-sonnet-4-5-thinking": "Sonnet 4.5 Tk", - "claude-sonnet-4-5": "Sonnet 4.5", chat: "Chat", completions: "Completions", premium_interactions: "Premium", @@ -254,6 +245,14 @@ export function parseQuotaData(provider, data) { } break; + case "gemini-cli": + if (data.quotas) { + Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { + normalizedQuotas.push(normalizeQuotaEntry(modelKey, quota, { modelKey })); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/api/analytics/diversity/route.ts b/src/app/api/analytics/diversity/route.ts new file mode 100644 index 0000000000..6e00929eec --- /dev/null +++ b/src/app/api/analytics/diversity/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getDiversityReport } from "../../../../../open-sse/services/autoCombo/providerDiversity"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const report = getDiversityReport(); + return NextResponse.json(report); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/audit/route.ts b/src/app/api/audit/route.ts new file mode 100644 index 0000000000..5819553ffe --- /dev/null +++ b/src/app/api/audit/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { + getAuditLog, + getAuditSummary, + AuditTarget, + AuditAction, + AuditSource, +} from "@/domain/configAudit"; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const summary = url.searchParams.get("summary"); + + if (summary === "true") { + return NextResponse.json(getAuditSummary()); + } + + const limit = parseInt(url.searchParams.get("limit") || "50", 10); + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const target = url.searchParams.get("target") as AuditTarget | null; + const action = url.searchParams.get("action") as AuditAction | null; + const source = url.searchParams.get("source") as AuditSource | null; + const since = url.searchParams.get("since"); + + const options: any = { limit, offset }; + if (target) options.target = target; + if (action) options.action = action; + if (source) options.source = source; + if (since) options.since = since; + + const result = getAuditLog(options); + return NextResponse.json(result); + } catch (error) { + console.error("[API ERROR] /api/audit GET:", error); + return NextResponse.json({ error: "Failed to fetch audit log." }, { status: 500 }); + } +} diff --git a/src/app/api/cache/entries/route.ts b/src/app/api/cache/entries/route.ts new file mode 100644 index 0000000000..26f30b02a9 --- /dev/null +++ b/src/app/api/cache/entries/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbInstance } from "@/lib/db/core"; + +interface CacheEntry { + id: string; + signature: string; + model: string; + hit_count: number; + tokens_saved: number; + created_at: string; + expires_at: string; +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20", 10))); + const search = searchParams.get("search") || ""; + const model = searchParams.get("model") || ""; + const sortBy = searchParams.get("sortBy") || "created_at"; + const sortOrder = searchParams.get("sortOrder") || "desc"; + + const db = getDbInstance(); + const offset = (page - 1) * limit; + + const conditions: string[] = []; + const params: unknown[] = []; + + if (search) { + conditions.push("(signature LIKE ? OR model LIKE ?)"); + params.push(`%${search}%`, `%${search}%`); + } + + if (model) { + conditions.push("model = ?"); + params.push(model); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const validSortColumns = ["created_at", "expires_at", "hit_count", "tokens_saved", "model"]; + const orderBy = validSortColumns.includes(sortBy) ? sortBy : "created_at"; + const order = sortOrder === "asc" ? "ASC" : "DESC"; + + const countRow = db + .prepare(`SELECT COUNT(*) as total FROM semantic_cache ${whereClause}`) + .get(...params) as { total: number }; + + const entries = db + .prepare( + `SELECT id, signature, model, hit_count, tokens_saved, created_at, expires_at + FROM semantic_cache ${whereClause} + ORDER BY ${orderBy} ${order} + LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as CacheEntry[]; + + return NextResponse.json({ + entries, + pagination: { + page, + limit, + total: countRow?.total || 0, + totalPages: Math.ceil((countRow?.total || 0) / limit), + }, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const signature = searchParams.get("signature"); + const model = searchParams.get("model"); + + const db = getDbInstance(); + + if (signature) { + db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature); + return NextResponse.json({ ok: true, deleted: 1 }); + } + + if (model) { + const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model); + return NextResponse.json({ ok: true, deleted: result.changes }); + } + + return NextResponse.json({ error: "Provide signature or model parameter" }, { status: 400 }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts index ebb02dc2f4..d1bca53891 100644 --- a/src/app/api/cache/route.ts +++ b/src/app/api/cache/route.ts @@ -8,21 +8,26 @@ import { invalidateStale, } from "@/lib/semanticCache"; import { getIdempotencyStats } from "@/lib/idempotencyLayer"; +import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings"; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** - * GET /api/cache — Cache statistics - */ -export async function GET() { +export async function GET(req: NextRequest) { try { + const { searchParams } = new URL(req.url); + const trendHours = parseInt(searchParams.get("trendHours") || "24", 10); + const cacheStats = getCacheStats(); const idempotencyStats = getIdempotencyStats(); + const promptCacheMetrics = await getCacheMetrics(); + const trend = await getCacheTrend(trendHours); return NextResponse.json({ semanticCache: cacheStats, + promptCache: promptCacheMetrics, + trend, idempotency: idempotencyStats, }); } catch (error) { @@ -30,17 +35,6 @@ export async function GET() { } } -/** - * DELETE /api/cache — Clear all caches or targeted invalidation. - * - * Exactly one optional query parameter may be provided: - * ?model= — invalidate all entries for a specific model - * ?signature= — invalidate a single entry by its SHA-256 signature - * ?staleMs= — invalidate entries older than N milliseconds - * (no params) — clear all cache entries - * - * Providing more than one parameter returns 400 Bad Request. - */ export async function DELETE(req: NextRequest) { try { const { searchParams } = new URL(req.url); diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index fb9888ea29..5e93979a79 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -5,6 +5,87 @@ import { getComboByName } from "@/lib/localDb"; import { testComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +async function testComboModel(modelStr, internalUrl) { + const startTime = Date.now(); + try { + // Send a minimal but real chat request through the same internal + // endpoint an external OpenAI-compatible client would use. + const testBody = buildComboTestRequestBody(modelStr); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + + let res; + try { + res = await fetch(internalUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Internal dashboard tests still use the normal /v1 pipeline but + // bypass REQUIRE_API_KEY so admins can test with local session auth. + "X-Internal-Test": "combo-health-check", + // Force a fresh execution path so combo tests cannot be satisfied by + // OmniRoute's semantic cache or other request reuse layers. + "X-OmniRoute-No-Cache": "true", + "X-Request-Id": `combo-test-${randomUUID()}`, + }, + body: JSON.stringify(testBody), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + const latencyMs = Date.now() - startTime; + + if (res.ok) { + let responseBody = null; + try { + responseBody = await res.json(); + } catch { + responseBody = null; + } + + const responseText = extractComboTestResponseText(responseBody); + if (!responseText) { + return { + model: modelStr, + status: "error", + statusCode: res.status, + error: "Provider returned HTTP 200 but no text content.", + latencyMs, + }; + } + + return { model: modelStr, status: "ok", latencyMs, responseText }; + } + + let errorMsg = ""; + try { + const errBody = await res.json(); + errorMsg = errBody?.error?.message || errBody?.error || res.statusText; + } catch { + errorMsg = res.statusText; + } + + return { + model: modelStr, + status: "error", + statusCode: res.status, + error: errorMsg, + latencyMs, + }; + } catch (error) { + const latencyMs = Date.now() - startTime; + return { + model: modelStr, + status: "error", + error: error.name === "AbortError" ? "Timeout (20s)" : error.message, + latencyMs, + }; + } +} + /** * POST /api/combos/test - Quick test a combo * Sends a real chat completion request through each model in the combo @@ -44,93 +125,11 @@ export async function POST(request) { return NextResponse.json({ error: "Combo has no models" }, { status: 400 }); } - const results = []; - let resolvedBy = null; - - // Test each model sequentially - for (const modelStr of models) { - const startTime = Date.now(); - try { - // Send a minimal but real chat request through the same internal - // endpoint an external OpenAI-compatible client would use. - const testBody = buildComboTestRequestBody(modelStr); - - const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 20000); - - let res; - try { - res = await fetch(internalUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - // Internal dashboard tests still use the normal /v1 pipeline but - // bypass REQUIRE_API_KEY so admins can test with local session auth. - "X-Internal-Test": "combo-health-check", - // Force a fresh execution path so combo tests cannot be satisfied by - // OmniRoute's semantic cache or other request reuse layers. - "X-OmniRoute-No-Cache": "true", - "X-Request-Id": `combo-test-${randomUUID()}`, - }, - body: JSON.stringify(testBody), - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } - - const latencyMs = Date.now() - startTime; - - if (res.ok) { - let responseBody = null; - try { - responseBody = await res.json(); - } catch { - responseBody = null; - } - - const responseText = extractComboTestResponseText(responseBody); - if (!responseText) { - results.push({ - model: modelStr, - status: "error", - statusCode: res.status, - error: "Provider returned HTTP 200 but no text content.", - latencyMs, - }); - continue; - } - - results.push({ model: modelStr, status: "ok", latencyMs, responseText }); - if (!resolvedBy) resolvedBy = modelStr; - } else { - let errorMsg = ""; - try { - const errBody = await res.json(); - errorMsg = errBody?.error?.message || errBody?.error || res.statusText; - } catch { - errorMsg = res.statusText; - } - - results.push({ - model: modelStr, - status: "error", - statusCode: res.status, - error: errorMsg, - latencyMs, - }); - } - } catch (error) { - const latencyMs = Date.now() - startTime; - results.push({ - model: modelStr, - status: "error", - error: error.name === "AbortError" ? "Timeout (20s)" : error.message, - latencyMs, - }); - } - } + const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`; + const results = await Promise.all( + models.map((modelStr) => testComboModel(modelStr, internalUrl)) + ); + const resolvedBy = results.find((result) => result.status === "ok")?.model || null; return NextResponse.json({ comboName, diff --git a/src/app/api/health/degradation/route.ts b/src/app/api/health/degradation/route.ts new file mode 100644 index 0000000000..1a9bf0d0c7 --- /dev/null +++ b/src/app/api/health/degradation/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { + getDegradationReport, + getDegradationSummary, + hasAnyDegradation, +} from "@/domain/degradation"; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const summaryStr = url.searchParams.get("summary"); + + if (summaryStr === "true") { + return NextResponse.json({ + summary: getDegradationSummary(), + isDegraded: hasAnyDegradation(), + }); + } + + const report = getDegradationReport(); + return NextResponse.json({ + active: hasAnyDegradation(), + summary: getDegradationSummary(), + features: report, + }); + } catch (error) { + console.error("[API ERROR] /api/health/degradation GET:", error); + return NextResponse.json({ error: "Failed to fetch degradation report." }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index ef8a8d1a36..d7e80d59a1 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -139,19 +139,7 @@ const PROVIDER_MODELS_CONFIG: Record = { name: m.displayName || (m.name || "").replace(/^models\//, ""), })), }, - "gemini-cli": { - url: "https://generativelanguage.googleapis.com/v1beta/models", - method: "GET", - headers: { "Content-Type": "application/json" }, - authHeader: "Authorization", - authPrefix: "Bearer ", - parseResponse: (data) => - (data.models || []).map((m) => ({ - ...m, - id: (m.name || m.id || "").replace(/^models\//, ""), - name: m.displayName || (m.name || "").replace(/^models\//, ""), - })), - }, + // gemini-cli handled via retrieveUserQuota (see GET handler) qwen: { url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", method: "GET", @@ -505,6 +493,68 @@ export async function GET( return buildResponse({ provider, connectionId, models }); } + if (provider === "gemini-cli") { + // Gemini CLI doesn't have a /models endpoint. Instead, query the quota + // endpoint to discover available models from the quota buckets. + if (!accessToken) { + return NextResponse.json( + { error: "No access token for Gemini CLI. Please reconnect OAuth." }, + { status: 400 } + ); + } + + const psd = asRecord(connection.providerSpecificData); + const projectId = connection.projectId || psd.projectId || null; + + if (!projectId) { + return NextResponse.json( + { error: "Gemini CLI project ID not available. Please reconnect OAuth." }, + { status: 400 } + ); + } + + try { + const quotaRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + } + ); + + if (!quotaRes.ok) { + const errText = await quotaRes.text(); + console.log(`[models] Gemini CLI quota fetch failed (${quotaRes.status}):`, errText); + return NextResponse.json( + { error: `Failed to fetch Gemini CLI models: ${quotaRes.status}` }, + { status: quotaRes.status } + ); + } + + const quotaData = await quotaRes.json(); + const buckets: Array<{ modelId?: string; tokenType?: string }> = quotaData.buckets || []; + + const models = buckets + .filter((b) => b.modelId) + .map((b) => ({ + id: b.modelId, + name: b.modelId, + owned_by: "google", + })); + + return buildResponse({ provider, connectionId, models }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.log("[models] Gemini CLI model fetch error:", msg); + return NextResponse.json({ error: "Failed to fetch Gemini CLI models" }, { status: 500 }); + } + } + if (isAnthropicCompatibleProvider(provider)) { let baseUrl = getProviderBaseUrl(connection.providerSpecificData); if (!baseUrl) { diff --git a/src/app/api/providers/expiration/route.ts b/src/app/api/providers/expiration/route.ts new file mode 100644 index 0000000000..c9d4f24f5d --- /dev/null +++ b/src/app/api/providers/expiration/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getAllExpirations, getExpirationSummary } from "@/domain/providerExpiration"; + +export async function GET() { + try { + const list = getAllExpirations(); + const summary = getExpirationSummary(); + + return NextResponse.json({ + summary, + list, + }); + } catch (error) { + console.error("[API ERROR] /api/providers/expiration GET:", error); + return NextResponse.json({ error: "Failed to fetch expiration metadata." }, { status: 500 }); + } +} diff --git a/src/app/api/settings/cache-config/route.ts b/src/app/api/settings/cache-config/route.ts new file mode 100644 index 0000000000..e7704454e3 --- /dev/null +++ b/src/app/api/settings/cache-config/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { z } from "zod"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +const cacheConfigUpdateSchema = z.object({ + semanticCacheEnabled: z.boolean().optional(), + semanticCacheMaxSize: z.number().positive().optional(), + semanticCacheTTL: z.number().positive().optional(), + promptCacheEnabled: z.boolean().optional(), + promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(), + alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), +}); + +const CACHE_CONFIG_KEYS = [ + "semanticCacheEnabled", + "semanticCacheMaxSize", + "semanticCacheTTL", + "promptCacheEnabled", + "promptCacheStrategy", + "alwaysPreserveClientCache", +] as const; + +const DEFAULTS = { + semanticCacheEnabled: true, + semanticCacheMaxSize: 100, + semanticCacheTTL: 1800000, + promptCacheEnabled: true, + promptCacheStrategy: "auto", + alwaysPreserveClientCache: "auto", +}; + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const settings = await getSettings(); + const config: Record = {}; + for (const key of CACHE_CONFIG_KEYS) { + config[key] = settings[key] ?? DEFAULTS[key]; + } + return NextResponse.json(config); + } 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(cacheConfigUpdateSchema, rawBody); + if (isValidationFailure(validation)) { + return validation.response; + } + + const updates: Record = {}; + const body = validation.data; + + if (body.semanticCacheEnabled !== undefined) { + updates.semanticCacheEnabled = body.semanticCacheEnabled; + } + if (body.semanticCacheMaxSize !== undefined) { + updates.semanticCacheMaxSize = body.semanticCacheMaxSize; + } + if (body.semanticCacheTTL !== undefined) { + updates.semanticCacheTTL = body.semanticCacheTTL; + } + if (body.promptCacheEnabled !== undefined) { + updates.promptCacheEnabled = body.promptCacheEnabled; + } + if (body.promptCacheStrategy !== undefined) { + updates.promptCacheStrategy = body.promptCacheStrategy; + } + if (body.alwaysPreserveClientCache !== undefined) { + updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache; + } + + await updateSettings(updates); + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/tunnels/cloudflared/route.ts b/src/app/api/tunnels/cloudflared/route.ts index 3b65450532..14f78fbdf5 100644 --- a/src/app/api/tunnels/cloudflared/route.ts +++ b/src/app/api/tunnels/cloudflared/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getCloudflaredTunnelStatus, startCloudflaredTunnel, @@ -40,27 +41,27 @@ export async function POST(request: NextRequest) { return unauthorized(); } - let payload: unknown; + let rawBody: unknown; try { - payload = await request.json(); + rawBody = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const parsed = actionSchema.safeParse(payload); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const validation = validateBody(actionSchema, rawBody); + if (isValidationFailure(validation)) { + return validation.response; } + const parsed = validation.data; + try { const status = - parsed.data.action === "enable" - ? await startCloudflaredTunnel() - : await stopCloudflaredTunnel(); + parsed.action === "enable" ? await startCloudflaredTunnel() : await stopCloudflaredTunnel(); return NextResponse.json({ success: true, - action: parsed.data.action, + action: parsed.action, status, }); } catch (error) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bd5605eb7f..43463d7cba 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1712,6 +1712,17 @@ "cacheMisses": "Cache Misses", "hitRate": "Hit Rate", "cacheEntries": "Cache Entries", + "cacheSettings": "Cache Settings", + "semanticCache": "Semantic Cache", + "maxEntries": "Max Entries", + "ttlMinutes": "TTL (minutes)", + "promptCache": "Prompt Cache", + "strategy": "Strategy", + "preserveClientCache": "Preserve Client Cache", + "enabled": "Enabled", + "loading": "Loading...", + "saving": "Saving...", + "save": "Save", "circuitBreaker": "Circuit Breaker", "retryPolicy": "Retry Policy", "maxRetries": "Max Retries", @@ -2920,6 +2931,30 @@ "clearSuccess": "Cache cleared. {count} expired entries removed.", "clearError": "Failed to clear cache.", "unavailable": "Cache unavailable", - "unavailableDesc": "Could not fetch cache statistics. Make sure the server is running." + "unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.", + "promptCache": "Prompt Cache (Provider-Side)", + "cachedRequests": "Cached Requests", + "cacheHitRate": "Cache Hit Rate", + "cachedTokens": "Cached Tokens", + "cacheCreationTokens": "Cache Creation Tokens", + "byProvider": "Breakdown by Provider", + "provider": "Provider", + "requests": "Requests", + "inputTokens": "Input Tokens", + "cachedTokensCol": "Cached", + "cacheCreation": "Creation", + "trend24h": "Cache Trend (24h)", + "cached": "Cached", + "overview": "Overview", + "entries": "Entries", + "searchEntries": "Search entries...", + "search": "Search", + "loading": "Loading...", + "noEntries": "No cache entries found", + "signature": "Signature", + "model": "Model", + "created": "Created", + "expires": "Expires", + "actions": "Actions" } } diff --git a/src/lib/cloudflaredTunnel.ts b/src/lib/cloudflaredTunnel.ts index afb582a3bb..e3b457501e 100644 --- a/src/lib/cloudflaredTunnel.ts +++ b/src/lib/cloudflaredTunnel.ts @@ -13,6 +13,18 @@ const CLOUDFLARED_RELEASE_BASE = "https://github.com/cloudflare/cloudflared/releases/latest/download"; const START_TIMEOUT_MS = 30000; const STOP_TIMEOUT_MS = 5000; +const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited"; +const DEFAULT_CERT_FILE_CANDIDATES = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/cert.pem", + "/private/etc/ssl/cert.pem", +] as const; +const DEFAULT_CERT_DIR_CANDIDATES = [ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/system/etc/security/cacerts", +] as const; type CloudflaredInstallSource = "managed" | "path" | "env"; type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error"; @@ -238,9 +250,55 @@ export function extractTryCloudflareUrl(text: string) { return match ? match[0] : null; } +function normalizeCloudflaredLogLine(line: string) { + return line + .trim() + .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:INF|WRN|ERR)\s+/i, "") + .trim(); +} + +export function extractCloudflaredErrorMessage(text: string) { + const lines = String(text || "") + .split(/\r?\n/) + .map(normalizeCloudflaredLogLine) + .filter(Boolean); + + for (let i = lines.length - 1; i >= 0; i--) { + if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) { + return lines[i]; + } + } + + return null; +} + +function isSpecificCloudflaredError(error: string | null | undefined) { + return !!error && !error.startsWith(GENERIC_EXIT_ERROR_PREFIX); +} + +function getGenericExitError(code: number | null, signal: NodeJS.Signals | null) { + return `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`; +} + +export function getDefaultCloudflaredCertEnv( + existsSync: (candidate: string) => boolean = fsSync.existsSync, + certFileCandidates: readonly string[] = DEFAULT_CERT_FILE_CANDIDATES, + certDirCandidates: readonly string[] = DEFAULT_CERT_DIR_CANDIDATES +) { + const certEnv: NodeJS.ProcessEnv = {}; + const certFile = certFileCandidates.find((candidate) => existsSync(candidate)); + const certDir = certDirCandidates.find((candidate) => existsSync(candidate)); + + if (certFile) certEnv.SSL_CERT_FILE = certFile; + if (certDir) certEnv.SSL_CERT_DIR = certDir; + + return certEnv; +} + export function buildCloudflaredChildEnv( sourceEnv: NodeJS.ProcessEnv = process.env, - runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs() + runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs(), + defaultCertEnv: NodeJS.ProcessEnv = getDefaultCloudflaredCertEnv() ): NodeJS.ProcessEnv { const childEnv: NodeJS.ProcessEnv = {}; @@ -262,6 +320,12 @@ export function buildCloudflaredChildEnv( if (!childEnv.TMPDIR) childEnv.TMPDIR = runtimeDirs.tempDir; if (!childEnv.TMP) childEnv.TMP = runtimeDirs.tempDir; if (!childEnv.TEMP) childEnv.TEMP = runtimeDirs.tempDir; + if (!childEnv.SSL_CERT_FILE && defaultCertEnv.SSL_CERT_FILE) { + childEnv.SSL_CERT_FILE = defaultCertEnv.SSL_CERT_FILE; + } + if (!childEnv.SSL_CERT_DIR && defaultCertEnv.SSL_CERT_DIR) { + childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR; + } return childEnv; } @@ -447,7 +511,9 @@ async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | const lastError = code === 0 || signal === "SIGTERM" || signal === "SIGINT" ? null - : `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`; + : isSpecificCloudflaredError(currentState.lastError) + ? currentState.lastError + : getGenericExitError(code, signal); tunnelProcess = null; tunnelPid = null; @@ -562,14 +628,10 @@ export async function startCloudflaredTunnel(): Promise startedAt: new Date().toISOString(), }); - const child = spawn( - binary.binaryPath as string, - getCloudflaredStartArgs(targetUrl), - { - stdio: ["ignore", "pipe", "pipe"], - env: buildCloudflaredChildEnv(), - } - ); + const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), { + stdio: ["ignore", "pipe", "pipe"], + env: buildCloudflaredChildEnv(), + }); tunnelProcess = child; tunnelPid = child.pid ?? null; @@ -597,6 +659,14 @@ export async function startCloudflaredTunnel(): Promise if (!text) return; await appendTunnelLog(source, text); + const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null; + if (errorMessage) { + await updateStateFile({ + pid: child.pid, + status: "error", + lastError: errorMessage, + }); + } const url = extractTryCloudflareUrl(text); if (!url) return; @@ -643,11 +713,18 @@ export async function startCloudflaredTunnel(): Promise try { return await startPromise; } catch (error) { + const currentState = await readStateFile(); + const message = isSpecificCloudflaredError(currentState.lastError) + ? currentState.lastError + : error instanceof Error + ? error.message + : "Failed to start cloudflared tunnel"; + await updateStateFile({ status: "error", - lastError: error instanceof Error ? error.message : "Failed to start cloudflared tunnel", + lastError: message, }); - throw error; + throw new Error(message); } finally { startPromise = null; } diff --git a/src/lib/combos/testHealth.ts b/src/lib/combos/testHealth.ts index 15959e6913..f7ac5f66cf 100644 --- a/src/lib/combos/testHealth.ts +++ b/src/lib/combos/testHealth.ts @@ -4,6 +4,10 @@ function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +function joinNonEmpty(parts: string[]) { + return parts.filter(Boolean).join("\n").trim(); +} + function extractTextFromContent(content: unknown): string { if (typeof content === "string") return content.trim(); @@ -28,12 +32,85 @@ function extractTextFromContent(content: unknown): string { .trim(); } +function extractReasoningText(record: JsonRecord): string { + const reasoningDetails = Array.isArray(record.reasoning_details) ? record.reasoning_details : []; + const detailText = reasoningDetails + .map((detail) => { + const detailRecord = asRecord(detail); + const detailType = typeof detailRecord.type === "string" ? detailRecord.type : ""; + const text = + typeof detailRecord.text === "string" + ? detailRecord.text.trim() + : typeof detailRecord.content === "string" + ? detailRecord.content.trim() + : ""; + + if ( + text && + (detailType === "" || + detailType === "reasoning" || + detailType === "reasoning.text" || + detailType === "thinking") + ) { + return text; + } + + return ""; + }) + .filter(Boolean); + + return joinNonEmpty([ + typeof record.reasoning_content === "string" ? record.reasoning_content.trim() : "", + typeof record.reasoning === "string" ? record.reasoning.trim() : "", + typeof record.reasoning_text === "string" ? record.reasoning_text.trim() : "", + joinNonEmpty(detailText), + ]); +} + +function getUsageReasoningTokens(body: JsonRecord): number { + const usage = asRecord(body.usage); + if (!usage) return 0; + + const completionDetails = asRecord(usage.completion_tokens_details); + const topLevelReasoning = + typeof usage.reasoning_tokens === "number" && Number.isFinite(usage.reasoning_tokens) + ? usage.reasoning_tokens + : 0; + const detailedReasoning = + typeof completionDetails.reasoning_tokens === "number" && + Number.isFinite(completionDetails.reasoning_tokens) + ? completionDetails.reasoning_tokens + : 0; + + return Math.max(topLevelReasoning, detailedReasoning); +} + +function hasReasoningOnlyCompletion(body: JsonRecord): boolean { + if (!Array.isArray(body.choices) || body.choices.length === 0) return false; + if (getUsageReasoningTokens(body) <= 0) return false; + + return body.choices.some((choice) => { + const choiceRecord = asRecord(choice); + const message = asRecord(choiceRecord.message); + const finishReason = + typeof choiceRecord.finish_reason === "string" ? choiceRecord.finish_reason : ""; + + if (!message || message.role !== "assistant") return false; + if (!finishReason) return false; + if (extractTextFromContent(message.content)) return false; + if (extractReasoningText(message)) return false; + return true; + }); +} + export function buildComboTestRequestBody(modelStr: string) { return { model: modelStr, messages: [{ role: "user", content: "Reply with OK only." }], - // Keep this close to a real client request without inflating cost. - max_tokens: 16, + // Give reasoning-heavy models enough headroom to emit a tiny visible answer + // without turning the smoke test into a full-cost real request. + max_tokens: 64, + temperature: 0, stream: false, }; } @@ -52,6 +129,9 @@ export function extractComboTestResponseText(responseBody: unknown): string { const messageText = extractTextFromContent(message.content); if (messageText) return messageText; + const reasoningText = extractReasoningText(message); + if (reasoningText) return reasoningText; + if (typeof choiceRecord.text === "string" && choiceRecord.text.trim()) { return choiceRecord.text.trim(); } @@ -63,8 +143,21 @@ export function extractComboTestResponseText(responseBody: unknown): string { const itemRecord = asRecord(item); const contentText = extractTextFromContent(itemRecord.content); if (contentText) return contentText; + + const reasoningText = extractReasoningText(itemRecord); + if (reasoningText) return reasoningText; } } - return extractTextFromContent(body.content); + const topLevelText = extractTextFromContent(body.content); + if (topLevelText) return topLevelText; + + const topLevelReasoning = extractReasoningText(body); + if (topLevelReasoning) return topLevelReasoning; + + if (hasReasoningOnlyCompletion(body)) { + return "[reasoning-only completion]"; + } + + return ""; } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 00224a353a..333d71379f 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -577,9 +577,14 @@ export async function getCacheMetrics() { cacheCreationTokens: number | null; }>; - // Calculate tokens saved (cached tokens are reused, not charged at full price) const tokensSaved = totalsRow?.totalCachedTokens || 0; + const AVG_INPUT_PRICE_PER_MILLION = 3; + const CACHE_DISCOUNT = 0.9; + const estimatedCostSaved = + Math.round((tokensSaved / 1_000_000) * AVG_INPUT_PRICE_PER_MILLION * CACHE_DISCOUNT * 100) / + 100; + // Build byProvider object const byProvider: Record< string, @@ -653,6 +658,58 @@ export async function updateCacheMetrics(_metrics: Record) { return getCacheMetrics(); } +export interface CacheTrendPoint { + timestamp: string; + requests: number; + cachedRequests: number; + inputTokens: number; + cachedTokens: number; + cacheCreationTokens: number; +} + +export async function getCacheTrend(hours = 24): Promise { + const db = getDbInstance(); + + try { + const rows = db + .prepare( + ` + SELECT + strftime('%Y-%m-%dT%H:00:00Z', timestamp) as hour, + COUNT(*) as requests, + SUM(CASE WHEN tokens_cache_read > 0 OR tokens_cache_creation > 0 THEN 1 ELSE 0 END) as cachedRequests, + SUM(tokens_input) as inputTokens, + SUM(tokens_cache_read) as cachedTokens, + SUM(tokens_cache_creation) as cacheCreationTokens + FROM usage_history + WHERE timestamp >= datetime('now', ?) + GROUP BY hour + ORDER BY hour ASC + ` + ) + .all(`-${hours} hours`) as Array<{ + hour: string; + requests: number; + cachedRequests: number; + inputTokens: number | null; + cachedTokens: number | null; + cacheCreationTokens: number | null; + }>; + + return rows.map((r) => ({ + timestamp: r.hour, + requests: r.requests, + cachedRequests: r.cachedRequests, + inputTokens: r.inputTokens || 0, + cachedTokens: r.cachedTokens || 0, + cacheCreationTokens: r.cacheCreationTokens || 0, + })); + } catch (error) { + console.error("Failed to fetch cache trend:", error); + return []; + } +} + export async function resetCacheMetrics() { // No-op: cannot delete historical usage data // Cache metrics are computed from usage_history, so they reflect actual request history diff --git a/src/lib/promptCache/index.ts b/src/lib/promptCache/index.ts new file mode 100644 index 0000000000..f4ee1fd501 --- /dev/null +++ b/src/lib/promptCache/index.ts @@ -0,0 +1 @@ +export { analyzePrefix, shouldInjectCacheControl } from "./prefixAnalyzer"; diff --git a/src/lib/promptCache/prefixAnalyzer.ts b/src/lib/promptCache/prefixAnalyzer.ts new file mode 100644 index 0000000000..43f622c87f --- /dev/null +++ b/src/lib/promptCache/prefixAnalyzer.ts @@ -0,0 +1,77 @@ +import crypto from "crypto"; + +interface Message { + role: string; + content: string | unknown[]; +} + +interface PrefixAnalysis { + prefixEndIdx: number; + prefixHash: string; + prefixTokens: number; + prefixType: "system_only" | "system_and_tools" | "system_tools_history"; + confidence: number; +} + +function normalizeContent(content: string | unknown[]): string { + if (typeof content === "string") return content; + return JSON.stringify(content); +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export function analyzePrefix(messages: Message[]): PrefixAnalysis { + if (!Array.isArray(messages) || messages.length === 0) { + return { + prefixEndIdx: -1, + prefixHash: "", + prefixTokens: 0, + prefixType: "system_only", + confidence: 0, + }; + } + + let prefixEndIdx = -1; + let prefixType: PrefixAnalysis["prefixType"] = "system_only"; + let confidence = 0.5; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const role = msg.role || "user"; + + if (role === "system") { + prefixEndIdx = i; + prefixType = "system_only"; + confidence = 0.9; + } else if (role === "tool" || (role === "assistant" && Array.isArray(msg.content))) { + prefixEndIdx = i; + prefixType = "system_and_tools"; + confidence = 0.8; + } else if (role === "assistant") { + prefixEndIdx = i; + prefixType = "system_tools_history"; + confidence = 0.7; + } else { + break; + } + } + + const prefixMessages = messages.slice(0, prefixEndIdx + 1); + const prefixText = prefixMessages.map((m) => normalizeContent(m.content)).join("\n"); + const prefixHash = crypto.createHash("sha256").update(prefixText).digest("hex"); + const prefixTokens = estimateTokens(prefixText); + + return { + prefixEndIdx, + prefixHash, + prefixTokens, + prefixType, + confidence, + }; +} + +export function shouldInjectCacheControl(analysis: PrefixAnalysis, minTokens = 1024): boolean { + return analysis.prefixTokens >= minTokens && analysis.confidence >= 0.7; +} diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index d20e4b560e..369d7a0d68 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -29,6 +29,45 @@ function toNumber(value: unknown, fallback = 0): number { return fallback; } +function ensureCacheMetricsTable() { + try { + const db = getDbInstance(); + db.prepare( + `CREATE TABLE IF NOT EXISTS cache_metrics ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )` + ).run(); + db.prepare( + `INSERT OR IGNORE INTO cache_metrics (key, value) VALUES ('hits', 0), ('misses', 0), ('tokens_saved', 0)` + ).run(); + } catch { + // DB not available + } +} + +function incrementMetric(metric: "hits" | "misses" | "tokens_saved", amount = 1) { + try { + const db = getDbInstance(); + db.prepare( + `UPDATE cache_metrics SET value = value + ?, updated_at = datetime('now') WHERE key = ?` + ).run(amount, metric); + } catch { + // DB not available — fall back to in-memory + } +} + +function getMetricValue(metric: string): number { + try { + const db = getDbInstance(); + const row = db.prepare(`SELECT value FROM cache_metrics WHERE key = ?`).get(metric); + return row ? toNumber(asRecord(row).value, 0) : 0; + } catch { + return 0; + } +} + function getHeaderValue( headers: { get?: (name: string) => string | null } | Record | null | undefined, name: string @@ -51,7 +90,6 @@ function getHeaderValue( // ─── Singleton ───────────────── let memoryCache: LRUCache | null = null; -let stats = { hits: 0, misses: 0, tokensSaved: 0 }; function getMemoryCache() { if (!memoryCache) { @@ -60,6 +98,7 @@ function getMemoryCache() { maxBytes: parseInt(process.env.SEMANTIC_CACHE_MAX_BYTES || String(4 * 1024 * 1024), 10), defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "1800000", 10), }); + ensureCacheMetricsTable(); } return memoryCache; } @@ -108,8 +147,8 @@ export function getCachedResponse(signature) { // 1. Check memory cache const memResult = getMemoryCache().get(signature); if (memResult) { - stats.hits++; - stats.tokensSaved += memResult.tokensSaved || 0; + incrementMetric("hits"); + incrementMetric("tokens_saved", memResult.tokensSaved || 0); return memResult.response; } @@ -126,7 +165,7 @@ export function getCachedResponse(signature) { const record = asRecord(row); const responsePayload = typeof record.response === "string" ? record.response : null; if (!responsePayload) { - stats.misses++; + incrementMetric("misses"); return null; } const parsed = JSON.parse(responsePayload); @@ -141,15 +180,15 @@ export function getCachedResponse(signature) { signature ); - stats.hits++; - stats.tokensSaved += tokensSaved; + incrementMetric("hits"); + incrementMetric("tokens_saved", tokensSaved); return parsed; } } catch { // DB not available — fail open } - stats.misses++; + incrementMetric("misses"); return null; } @@ -280,6 +319,17 @@ export function stopAutoCleanup(): void { } } +export function cleanOldMetrics(retentionDays = 90): number { + try { + const db = getDbInstance(); + const cutoff = new Date(Date.now() - retentionDays * 86400000).toISOString(); + const result = db.prepare("DELETE FROM semantic_cache WHERE created_at < ?").run(cutoff); + return result.changes || 0; + } catch { + return 0; + } +} + /** * Clear all cache entries. */ @@ -288,17 +338,12 @@ export function clearCache() { try { const db = getDbInstance(); db.prepare("DELETE FROM semantic_cache").run(); + db.prepare("UPDATE cache_metrics SET value = 0").run(); } catch { // DB not available } - stats = { hits: 0, misses: 0, tokensSaved: 0 }; } -// ─── Stats ───────────────── - -/** - * Get cache statistics. - */ export function getCacheStats() { const memStats = getMemoryCache().getStats(); let dbSize = 0; @@ -312,14 +357,18 @@ export function getCacheStats() { // DB not available } - const total = stats.hits + stats.misses; + const hits = getMetricValue("hits"); + const misses = getMetricValue("misses"); + const tokensSaved = getMetricValue("tokens_saved"); + const total = hits + misses; + return { memoryEntries: memStats.size, dbEntries: dbSize, - hits: stats.hits, - misses: stats.misses, - hitRate: total > 0 ? ((stats.hits / total) * 100).toFixed(1) : "0.0", - tokensSaved: stats.tokensSaved, + hits, + misses, + hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) : "0.0", + tokensSaved, }; } diff --git a/src/shared/components/DegradationBadge.tsx b/src/shared/components/DegradationBadge.tsx new file mode 100644 index 0000000000..031cc92e1c --- /dev/null +++ b/src/shared/components/DegradationBadge.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export default function DegradationBadge() { + const [isDegraded, setDegraded] = useState(false); + const t = useTranslations("common"); // Or a specific namespace if needed + + useEffect(() => { + const checkDegradation = async () => { + try { + const res = await fetch("/api/health/degradation?summary=true"); + if (res.ok) { + const data = await res.json(); + setDegraded(data.isDegraded); + } + } catch (err) { + // Ignore error + } + }; + + checkDegradation(); + const interval = setInterval(checkDegradation, 60000); + return () => clearInterval(interval); + }, []); + + if (!isDegraded) return null; + + return ( + + healing + Degraded + + ); +} diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx index aaf2223148..9ffa39bbd6 100644 --- a/src/shared/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import PropTypes from "prop-types"; import ThemeToggle from "./ThemeToggle"; import TokenHealthBadge from "./TokenHealthBadge"; +import DegradationBadge from "./DegradationBadge"; import LanguageSelector from "./LanguageSelector"; import ProviderIcon from "./ProviderIcon"; import { useTranslations } from "next-intl"; @@ -199,7 +200,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {/* Theme toggle */} - {/* Token health */} + {/* Degradation & Token health */} + {/* Logout button */} diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 34d55b825f..96f0103745 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -78,6 +78,26 @@ export default function UsageAnalytics() { return (analytics?.byProvider || []).length; }, [analytics]); + const providerDiversity = useMemo(() => { + const providers = analytics?.byProvider || []; + if (providers.length <= 1) return 0; + + let totalCalls = 0; + for (const p of providers) { + totalCalls += p.totalRequests || p.apiCalls || 0; + } + if (totalCalls === 0) return 0; + + let h = 0; + for (const p of providers) { + const p_i = (p.totalRequests || p.apiCalls || 0) / totalCalls; + if (p_i > 0) h -= p_i * Math.log2(p_i); + } + + const maxH = Math.log2(providers.length); + return maxH > 0 ? (h / maxH) * 100 : 0; + }, [analytics]); + if (loading && !analytics) return ; if (error) return Error: {error}; @@ -176,9 +196,9 @@ export default function UsageAnalytics() {
diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 85ea9128dc..266dc08234 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -656,6 +656,7 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { // Providers that support usage/quota API export const USAGE_SUPPORTED_PROVIDERS = [ "antigravity", + "gemini-cli", "kiro", "github", "codex", diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index fa73e228d7..b3dd15904f 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -16,6 +16,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "search-tools", "health", "logs", + "audit", "settings", "docs", "issues", @@ -74,6 +75,7 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, + { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" }, { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, ]; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 4517b33d5a..9d56f2b394 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -50,6 +50,8 @@ export const updateSettingsSchema = z.object({ stripModelPrefix: z.boolean().optional(), // Cache control preservation mode alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), + // Adaptive Volume Routing + adaptiveVolumeRouting: z.boolean().optional(), // Custom CLI agent definitions for ACP customAgents: z .array( diff --git a/tests/unit/cloudflaredTunnel.test.mjs b/tests/unit/cloudflaredTunnel.test.mjs index 30650f23a8..62a51c4096 100644 --- a/tests/unit/cloudflaredTunnel.test.mjs +++ b/tests/unit/cloudflaredTunnel.test.mjs @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { buildCloudflaredChildEnv, + extractCloudflaredErrorMessage, extractTryCloudflareUrl, + getDefaultCloudflaredCertEnv, getCloudflaredStartArgs, getCloudflaredAssetSpec, } from "../../src/lib/cloudflaredTunnel.ts"; @@ -20,6 +22,17 @@ test("extractTryCloudflareUrl returns null when no tunnel URL is present", () => assert.equal(extractTryCloudflareUrl("cloudflared starting without assigned URL"), null); }); +test("extractCloudflaredErrorMessage keeps the actionable stderr line", () => { + const error = extractCloudflaredErrorMessage( + '2026-03-30T19:56:12Z INF Requesting new quick Tunnel on trycloudflare.com...\n2026-03-30T19:56:12Z ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": tls: failed to verify certificate: x509: certificate signed by unknown authority' + ); + + assert.equal( + error, + 'failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": tls: failed to verify certificate: x509: certificate signed by unknown authority' + ); +}); + test("getCloudflaredAssetSpec resolves linux amd64 binary", () => { const spec = getCloudflaredAssetSpec("linux", "x64"); @@ -49,22 +62,26 @@ test("getCloudflaredAssetSpec returns null for unsupported platforms", () => { }); test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs, and drops secrets", () => { - const env = buildCloudflaredChildEnv({ - PATH: "/usr/bin", - HTTPS_PROXY: "http://proxy.internal:8080", - JWT_SECRET: "top-secret", - API_KEY_SECRET: "another-secret", - }, { - runtimeRoot: "/managed/runtime", - homeDir: "/managed/runtime/home", - configDir: "/managed/runtime/config", - cacheDir: "/managed/runtime/cache", - dataDir: "/managed/runtime/data", - tempDir: "/managed/runtime/tmp", - userProfileDir: "/managed/runtime/userprofile", - appDataDir: "/managed/runtime/userprofile/AppData/Roaming", - localAppDataDir: "/managed/runtime/userprofile/AppData/Local", - }); + const env = buildCloudflaredChildEnv( + { + PATH: "/usr/bin", + HTTPS_PROXY: "http://proxy.internal:8080", + JWT_SECRET: "top-secret", + API_KEY_SECRET: "another-secret", + }, + { + runtimeRoot: "/managed/runtime", + homeDir: "/managed/runtime/home", + configDir: "/managed/runtime/config", + cacheDir: "/managed/runtime/cache", + dataDir: "/managed/runtime/data", + tempDir: "/managed/runtime/tmp", + userProfileDir: "/managed/runtime/userprofile", + appDataDir: "/managed/runtime/userprofile/AppData/Roaming", + localAppDataDir: "/managed/runtime/userprofile/AppData/Local", + }, + {} + ); assert.deepEqual(env, { PATH: "/usr/bin", @@ -82,6 +99,41 @@ test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs, }); }); +test("getDefaultCloudflaredCertEnv detects common CA bundle paths", () => { + const env = getDefaultCloudflaredCertEnv((candidate) => + ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs"].includes(candidate) + ); + + assert.deepEqual(env, { + SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt", + SSL_CERT_DIR: "/etc/ssl/certs", + }); +}); + +test("buildCloudflaredChildEnv injects discovered CA paths when the parent env omits them", () => { + const env = buildCloudflaredChildEnv( + { PATH: "/usr/bin" }, + { + runtimeRoot: "/managed/runtime", + homeDir: "/managed/runtime/home", + configDir: "/managed/runtime/config", + cacheDir: "/managed/runtime/cache", + dataDir: "/managed/runtime/data", + tempDir: "/managed/runtime/tmp", + userProfileDir: "/managed/runtime/userprofile", + appDataDir: "/managed/runtime/userprofile/AppData/Roaming", + localAppDataDir: "/managed/runtime/userprofile/AppData/Local", + }, + { + SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt", + SSL_CERT_DIR: "/etc/ssl/certs", + } + ); + + assert.equal(env.SSL_CERT_FILE, "/etc/ssl/certs/ca-certificates.crt"); + assert.equal(env.SSL_CERT_DIR, "/etc/ssl/certs"); +}); + test("getCloudflaredStartArgs relies on cloudflared protocol auto-negotiation", () => { assert.deepEqual(getCloudflaredStartArgs("http://127.0.0.1:20128"), [ "tunnel", diff --git a/tests/unit/combo-test-health.test.mjs b/tests/unit/combo-test-health.test.mjs index 2471656d46..19de579271 100644 --- a/tests/unit/combo-test-health.test.mjs +++ b/tests/unit/combo-test-health.test.mjs @@ -9,7 +9,8 @@ test("combo test helper builds a realistic smoke payload", () => { assert.equal(body.model, "openrouter/openai/gpt-5.4"); assert.equal(body.messages[0].content, "Reply with OK only."); - assert.equal(body.max_tokens, 16); + assert.equal(body.max_tokens, 64); + assert.equal(body.temperature, 0); assert.equal(body.stream, false); }); @@ -46,6 +47,62 @@ test("combo test helper extracts text from block-based responses", () => { assert.equal(text, "OK\nConfirmed."); }); +test("combo test helper extracts reasoning content when visible text is absent", () => { + const text = extractComboTestResponseText({ + choices: [ + { + message: { + role: "assistant", + content: null, + reasoning_content: "Working through the request.\nOK", + }, + }, + ], + }); + + assert.equal(text, "Working through the request.\nOK"); +}); + +test("combo test helper extracts reasoning_text aliases from GitHub-style responses", () => { + const text = extractComboTestResponseText({ + choices: [ + { + message: { + role: "assistant", + content: "", + reasoning_text: "Reasoning trace", + }, + }, + ], + }); + + assert.equal(text, "Reasoning trace"); +}); + +test("combo test helper treats reasoning-only completions as a healthy signal", () => { + const text = extractComboTestResponseText({ + choices: [ + { + finish_reason: "length", + message: { + role: "assistant", + content: "", + }, + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 12, + total_tokens: 18, + completion_tokens_details: { + reasoning_tokens: 12, + }, + }, + }); + + assert.equal(text, "[reasoning-only completion]"); +}); + test("combo test helper returns empty string when no text content exists", () => { const text = extractComboTestResponseText({ choices: [ diff --git a/tests/unit/combo-test-route.test.mjs b/tests/unit/combo-test-route.test.mjs index 2cadf2ec1c..3a75a3322f 100644 --- a/tests/unit/combo-test-route.test.mjs +++ b/tests/unit/combo-test-route.test.mjs @@ -86,6 +86,8 @@ test("combo test route marks a model healthy only when it returns assistant text assert.match(fetchCalls[0].init.headers["X-Request-Id"], /^combo-test-/); assert.equal(forwardedBody.model, "openrouter/openai/gpt-5.4"); assert.equal(forwardedBody.messages[0].content, "Reply with OK only."); + assert.equal(forwardedBody.max_tokens, 64); + assert.equal(forwardedBody.temperature, 0); assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4"); assert.equal(body.results[0].status, "ok"); assert.equal(body.results[0].responseText, "OK"); @@ -122,6 +124,45 @@ test("combo test route treats empty successful responses as failures", async () assert.match(body.results[0].error, /no text content/i); }); +test("combo test route accepts reasoning-only completions as healthy smoke-test responses", async () => { + await createTestCombo(); + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + choices: [ + { + finish_reason: "length", + message: { + role: "assistant", + content: "", + }, + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 12, + total_tokens: 18, + completion_tokens_details: { + reasoning_tokens: 12, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + + const response = await route.POST(makeRequest()); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4"); + assert.equal(body.results[0].status, "ok"); + assert.equal(body.results[0].responseText, "[reasoning-only completion]"); +}); + test("combo test route surfaces provider errors instead of downgrading them to reachability", async () => { await createTestCombo(); @@ -148,3 +189,67 @@ test("combo test route surfaces provider errors instead of downgrading them to r assert.equal(body.results[0].error, "Upstream rejected this request shape"); assert.equal("probeMethod" in body.results[0], false); }); + +test("combo test route launches model probes concurrently while preserving combo order", async () => { + await createTestCombo(["provider/first", "provider/second", "provider/third"]); + + const fetchCalls = []; + const resolvers = []; + globalThis.fetch = (url, init = {}) => + new Promise((resolve) => { + fetchCalls.push({ url: String(url), init }); + resolvers.push(resolve); + }); + + const responsePromise = route.POST(makeRequest()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetchCalls.length, 3); + assert.deepEqual( + fetchCalls.map(({ init }) => JSON.parse(init.body).model), + ["provider/first", "provider/second", "provider/third"] + ); + + resolvers[2]( + new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "THIRD" } }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + resolvers[1]( + new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "SECOND" } }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + resolvers[0]( + new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "FIRST" } }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + + const response = await responsePromise; + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.resolvedBy, "provider/first"); + assert.deepEqual( + body.results.map((result) => ({ + model: result.model, + status: result.status, + responseText: result.responseText, + })), + [ + { model: "provider/first", status: "ok", responseText: "FIRST" }, + { model: "provider/second", status: "ok", responseText: "SECOND" }, + { model: "provider/third", status: "ok", responseText: "THIRD" }, + ] + ); +});