mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(ui): integrate FSM, adaptive routing, and provider diversity
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.3.3
|
||||
version: 3.3.4
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
272
src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx
Normal file
272
src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/app/(dashboard)/dashboard/audit/page.tsx
Normal file
44
src/app/(dashboard)/dashboard/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
38
src/app/api/audit/route.ts
Normal file
38
src/app/api/audit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/health/degradation/route.ts
Normal file
30
src/app/api/health/degradation/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/providers/expiration/route.ts
Normal file
17
src/app/api/providers/expiration/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
src/shared/components/DegradationBadge.tsx
Normal file
41
src/shared/components/DegradationBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user