Resolve merge conflicts with main natively built Prompt Cache UI

This commit is contained in:
diegosouzapw
2026-03-30 18:20:19 -03:00
58 changed files with 2965 additions and 486 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):**

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "2.3.13",
"version": "3.3.5",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -226,23 +226,18 @@ export const REGISTRY: Record<string, RegistryEntry> = {
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" },
],
},

View File

@@ -60,6 +60,12 @@ export {
getSessionSnapshotInput,
getSessionSnapshotOutput,
getSessionSnapshotTool,
cacheStatsInput,
cacheStatsOutput,
cacheStatsTool,
cacheFlushInput,
cacheFlushOutput,
cacheFlushTool,
} from "./tools.ts";
// A2A schemas

View File

@@ -806,11 +806,73 @@ export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof
sourceEndpoints: ["/api/pricing/sync"],
};
// ============ Cache Tools ============
export const cacheStatsInput = z.object({}).describe("No parameters required");
export const cacheStatsOutput = z.object({
semanticCache: z.object({
memoryEntries: z.number(),
dbEntries: z.number(),
hits: z.number(),
misses: z.number(),
hitRate: z.string(),
tokensSaved: z.number(),
}),
promptCache: z
.object({
totalRequests: z.number(),
requestsWithCacheControl: z.number(),
totalCachedTokens: z.number(),
totalCacheCreationTokens: z.number(),
estimatedCostSaved: z.number(),
})
.nullable(),
idempotency: z.object({
activeKeys: z.number(),
windowMs: z.number(),
}),
});
export const cacheStatsTool: McpToolDefinition<typeof cacheStatsInput, typeof cacheStatsOutput> = {
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<typeof cacheFlushInput, typeof cacheFlushOutput> = {
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) */

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -159,13 +159,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
* @returns {Promise<unknown>} 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<string, UsageQuota> = {};
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 }

View File

@@ -141,10 +141,10 @@ export function detectVolumeSignals(body: Record<string, unknown>): VolumeSignal
* @param currentStrategy - The combo's configured strategy
* @returns Override recommendation
*/
export function recommendStrategyOverride(
export async function recommendStrategyOverride(
signals: VolumeSignals,
currentStrategy: string
): StrategyOverride {
): Promise<StrategyOverride> {
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 {

View File

@@ -72,12 +72,7 @@ const DETERMINISTIC_STRATEGIES: Set<RoutingStrategyValue> = 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

6
package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -433,43 +433,78 @@ export default function A2ADashboardPage() {
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
<th className="text-left py-2 pr-2">{t("tableState")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
<th className="text-left py-2">{t("tableActions")}</th>
</tr>
</thead>
<tbody>
{tasksData.tasks.map((task) => (
<tr key={task.id} className="border-b border-border/40">
<td className="py-2 pr-2 font-mono text-xs">{task.id}</td>
<td className="py-2 pr-2">{task.skill}</td>
<td className="py-2 pr-2">
<span className={`text-xs px-2 py-1 rounded-full ${stateClass(task.state)}`}>
{t(`state.${task.state}`)}
</span>
</td>
<td className="py-2 pr-2 text-xs">
{new Date(task.updatedAt).toLocaleString()}
</td>
<td className="py-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={() => handleLoadTask(task.id)}>
{t("view")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleCancelTask(task.id)}
disabled={
task.state === "completed" ||
task.state === "failed" ||
task.state === "cancelled" ||
actionBusy === "cancel"
}
>
{t("cancel")}
</Button>
</td>
</tr>
))}
{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 (
<tr key={task.id} className="border-b border-border/40">
<td className="py-2 pr-2 font-mono text-xs">{task.id}</td>
<td className="py-2 pr-2">{task.skill}</td>
<td className="py-2 pr-2">
<span
className={`text-xs px-2 py-1 rounded-full ${stateClass(task.state)}`}
>
{t(`state.${task.state}`)}
</span>
</td>
<td className="py-2 pr-2">
{fsmPhase ? (
<span
className={`text-xs px-2 py-1 rounded border border-current/20 font-medium ${fsmBadgeColor}`}
>
{fsmPhase}
</span>
) : (
<span className="text-xs text-text-muted"></span>
)}
</td>
<td className="py-2 pr-2 text-xs">
{new Date(task.updatedAt).toLocaleString()}
</td>
<td className="py-2 flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleLoadTask(task.id)}
>
{t("view")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleCancelTask(task.id)}
disabled={
task.state === "completed" ||
task.state === "failed" ||
task.state === "cancelled" ||
actionBusy === "cancel"
}
>
{t("cancel")}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -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<any>(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 (
<Card className="p-5 flex flex-col justify-center items-center h-full min-h-[200px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</Card>
);
}
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 (
<Card className="p-5 flex flex-col h-full bg-[var(--card-bg,#1e1e2e)] relative overflow-hidden group">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-[20px] text-cyan-400">pie_chart</span>
<h3 className="font-semibold text-[var(--text-primary,#fff)] flex-1">
Provider Diversity Score
</h3>
<span
className={`text-xs px-2 py-0.5 rounded-md border ${gaugeColor.replace("bg-", "border-").replace("500", "500/20")} ${gaugeColor.replace("bg-", "bg-").replace("500", "500/10")} ${riskColor}`}
>
Shannon Entropy
</span>
</div>
<div className="flex items-center justify-between mt-2 mb-6">
<div className="flex flex-col">
<span className={`text-4xl font-bold tabular-nums tracking-tight ${riskColor}`}>
{scorePercentage}%
</span>
<span className="text-sm text-[var(--text-muted,#aaaaaa)] mt-1">{riskLabel}</span>
</div>
{/* Simple CSS Donut */}
<div className="relative w-20 h-20 flex-shrink-0">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
<path
className="text-[var(--border,#333)]"
strokeWidth="4"
stroke="currentColor"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className={riskColor}
strokeWidth="4"
strokeDasharray={`${scorePercentage}, 100`}
stroke="currentColor"
fill="none"
strokeLinecap="round"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</div>
</div>
<div className="space-y-4 flex-1">
<p className="text-xs uppercase tracking-wider font-semibold text-[var(--text-muted,#888)]">
Provider Share
</p>
{Object.keys(data.providers || {}).length === 0 ? (
<div className="text-sm text-[var(--text-secondary,#666)] py-2">
No recent usage data available.
</div>
) : (
<div className="space-y-3">
{Object.entries(data.providers)
.sort(([, a]: any, [, b]: any) => b.share - a.share)
.slice(0, 4) // Top 4 providers
.map(([provider, stat]: [string, any]) => (
<div key={provider} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-[var(--text-primary,#ddd)] capitalize">
{provider}
</span>
<span className="font-mono text-[var(--text-muted,#aaa)]">
{Math.round(stat.share * 100)}%
</span>
</div>
<div className="w-full h-1.5 bg-[var(--surface,#333)] rounded-full overflow-hidden">
<div
className={`h-full ${gaugeColor} rounded-full`}
style={{ width: `${Math.round(stat.share * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-[var(--border,#333)] flex justify-between text-[11px] text-[var(--text-muted,#777)]">
<span>Window: {data.windowSize} reqs</span>
<span>Based on Last {Math.round(data.ttlMs / 60000)} mins</span>
</div>
</Card>
);
}

View File

@@ -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" && (
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DiversityScoreCard />
</div>
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
</div>
)}
{activeTab === "evals" && <EvalsTab />}
{activeTab === "search" && <SearchAnalyticsTab />}

View File

@@ -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<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AuditEntry | null>(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 (
<div className="flex justify-center p-8">
<div className="w-6 h-6 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-12 text-[var(--text-muted,#666)]">
<svg
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="w-12 h-12 mb-4 opacity-50"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p>No Configuration Audit Logs found.</p>
</div>
);
}
return (
<div className="w-full">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-[var(--border,#333)] text-[var(--text-secondary,#aaa)] text-sm">
<th className="px-6 py-4 font-medium">Timestamp</th>
<th className="px-6 py-4 font-medium">Action</th>
<th className="px-6 py-4 font-medium">Target</th>
<th className="px-6 py-4 font-medium">Resource</th>
<th className="px-6 py-4 font-medium">Source</th>
<th className="px-6 py-4 font-medium text-right">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border,#333)]">
{entries.map((entry) => (
<tr
key={entry.id}
className="hover:bg-[var(--hover-bg,#2a2a3e)] transition-colors group"
>
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-secondary,#aaa)]">
{new Date(entry.timestamp).toLocaleString()}
</td>
<td className="px-6 py-3 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-md border capitalize ${getActionColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-primary,#fff)] font-medium capitalize">
{entry.target}
</td>
<td className="px-6 py-3 text-sm text-[var(--text-secondary,#aaa)] font-mono">
{entry.targetName}
</td>
<td className="px-6 py-3 whitespace-nowrap text-sm text-[var(--text-muted,#666)] capitalize">
{entry.source}
</td>
<td className="px-6 py-3 whitespace-nowrap text-right">
<button
onClick={() => setSelectedEntry(entry)}
className="px-3 py-1 text-xs font-medium text-[var(--text-primary,#fff)] bg-[var(--accent,#7c3aed)] hover:bg-opacity-80 rounded-md transition-colors invisible group-hover:visible"
>
View Diff
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedEntry && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)] rounded-2xl w-full max-w-4xl max-h-[85vh] flex flex-col shadow-2xl overflow-hidden scale-in">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-[var(--border,#333)] bg-[#15151f]">
<div>
<h3 className="text-xl font-semibold text-[var(--text-primary,#fff)] capitalize">
{selectedEntry.action} {selectedEntry.target}
</h3>
<p className="text-sm text-[var(--text-secondary,#aaa)] font-mono mt-1">
ID: {selectedEntry.targetId} {selectedEntry.targetName}
</p>
</div>
<button
onClick={() => setSelectedEntry(null)}
className="p-2 text-[var(--text-muted,#666)] hover:text-white bg-[var(--hover-bg,#2a2a3e)] hover:bg-[#333] rounded-full transition-colors"
title="Close"
>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" className="w-5 h-5">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Modal Content */}
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 bg-[#1a1a24]">
{selectedEntry.note && (
<div className="mb-6 p-4 bg-yellow-500/10 border border-yellow-500/20 text-yellow-300 rounded-xl text-sm italic">
📝 {selectedEntry.note}
</div>
)}
{selectedEntry.diff?.isEmpty ? (
<div className="text-center p-8 text-[var(--text-muted,#666)]">
No changes detected in Diff
</div>
) : (
<div className="space-y-6">
{/* Added Keys */}
{selectedEntry.diff?.added?.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-green-400 mb-2 uppercase tracking-wider">
++ Added Properties
</h4>
<pre className="bg-[#111116] border border-green-500/20 rounded-xl p-4 overflow-x-auto text-xs font-mono text-green-300 shadow-inner">
{JSON.stringify(
selectedEntry.diff.added.reduce(
(acc, key) => ({ ...acc, [key]: selectedEntry.after?.[key] }),
{}
),
null,
2
)}
</pre>
</div>
)}
{/* Removed Keys */}
{selectedEntry.diff?.removed?.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-red-400 mb-2 uppercase tracking-wider">
-- Removed Properties
</h4>
<pre className="bg-[#111116] border border-red-500/20 rounded-xl p-4 overflow-x-auto text-xs font-mono text-red-300 shadow-inner">
{JSON.stringify(
selectedEntry.diff.removed.reduce(
(acc, key) => ({ ...acc, [key]: selectedEntry.before?.[key] }),
{}
),
null,
2
)}
</pre>
</div>
)}
{/* Changed Keys */}
{selectedEntry.diff?.changed?.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-yellow-400 mb-2 uppercase tracking-wider">
~ Modified Properties
</h4>
<div className="space-y-2">
{selectedEntry.diff.changed.map((change, idx) => (
<div
key={idx}
className="bg-[#111116] border border-yellow-500/20 rounded-xl overflow-hidden shadow-inner text-sm font-mono flex flex-col"
>
<div className="px-4 py-2 bg-[#1b1b22] border-b border-[#2d2d3a] text-yellow-500/80 font-semibold">
{change.key}
</div>
<div className="grid grid-cols-2 divide-x divide-[#2d2d3a]">
<div className="p-4 bg-red-500/5 text-red-300/80">
<div className="text-[10px] text-red-400/50 mb-1 uppercase">
Before
</div>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(change.from, null, 2)}
</pre>
</div>
<div className="p-4 bg-green-500/5 text-green-300/80">
<div className="text-[10px] text-green-400/50 mb-1 uppercase">
After
</div>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(change.to, null, 2)}
</pre>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { useState, useEffect } from "react";
import ConfigAuditViewer from "./ConfigAuditViewer";
export default function ConfigAuditPage() {
const [summary, setSummary] = useState<any>(null);
useEffect(() => {
fetch("/api/audit?summary=true")
.then((res) => res.json())
.then((data) => setSummary(data))
.catch((err) => console.error(err));
}, []);
return (
<div className="flex flex-col gap-6 w-full max-w-6xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary,#fff)]">
Configuration Audit
</h1>
<p className="text-sm text-[var(--text-secondary,#aaa)] mt-1">
Track and diff changes made to routing policies, combos, and connections.
</p>
</div>
{summary && (
<div className="flex items-center gap-4 text-sm bg-[var(--card-bg,#1e1e2e)] px-4 py-2 rounded-xl border border-[var(--border,#333)]">
<div className="flex flex-col">
<span className="text-[var(--text-muted,#666)]">Total Audits</span>
<span className="font-mono text-[var(--text-primary,#fff)] font-semibold">
{summary.totalEntries}
</span>
</div>
</div>
)}
</div>
<div className="bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)] rounded-xl overflow-hidden shadow-sm">
<ConfigAuditViewer />
</div>
</div>
);
}

View File

@@ -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<CacheEntry[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
});
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [deleting, setDeleting] = useState<string | null>(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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder={t("searchEntries")}
value={search}
onChange={(e) => 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"
/>
<Button variant="secondary" size="sm" onClick={() => fetchEntries()}>
{t("search")}
</Button>
</div>
{loading ? (
<div className="text-sm text-text-muted">{t("loading")}</div>
) : entries.length === 0 ? (
<div className="text-sm text-text-muted text-center py-8">{t("noEntries")}</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border/30">
<th className="pb-2 pr-4">{t("signature")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("hits")}</th>
<th className="pb-2 pr-4">{t("tokensSaved")}</th>
<th className="pb-2 pr-4">{t("created")}</th>
<th className="pb-2 pr-4">{t("expires")}</th>
<th className="pb-2">{t("actions")}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-b border-border/20">
<td className="py-2 pr-4 font-mono text-xs">
{entry.signature.slice(0, 12)}...
</td>
<td className="py-2 pr-4">{entry.model}</td>
<td className="py-2 pr-4 tabular-nums">{entry.hit_count}</td>
<td className="py-2 pr-4 tabular-nums text-green-500">
{entry.tokens_saved.toLocaleString()}
</td>
<td className="py-2 pr-4 text-xs text-text-muted">
{formatDate(entry.created_at)}
</td>
<td className="py-2 pr-4 text-xs text-text-muted">
{formatDate(entry.expires_at)}
</td>
<td className="py-2">
<button
onClick={() => handleDelete(entry.signature)}
disabled={deleting === entry.signature}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
{deleting === entry.signature ? "..." : "🗑️"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
variant="secondary"
size="sm"
onClick={() => fetchEntries(pagination.page - 1)}
disabled={pagination.page <= 1}
>
</Button>
<span className="text-sm text-text-muted">
{pagination.page} / {pagination.totalPages}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => fetchEntries(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
>
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -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<string, PromptCacheProviderStats>;
byStrategy: Record<string, PromptCacheProviderStats>;
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<CacheStats | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
@@ -191,152 +228,334 @@ export default function CachePage() {
</div>
</div>
{/* Loading skeleton */}
{loading && (
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4"
aria-busy="true"
aria-label="Loading cache statistics"
{/* Tab navigation */}
<div className="flex gap-1 p-1 rounded-lg bg-black/5 dark:bg-white/5 w-fit">
<button
onClick={() => setActiveTab("overview")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === "overview"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-surface-raised animate-pulse" />
))}
</div>
)}
{t("overview")}
</button>
<button
onClick={() => setActiveTab("entries")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === "entries"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
{t("entries")}
</button>
</div>
{/* Error / empty state */}
{!loading && !stats && (
<EmptyState
icon="cached"
title={t("unavailable")}
description={t("unavailableDesc")}
actionLabel={t("refresh")}
onAction={() => void fetchStats()}
/>
)}
{/* Entries tab */}
{activeTab === "entries" && <CacheEntriesTab />}
{/* Main content */}
{!loading && stats && (
{/* Overview tab content */}
{activeTab === "overview" && (
<>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon="memory"
label={t("memoryEntries")}
value={sc?.memoryEntries ?? 0}
sub={t("memoryEntriesSub")}
/>
<StatCard
icon="storage"
label={t("dbEntries")}
value={sc?.dbEntries ?? 0}
sub={t("dbEntriesSub")}
/>
<StatCard
icon="trending_up"
label={t("cacheHits")}
value={sc?.hits ?? 0}
sub={t("cacheHitsSub", { total: totalRequests })}
valueClass="text-green-500"
/>
<StatCard
icon="token"
label={t("tokensSaved")}
value={(sc?.tokensSaved ?? 0).toLocaleString()}
sub={t("tokensSavedSub")}
valueClass="text-blue-400"
/>
</div>
{/* Hit rate + breakdown */}
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="font-medium text-sm">{t("performance")}</h2>
<span className="text-xs text-text-muted">
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
</span>
</div>
<HitRateBar hitRate={hitRate} label={t("hitRate")} />
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
<div>
<div className="text-lg font-semibold tabular-nums text-green-500">
{sc?.hits ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("hits")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums text-red-400">
{sc?.misses ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("misses")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
</div>
</div>
{/* Loading skeleton */}
{loading && (
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4"
aria-busy="true"
aria-label="Loading cache statistics"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-surface-raised animate-pulse" />
))}
</div>
</Card>
)}
{/* Cache behavior */}
<Card>
<div className="p-5 flex flex-col gap-3">
<h2 className="font-medium text-sm">{t("behavior")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<InfoRow icon="info">{t("behaviorDeterministic")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorBypass", {
header: (chunks) => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
{chunks}
</code>
),
})}
</InfoRow>
<InfoRow icon="info">{t("behaviorTwoTier")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorTtl", {
envVar: (chunks) => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
{chunks}
</code>
),
})}
</InfoRow>
</div>
</div>
</Card>
{/* Error / empty state */}
{!loading && !stats && (
<EmptyState
icon="cached"
title={t("unavailable")}
description={t("unavailableDesc")}
actionLabel={t("refresh")}
onAction={() => void fetchStats()}
/>
)}
{/* Idempotency */}
<Card>
<div className="p-5 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
fingerprint
</span>
<h2 className="font-medium text-sm">{t("idempotency")}</h2>
{/* Main content */}
{!loading && stats && (
<>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon="memory"
label={t("memoryEntries")}
value={sc?.memoryEntries ?? 0}
sub={t("memoryEntriesSub")}
/>
<StatCard
icon="storage"
label={t("dbEntries")}
value={sc?.dbEntries ?? 0}
sub={t("dbEntriesSub")}
/>
<StatCard
icon="trending_up"
label={t("cacheHits")}
value={sc?.hits ?? 0}
sub={t("cacheHitsSub", { total: totalRequests })}
valueClass="text-green-500"
/>
<StatCard
icon="token"
label={t("tokensSaved")}
value={(sc?.tokensSaved ?? 0).toLocaleString()}
sub={t("tokensSavedSub")}
valueClass="text-blue-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">{idp?.activeKeys ?? 0}</div>
<div className="text-xs text-text-muted mt-0.5">{t("activeDedupKeys")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{idp ? `${(idp.windowMs / 1000).toFixed(0)}s` : "—"}
{/* Hit rate + breakdown */}
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="font-medium text-sm">{t("performance")}</h2>
<span className="text-xs text-text-muted">
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
</span>
</div>
<HitRateBar hitRate={hitRate} label={t("hitRate")} />
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
<div>
<div className="text-lg font-semibold tabular-nums text-green-500">
{sc?.hits ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("hits")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums text-red-400">
{sc?.misses ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("misses")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
</div>
</div>
<div className="text-xs text-text-muted mt-0.5">{t("dedupWindow")}</div>
</div>
</div>
</div>
</Card>
</Card>
{/* Claude Prompt Cache Metrics */}
<CacheStatsCard />
{/* Prompt Cache Stats */}
{pc && (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
bolt
</span>
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{pc.requestsWithCacheControl.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cachedRequests")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-green-500">
{promptCacheHitRate.toFixed(1)}%
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-blue-400">
{pc.totalCachedTokens.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cachedTokens")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-purple-400">
{pc.totalCacheCreationTokens.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">
{t("cacheCreationTokens")}
</div>
</div>
</div>
{providerEntries.length > 0 && (
<div className="pt-3 border-t border-border/30">
<h3 className="text-xs font-medium text-text-muted mb-3">
{t("byProvider")}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border/30">
<th className="pb-2 pr-4">{t("provider")}</th>
<th className="pb-2 pr-4">{t("requests")}</th>
<th className="pb-2 pr-4">{t("inputTokens")}</th>
<th className="pb-2 pr-4">{t("cachedTokensCol")}</th>
<th className="pb-2">{t("cacheCreation")}</th>
</tr>
</thead>
<tbody>
{providerEntries.map(([provider, data]) => (
<tr key={provider} className="border-b border-border/20">
<td className="py-2 pr-4 font-medium">{provider}</td>
<td className="py-2 pr-4 tabular-nums">
{data.requests.toLocaleString()}
</td>
<td className="py-2 pr-4 tabular-nums">
{data.inputTokens.toLocaleString()}
</td>
<td className="py-2 pr-4 tabular-nums text-green-500">
{data.cachedTokens.toLocaleString()}
</td>
<td className="py-2 tabular-nums text-purple-400">
{data.cacheCreationTokens.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Card>
)}
{/* Cache Trend (24h) */}
{trend.length > 0 && (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
timeline
</span>
<h2 className="font-medium text-sm">{t("trend24h")}</h2>
</div>
<div className="flex items-end gap-1 h-32">
{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 (
<div
key={point.timestamp}
className="flex-1 flex flex-col items-center gap-1 group relative"
>
<div className="absolute bottom-full mb-1 hidden group-hover:block bg-surface-raised border border-border rounded px-2 py-1 text-xs whitespace-nowrap z-10">
{hour}: {point.requests} {t("requests").toLowerCase()},{" "}
{point.cachedRequests} {t("cached").toLowerCase()}
</div>
<div className="w-full flex flex-col justify-end h-full gap-px">
<div
className="w-full bg-green-500/30 rounded-t"
style={{ height: `${cachedHeight}%` }}
/>
<div
className="w-full bg-text-muted/20 rounded-t"
style={{ height: `${height - cachedHeight}%` }}
/>
</div>
<span className="text-[10px] text-text-muted truncate w-full text-center">
{hour.split(":")[0]}
</span>
</div>
);
})}
</div>
<div className="flex items-center gap-4 text-xs text-text-muted">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-text-muted/20" />
<span>{t("total")}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-green-500/30" />
<span>{t("cached")}</span>
</div>
</div>
</div>
</Card>
)}
{/* Cache behavior */}
<Card>
<div className="p-5 flex flex-col gap-3">
<h2 className="font-medium text-sm">{t("behavior")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<InfoRow icon="info">{t("behaviorDeterministic")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorBypass", {
header: () => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
X-OmniRoute-No-Cache: true
</code>
),
})}
</InfoRow>
<InfoRow icon="info">{t("behaviorTwoTier")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorTtl", {
envVar: () => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
SEMANTIC_CACHE_TTL_MS
</code>
),
})}
</InfoRow>
</div>
</div>
</Card>
{/* Idempotency */}
<Card>
<div className="p-5 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
fingerprint
</span>
<h2 className="font-medium text-sm">{t("idempotency")}</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{idp?.activeKeys ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("activeDedupKeys")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{idp ? `${(idp.windowMs / 1000).toFixed(0)}s` : "—"}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("dedupWindow")}</div>
</div>
</div>
</div>
</Card>
</>
)}
</>
)}
</div>

View File

@@ -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() {
</Card>
</div>
{/* Graceful Degradation Status */}
{degradation && degradation.features && degradation.features.length > 0 && (
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">healing</span>
Graceful Degradation Status
</h2>
<div className="flex items-center gap-3 text-xs text-text-muted font-medium">
<span className="px-2 py-0.5 rounded bg-green-500/10 text-green-400">
Full: {degradation.summary.full}
</span>
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-500">
Reduced: {degradation.summary.reduced}
</span>
<span className="px-2 py-0.5 rounded bg-orange-500/10 text-orange-500">
Minimal: {degradation.summary.minimal}
</span>
<span className="px-2 py-0.5 rounded bg-red-500/10 text-red-500">
Default: {degradation.summary.default}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{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 (
<div
key={feat.feature}
className={`rounded-lg p-3 border \${bg} flex flex-col gap-2`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold capitalize flex items-center gap-2 text-[var(--text-primary,#fff)]">
<span className={`w-2 h-2 rounded-full \${dot}`}></span>
{feat.feature}
</span>
<span className="text-xs uppercase tracking-wider font-bold opacity-70">
{feat.level}
</span>
</div>
<div className="text-xs text-[var(--text-secondary,#aaa)]">{feat.capability}</div>
{feat.reason && (
<div
className="text-[10px] text-red-300 mt-1 bg-red-900/20 p-1.5 rounded"
title={feat.reason}
>
{feat.reason.length > 80 ? feat.reason.substring(0, 80) + "..." : feat.reason}
</div>
)}
<div className="text-[10px] text-[var(--text-muted,#666)] text-right mt-1">
Since {new Date(feat.since).toLocaleTimeString()}
</div>
</div>
);
})}
</div>
</Card>
)}
{/* Telemetry Cards — Latency & Prompt Cache */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Latency Card */}

View File

@@ -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);
}}
/>

View File

@@ -95,6 +95,7 @@ function getConnectionErrorTag(connection) {
export default function ProvidersPage() {
const [connections, setConnections] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
const [expirations, setExpirations] = useState<any>(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 (
<div className="flex flex-col gap-6">
{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
<div
className={`p-4 rounded-xl flex items-start gap-3 border ${
expirations.summary.expired > 0
? "bg-red-500/10 border-red-500/20"
: "bg-amber-500/10 border-amber-500/20"
}`}
>
<span
className={`material-symbols-outlined text-[24px] ${
expirations.summary.expired > 0 ? "text-red-500" : "text-amber-500"
}`}
>
{expirations.summary.expired > 0 ? "error" : "warning"}
</span>
<div className="flex-1">
<h3
className={`font-semibold ${expirations.summary.expired > 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`}
</h3>
<p className="text-sm mt-1 opacity-80 text-text-main">
{expirations.summary.expired > 0
? "Immediate action required. Expired connections will permanently fail."
: "Please review and renew expiring connections to avoid disruption."}
</p>
</div>
</div>
)}
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
@@ -582,6 +629,16 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
) : (
<>
{getStatusDisplay(connected, error, errorCode, t)}
{stats.expiryStatus === "expired" && (
<Badge variant="error" size="sm" dot>
Expired
</Badge>
)}
{stats.expiryStatus === "expiring_soon" && (
<Badge variant="warning" size="sm" dot>
Expiring Soon
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
@@ -709,6 +766,16 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
) : (
<>
{getStatusDisplay(connected, error, errorCode, t)}
{stats.expiryStatus === "expired" && (
<Badge variant="error" size="sm" dot>
Expired
</Badge>
)}
{stats.expiryStatus === "expiring_soon" && (
<Badge variant="warning" size="sm" dot>
Expiring Soon
</Badge>
)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? t("responses") : t("chat")}

View File

@@ -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<CacheConfig>({
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 (
<Card className="p-6">
<p className="text-sm text-text-muted">{t("loading")}</p>
</Card>
);
}
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-[20px]">cached</span>
{t("cacheSettings")}
</h3>
<div className="space-y-6">
{/* Semantic Cache */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-main">{t("semanticCache")}</h4>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("enabled")}</span>
<button
onClick={() =>
setConfig((c) => ({ ...c, semanticCacheEnabled: !c.semanticCacheEnabled }))
}
className={`relative w-10 h-5 rounded-full transition-colors ${
config.semanticCacheEnabled ? "bg-green-500" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
config.semanticCacheEnabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("maxEntries")}</span>
<input
type="number"
min={1}
max={1000}
value={config.semanticCacheMaxSize}
onChange={(e) =>
setConfig((c) => ({ ...c, semanticCacheMaxSize: parseInt(e.target.value) || 100 }))
}
className="w-24 px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("ttlMinutes")}</span>
<input
type="number"
min={1}
max={1440}
value={Math.round(config.semanticCacheTTL / 60000)}
onChange={(e) =>
setConfig((c) => ({
...c,
semanticCacheTTL: (parseInt(e.target.value) || 30) * 60000,
}))
}
className="w-24 px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
/>
</label>
</div>
{/* Prompt Cache */}
<div className="space-y-3 pt-4 border-t border-border/30">
<h4 className="text-sm font-medium text-text-main">{t("promptCache")}</h4>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("enabled")}</span>
<button
onClick={() =>
setConfig((c) => ({ ...c, promptCacheEnabled: !c.promptCacheEnabled }))
}
className={`relative w-10 h-5 rounded-full transition-colors ${
config.promptCacheEnabled ? "bg-green-500" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
config.promptCacheEnabled ? "left-5" : "left-0.5"
}`}
/>
</button>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("strategy")}</span>
<select
value={config.promptCacheStrategy}
onChange={(e) =>
setConfig((c) => ({
...c,
promptCacheStrategy: e.target.value as CacheConfig["promptCacheStrategy"],
}))
}
className="px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
>
<option value="auto">Auto</option>
<option value="system-only">System Only</option>
<option value="manual">Manual</option>
</select>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-text-muted">{t("preserveClientCache")}</span>
<select
value={config.alwaysPreserveClientCache}
onChange={(e) =>
setConfig((c) => ({
...c,
alwaysPreserveClientCache: e.target
.value as CacheConfig["alwaysPreserveClientCache"],
}))
}
className="px-2 py-1 text-sm rounded border border-border bg-surface text-text-main"
>
<option value="auto">Auto</option>
<option value="always">Always</option>
<option value="never">Never</option>
</select>
</label>
</div>
{/* Save */}
<div className="pt-4 border-t border-border/30">
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? t("saving") : t("save")}
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -152,6 +152,40 @@ export default function RoutingTab() {
</p>
</Card>
{/* Adaptive Volume Routing */}
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500 h-fit">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
network_ping
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{t("adaptiveVolumeRouting") || "Adaptive Volume Routing"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("adaptiveVolumeRoutingDesc") ||
"Automatically adjusts traffic volume between providers based on real-time latency and error rates."}
</p>
</div>
</div>
<div className="pt-1">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={!!settings.adaptiveVolumeRouting}
onChange={(e) => updateSetting({ adaptiveVolumeRouting: e.target.checked })}
disabled={loading}
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
</Card>
{/* Wildcard Aliases */}
<Card>
<div className="flex items-center gap-3 mb-4">

View File

@@ -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() {
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheSettingsTab />
</div>
)}

View File

@@ -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 */}
<span
title={q.modelKey || q.name}
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>

View File

@@ -11,15 +11,6 @@ const PROVIDER_PLAN_FALLBACKS = new Set([
]);
const QUOTA_LABEL_MAP: Record<string, string> = {
"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) {

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

95
src/app/api/cache/entries/route.ts vendored Normal file
View File

@@ -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 });
}
}

View File

@@ -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=<name> — invalidate all entries for a specific model
* ?signature=<hex> — invalidate a single entry by its SHA-256 signature
* ?staleMs=<number> — 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);

View File

@@ -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,

View File

@@ -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 });
}
}

View File

@@ -139,19 +139,7 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
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) {

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown> = {};
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<string, unknown> = {};
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 });
}
}

View File

@@ -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) {

View File

@@ -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"
}
}

View File

@@ -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<CloudflaredTunnelStatus>
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<CloudflaredTunnelStatus>
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<CloudflaredTunnelStatus>
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;
}

View File

@@ -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 "";
}

View File

@@ -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<string, unknown>) {
return getCacheMetrics();
}
export interface CacheTrendPoint {
timestamp: string;
requests: number;
cachedRequests: number;
inputTokens: number;
cachedTokens: number;
cacheCreationTokens: number;
}
export async function getCacheTrend(hours = 24): Promise<CacheTrendPoint[]> {
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

View File

@@ -0,0 +1 @@
export { analyzePrefix, shouldInjectCacheControl } from "./prefixAnalyzer";

View File

@@ -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;
}

View File

@@ -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<string, unknown> | 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,
};
}

View File

@@ -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 (
<Link
href="/dashboard/health"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 transition-colors border border-amber-500/20"
title={t("warning")} // Using common warning text, or we could just use English / fixed string if i18n is not strict
>
<span className="material-symbols-outlined text-[16px]">healing</span>
<span className="text-xs font-semibold whitespace-nowrap">Degraded</span>
</Link>
);
}

View File

@@ -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 */}
<ThemeToggle />
{/* Token health */}
{/* Degradation & Token health */}
<DegradationBadge />
<TokenHealthBadge />
{/* Logout button */}

View File

@@ -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 <CardSkeleton />;
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
@@ -176,9 +196,9 @@ export default function UsageAnalytics() {
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
<StatCard
icon="rule"
label="Requested Coverage"
value={`${Number(s.requestedModelCoveragePct || 0).toFixed(1)}%`}
icon="network_node"
label="Diversity Score"
value={`${providerDiversity.toFixed(1)}%`}
color="text-sky-500"
/>
</div>

View File

@@ -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",

View File

@@ -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" },
];

View File

@@ -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(

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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" },
]
);
});