Merge branch 'features-agente-mcp-a2a'

# Conflicts:
#	package-lock.json
This commit is contained in:
diegosouzapw
2026-03-05 18:11:27 -03:00
380 changed files with 50417 additions and 13057 deletions

View File

@@ -0,0 +1,555 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
type A2ATaskState = "submitted" | "working" | "completed" | "failed" | "cancelled";
type A2AStatus = {
status: "ok";
tasks: {
counts: Record<A2ATaskState, number>;
total: number;
activeStreams: number;
lastTaskAt: string | null;
};
agent: {
name: string;
description: string;
version: string;
url: string;
} | null;
capabilities: Record<string, unknown> | null;
skills: Array<{
id: string;
name: string;
description: string;
tags?: string[];
}>;
};
type TaskArtifact = {
type: "text" | "json" | "error";
content: string;
};
type TaskEvent = {
timestamp: string;
state: A2ATaskState;
message?: string;
};
type A2ATask = {
id: string;
skill: string;
state: A2ATaskState;
input: {
skill: string;
messages: Array<{ role: string; content: string }>;
metadata?: Record<string, unknown>;
};
artifacts: TaskArtifact[];
events: TaskEvent[];
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
expiresAt: string;
};
type TaskListResponse = {
tasks: A2ATask[];
total: number;
limit: number;
offset: number;
};
const PAGE_SIZE = 20;
const TASK_STATES: Array<"all" | A2ATaskState> = [
"all",
"submitted",
"working",
"completed",
"failed",
"cancelled",
];
function stateClass(state: A2ATaskState) {
if (state === "completed") return "bg-green-500/15 text-green-500";
if (state === "failed") return "bg-red-500/15 text-red-500";
if (state === "working") return "bg-amber-500/15 text-amber-500";
if (state === "cancelled") return "bg-gray-500/15 text-gray-400";
return "bg-blue-500/15 text-blue-500";
}
export default function A2ADashboardPage() {
const t = useTranslations("a2aDashboard");
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<A2AStatus | null>(null);
const [stateFilter, setStateFilter] = useState<"all" | A2ATaskState>("all");
const [skillFilter, setSkillFilter] = useState("");
const [offset, setOffset] = useState(0);
const [tasksData, setTasksData] = useState<TaskListResponse>({
tasks: [],
total: 0,
limit: PAGE_SIZE,
offset: 0,
});
const [tasksLoading, setTasksLoading] = useState(false);
const [selectedTask, setSelectedTask] = useState<A2ATask | null>(null);
const [actionMessage, setActionMessage] = useState("");
const [actionBusy, setActionBusy] = useState<null | "cancel" | "send" | "stream">(null);
const refreshStatus = useCallback(async () => {
const response = await fetch("/api/a2a/status");
if (!response.ok) return;
const json = await response.json();
setStatus(json);
}, []);
const refreshTasks = useCallback(async () => {
setTasksLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
params.set("offset", String(offset));
if (stateFilter !== "all") params.set("state", stateFilter);
if (skillFilter) params.set("skill", skillFilter);
const response = await fetch(`/api/a2a/tasks?${params.toString()}`);
if (!response.ok) return;
const json = await response.json();
setTasksData({
tasks: Array.isArray(json.tasks) ? json.tasks : [],
total: Number(json.total || 0),
limit: Number(json.limit || PAGE_SIZE),
offset: Number(json.offset || 0),
});
} finally {
setTasksLoading(false);
}
}, [offset, stateFilter, skillFilter]);
useEffect(() => {
Promise.allSettled([refreshStatus(), refreshTasks()]).finally(() => setLoading(false));
const interval = setInterval(() => {
void refreshStatus();
void refreshTasks();
}, 30000);
return () => clearInterval(interval);
}, [refreshStatus, refreshTasks]);
useEffect(() => {
void refreshTasks();
}, [refreshTasks]);
const availableSkills = useMemo(() => {
const values = new Set<string>();
for (const skill of status?.skills || []) values.add(skill.id);
for (const task of tasksData.tasks) values.add(task.skill);
return Array.from(values.values()).sort();
}, [status, tasksData.tasks]);
const currentPage = Math.floor(tasksData.offset / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(tasksData.total / PAGE_SIZE));
const handleLoadTask = async (taskId: string) => {
const response = await fetch(`/api/a2a/tasks/${encodeURIComponent(taskId)}`);
if (!response.ok) return;
const json = await response.json();
setSelectedTask(json.task || null);
};
const handleCancelTask = async (taskId: string) => {
if (!globalThis.confirm(t("confirmCancelTask", { taskId }))) return;
setActionBusy("cancel");
setActionMessage("");
try {
const response = await fetch(`/api/a2a/tasks/${encodeURIComponent(taskId)}/cancel`, {
method: "POST",
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("cancelTaskFailed"));
return;
}
setActionMessage(t("cancelTaskSuccess", { taskId }));
await refreshStatus();
await refreshTasks();
if (selectedTask?.id === taskId) {
await handleLoadTask(taskId);
}
} finally {
setActionBusy(null);
}
};
const handleSmokeSend = async () => {
setActionBusy("send");
setActionMessage("");
try {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-send",
method: "message/send",
params: {
skill: "quota-management",
messages: [{ role: "user", content: "Show a short quota summary." }],
},
}),
});
const json = await response.json().catch(() => ({}));
if (!response.ok || json?.error) {
setActionMessage(json?.error?.message || t("smokeSendFailed"));
return;
}
const taskId = json?.result?.task?.id;
setActionMessage(taskId ? t("smokeSendSuccessWithTask", { taskId }) : t("smokeSendSuccess"));
await refreshStatus();
await refreshTasks();
} finally {
setActionBusy(null);
}
};
const handleSmokeStream = async () => {
setActionBusy("stream");
setActionMessage("");
try {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-stream",
method: "message/stream",
params: {
skill: "quota-management",
messages: [{ role: "user", content: "Stream a short quota summary." }],
},
}),
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
setActionMessage(text || t("smokeStreamFailed"));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let streamTaskId: string | null = null;
let terminalState: string | null = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const payload = part.slice("data: ".length);
let parsed: any;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const nextTaskId = parsed?.params?.task?.id;
const nextState = parsed?.params?.task?.state;
if (nextTaskId) streamTaskId = nextTaskId;
if (typeof nextState === "string") {
if (["completed", "failed", "cancelled"].includes(nextState)) {
terminalState = nextState;
}
}
}
}
if (streamTaskId) {
setActionMessage(
t("smokeStreamSuccessWithTask", {
taskId: streamTaskId,
stateSuffix: terminalState ? `, ${t(`state.${terminalState as A2ATaskState}`)}` : "",
})
);
} else {
setActionMessage(t("smokeStreamNoTaskId"));
}
await refreshStatus();
await refreshTasks();
} finally {
setActionBusy(null);
}
};
if (loading) {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="text-sm text-text-muted">{t("loading")}</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard label={t("health")} value={status?.status === "ok" ? t("ok") : "—"} />
<StatCard label={t("totalTasks")} value={status?.tasks?.total || 0} />
<StatCard label={t("activeStreams")} value={status?.tasks?.activeStreams || 0} />
<StatCard
label={t("lastTask")}
value={
status?.tasks?.lastTaskAt ? new Date(status.tasks.lastTaskAt).toLocaleTimeString() : "—"
}
/>
</div>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("taskStateOverview")}</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{(["submitted", "working", "completed", "failed", "cancelled"] as A2ATaskState[]).map(
(state) => (
<div key={state} className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs text-text-muted uppercase">{t(`state.${state}`)}</p>
<p className="text-2xl font-semibold mt-1">{status?.tasks?.counts?.[state] || 0}</p>
</div>
)
)}
</div>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="p-5">
<h2 className="text-lg font-semibold mb-3">{t("agentCard")}</h2>
{status?.agent ? (
<div className="space-y-2 text-sm">
<p className="font-semibold">{status.agent.name}</p>
<p className="text-text-muted">{status.agent.description}</p>
<p>
{t("version")}: <span className="font-mono">{status.agent.version}</span>
</p>
<p>
{t("url")}: <span className="font-mono text-xs break-all">{status.agent.url}</span>
</p>
<div className="pt-2">
<p className="text-xs uppercase text-text-muted mb-1">{t("capabilities")}</p>
<code className="text-xs break-all">
{JSON.stringify(status.capabilities || {}, null, 2)}
</code>
</div>
</div>
) : (
<p className="text-sm text-text-muted">{t("agentCardNotAvailable")}</p>
)}
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-3">{t("quickValidation")}</h2>
<p className="text-sm text-text-muted mb-3">{t("quickValidationDescription")}</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={handleSmokeSend}
disabled={actionBusy !== null}
>
{t("runMessageSend")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleSmokeStream}
disabled={actionBusy !== null}
>
{t("runMessageStream")}
</Button>
</div>
{actionMessage && <p className="text-sm text-text-muted mt-3">{actionMessage}</p>}
</Card>
</div>
<Card className="p-5">
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold">{t("taskManagement")}</h2>
<p className="text-sm text-text-muted">
{t("taskSummary", { total: tasksData.total, page: currentPage, totalPages })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={stateFilter}
onChange={(event) => {
setOffset(0);
setStateFilter(event.target.value as "all" | A2ATaskState);
}}
>
{TASK_STATES.map((state) => (
<option key={state} value={state}>
{state === "all" ? t("allStates") : t(`state.${state}`)}
</option>
))}
</select>
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={skillFilter}
onChange={(event) => {
setOffset(0);
setSkillFilter(event.target.value);
}}
>
<option value="">{t("allSkills")}</option>
{availableSkills.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
{tasksLoading ? (
<p className="text-sm text-text-muted">{t("loadingTasks")}</p>
) : tasksData.tasks.length === 0 ? (
<p className="text-sm text-text-muted">{t("noTasksForFilters")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<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("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>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-end gap-2 mt-4">
<Button
size="sm"
variant="secondary"
disabled={offset === 0}
onClick={() => setOffset((current) => Math.max(0, current - PAGE_SIZE))}
>
{t("previous")}
</Button>
<Button
size="sm"
variant="secondary"
disabled={offset + PAGE_SIZE >= tasksData.total}
onClick={() =>
setOffset((current) =>
current + PAGE_SIZE < tasksData.total ? current + PAGE_SIZE : current
)
}
>
{t("next")}
</Button>
</div>
</Card>
{selectedTask && (
<Card className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
<Button size="sm" variant="secondary" onClick={() => setSelectedTask(null)}>
{t("close")}
</Button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs uppercase text-text-muted mb-2">{t("metadata")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(
{
id: selectedTask.id,
skill: selectedTask.skill,
state: selectedTask.state,
createdAt: selectedTask.createdAt,
updatedAt: selectedTask.updatedAt,
expiresAt: selectedTask.expiresAt,
metadata: selectedTask.metadata,
},
null,
2
)}
</code>
</div>
<div className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs uppercase text-text-muted mb-2">{t("events")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(selectedTask.events, null, 2)}
</code>
</div>
</div>
<div className="rounded-lg border border-border p-3 bg-bg mt-4">
<p className="text-xs uppercase text-text-muted mb-2">{t("artifacts")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(selectedTask.artifacts, null, 2)}
</code>
</div>
</Card>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-border bg-bg p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
<p className="text-xl font-semibold mt-1">{value}</p>
</div>
);
}

View File

@@ -1,15 +1,17 @@
"use client";
import { Skeleton } from "@/shared/components/Loading";
export default function AnalyticsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40" />
<div className="space-y-6 p-6" role="status" aria-live="polite" aria-busy="true">
<Skeleton className="h-8 w-40" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} className="h-24 rounded-lg" />
))}
</div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</div>
);
}

View File

@@ -57,6 +57,7 @@ interface ApiKey {
name: string;
key: string;
allowedModels: string[] | null;
noLog?: boolean;
createdAt: string;
}
@@ -226,7 +227,7 @@ export default function ApiManagerPageClient() {
setShowPermissionsModal(true);
};
const handleUpdatePermissions = async (allowedModels: string[]) => {
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
if (!editingKey || !editingKey.id) return;
// Validate models array
@@ -253,7 +254,7 @@ export default function ApiManagerPageClient() {
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowedModels: validModels }),
body: JSON.stringify({ allowedModels: validModels, noLog }),
});
if (res.ok) {
@@ -448,6 +449,7 @@ export default function ApiManagerPageClient() {
{keys.map((key) => {
const stats = usageStats[key.id];
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const noLogEnabled = key.noLog === true;
return (
<div
key={key.id}
@@ -476,23 +478,33 @@ export default function ApiManagerPageClient() {
</button>
</div>
<div className="col-span-2 flex items-center">
{isRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{t("modelsCount", { count: key.allowedModels.length })}
</button>
) : (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock_open</span>
{t("allModels")}
</button>
)}
<div className="flex flex-col items-start gap-1">
{isRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{t("modelsCount", { count: key.allowedModels.length })}
</button>
) : (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock_open</span>
{t("allModels")}
</button>
)}
{noLogEnabled && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
visibility_off
</span>
No-Log
</span>
)}
</div>
</div>
<div className="col-span-2 flex flex-col justify-center">
<span className="text-sm font-medium tabular-nums">
@@ -675,7 +687,7 @@ const PermissionsModal = memo(function PermissionsModal({
allModels: Model[];
searchModel: string;
onSearchChange: (v: string) => void;
onSave: (models: string[]) => void;
onSave: (models: string[], noLog: boolean) => void;
}) {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
@@ -684,6 +696,7 @@ const PermissionsModal = memo(function PermissionsModal({
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
// Expand all providers by default when in restrict mode with existing selections
if (initialModels.length > 0) {
@@ -757,12 +770,8 @@ const PermissionsModal = memo(function PermissionsModal({
}, []);
const handleSave = useCallback(() => {
onSave(allowAll ? [] : selectedModels);
}, [onSave, allowAll, selectedModels]);
const handleClearSearch = useCallback(() => {
onSearchChange("");
}, [onSearchChange]);
onSave(allowAll ? [] : selectedModels, noLogEnabled);
}, [onSave, allowAll, selectedModels, noLogEnabled]);
const selectedCount = selectedModels.length;
const totalModels = allModels.length;
@@ -824,6 +833,32 @@ const PermissionsModal = memo(function PermissionsModal({
</p>
</div>
{/* Privacy Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">No-Log Payload Privacy</p>
<p className="text-xs text-text-muted">
Disable request/response payload persistence for this API key.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={noLogEnabled}
onClick={() => setNoLogEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
noLogEnabled
? "bg-violet-500/15 text-violet-700 dark:text-violet-300 border border-violet-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{noLogEnabled ? "visibility_off" : "visibility"}
</span>
{noLogEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Selected Models Summary (only in restrict mode) */}
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">

View File

@@ -0,0 +1,253 @@
/**
* Dashboard Auto-Combo Panel — /dashboard/auto-combo
*
* Shows provider scores, scoring factors, exclusions, mode packs, and routing history.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
interface ProviderScore {
provider: string;
model: string;
score: number;
factors: Record<string, number>;
}
interface ExclusionEntry {
provider: string;
excludedAt: string;
cooldownMs: number;
reason: string;
}
type AutoComboRecord = {
candidatePool?: unknown;
weights?: unknown;
};
type HealthRecord = {
providerHealth?: Record<string, { state?: string; lastFailure?: string | null }>;
circuitBreakers?: Array<{
provider?: string;
name?: string;
state?: string;
lastFailure?: string | null;
}>;
};
export default function AutoComboDashboard() {
const [scores, setScores] = useState<ProviderScore[]>([]);
const [exclusions, setExclusions] = useState<ExclusionEntry[]>([]);
const [incidentMode, setIncidentMode] = useState(false);
const [modePack, setModePack] = useState("ship-fast");
const fetchData = useCallback(async () => {
try {
const [combosRes, healthRes] = await Promise.allSettled([
fetch("/api/combos/auto"),
fetch("/api/monitoring/health"),
]);
if (combosRes.status === "fulfilled") {
const comboPayload = await combosRes.value.json();
const combos = Array.isArray(comboPayload?.combos)
? (comboPayload.combos as AutoComboRecord[])
: [];
const firstCombo = combos[0] || null;
const candidatePool = Array.isArray(firstCombo?.candidatePool)
? firstCombo.candidatePool.filter((entry): entry is string => typeof entry === "string")
: [];
const rawWeights =
firstCombo?.weights &&
typeof firstCombo.weights === "object" &&
!Array.isArray(firstCombo.weights)
? (firstCombo.weights as Record<string, unknown>)
: {};
const factors = Object.fromEntries(
Object.entries(rawWeights).map(([k, v]) => [k, typeof v === "number" ? v : 0])
);
const baseScore = candidatePool.length > 0 ? 1 / candidatePool.length : 0;
setScores(
candidatePool.map((provider) => ({
provider,
model: "auto",
score: baseScore,
factors,
}))
);
} else {
setScores([]);
}
if (healthRes.status === "fulfilled") {
const health = (await healthRes.value.json()) as HealthRecord;
const providerHealth =
health?.providerHealth && typeof health.providerHealth === "object"
? health.providerHealth
: {};
const breakersFromProviderHealth = Object.entries(providerHealth).map(
([provider, status]) => ({
provider,
state: status?.state || "CLOSED",
lastFailure: status?.lastFailure || null,
})
);
const breakersFromArray = Array.isArray(health?.circuitBreakers)
? health.circuitBreakers
: [];
const breakers =
breakersFromArray.length > 0
? breakersFromArray.map((breaker) => ({
provider: breaker.provider || breaker.name || "unknown",
state: breaker.state || "CLOSED",
lastFailure: breaker.lastFailure || null,
}))
: breakersFromProviderHealth;
const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN");
setIncidentMode(openBreakers.length / Math.max(breakers.length, 1) > 0.5);
setExclusions(
openBreakers.map((breaker) => ({
provider: breaker.provider,
excludedAt: breaker.lastFailure || new Date().toISOString(),
cooldownMs: 5 * 60 * 1000,
reason: "Circuit breaker OPEN",
}))
);
} else {
setIncidentMode(false);
setExclusions([]);
}
} catch {
/* ignore */
}
}, []);
useEffect(() => {
const id = setTimeout(fetchData, 0);
const interval = setInterval(fetchData, 30_000);
return () => {
clearTimeout(id);
clearInterval(interval);
};
}, [fetchData]);
const FACTOR_LABELS: Record<string, string> = {
quota: "📊 Quota",
health: "💚 Health",
costInv: "💰 Cost",
latencyInv: "⚡ Latency",
taskFit: "🎯 Task Fit",
stability: "📈 Stability",
};
const MODE_PACKS = [
{ id: "ship-fast", label: "🚀 Ship Fast" },
{ id: "cost-saver", label: "💰 Cost Saver" },
{ id: "quality-first", label: "🎯 Quality First" },
{ id: "offline-friendly", label: "📡 Offline Friendly" },
];
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-2xl font-bold mb-6"> Auto-Combo Engine</h1>
{/* Status Bar */}
<div className="flex gap-4 mb-6">
<div
className={`px-3 py-2 rounded-lg text-sm font-medium ${incidentMode ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" : "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"}`}
>
{incidentMode ? "🚨 INCIDENT MODE" : "✅ Normal"}
</div>
<div className="px-3 py-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg text-sm">
Mode: <strong>{MODE_PACKS.find((m) => m.id === modePack)?.label || modePack}</strong>
</div>
</div>
{/* Mode Pack Selector */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">🎛 Mode Pack</h2>
<div className="flex gap-2">
{MODE_PACKS.map((mp) => (
<button
key={mp.id}
onClick={() => setModePack(mp.id)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
modePack === mp.id
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{mp.label}
</button>
))}
</div>
</div>
{/* Provider Scores */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">📊 Provider Scores</h2>
{scores.length === 0 ? (
<p className="text-gray-500">
No auto-combo configured. Create one via <code>POST /api/combos/auto</code>.
</p>
) : (
<div className="space-y-3">
{scores.map((s) => (
<div key={s.provider} className="p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">
{s.provider} / {s.model}
</span>
<span className="font-bold text-lg">{(s.score * 100).toFixed(0)}%</span>
</div>
{/* Score Bar */}
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded overflow-hidden mb-2">
<div
className="h-full bg-blue-500 rounded"
style={{ width: `${s.score * 100}%` }}
/>
</div>
{/* Factor Breakdown */}
<div className="grid grid-cols-3 gap-1 text-xs text-gray-500">
{Object.entries(s.factors || {}).map(([key, val]) => (
<span key={key}>
{FACTOR_LABELS[key] || key}: {((val as number) * 100).toFixed(0)}%
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Exclusions */}
<div>
<h2 className="text-lg font-semibold mb-3">🚫 Excluded Providers</h2>
{exclusions.length === 0 ? (
<p className="text-gray-500">No providers currently excluded.</p>
) : (
<div className="space-y-2">
{exclusions.map((e) => (
<div
key={e.provider}
className="p-3 bg-red-50 dark:bg-red-900/10 rounded border border-red-200 dark:border-red-800"
>
<div className="flex justify-between">
<span className="font-medium text-red-700 dark:text-red-400">{e.provider}</span>
<span className="text-xs text-gray-500">
Cooldown: {Math.round(e.cooldownMs / 60000)}min
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{e.reason}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ export default function DefaultToolCard({
}) {
const t = useTranslations("cliTools");
const translateOrFallback = useCallback(
(key, fallback, values) => {
(key, fallback, values = undefined) => {
try {
return t(key, values);
} catch {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
"use client";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function ApiEndpointsTab() {
const t = useTranslations("endpoints");
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<Card className="p-8 text-center space-y-4">
<div className="flex items-center justify-center size-16 rounded-2xl bg-primary/10 text-primary mx-auto">
<span className="material-symbols-outlined text-[32px]">code</span>
</div>
<h2 className="text-xl font-semibold">{t("apiEndpointsTitle")}</h2>
<p className="text-sm text-text-muted max-w-md mx-auto">{t("apiEndpointsDescription")}</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-500/10 text-amber-500 text-sm font-medium">
<span className="material-symbols-outlined text-[18px]">construction</span>
{t("comingSoon")}
</div>
</Card>
<Card className="p-5">
<h3 className="text-sm font-semibold mb-3">{t("plannedFeatures")}</h3>
<ul className="space-y-2 text-sm text-text-muted">
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureRestApi")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureWebhooks")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureSwagger")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureAuth")}
</li>
</ul>
</Card>
</div>
);
}

View File

@@ -2,7 +2,8 @@
import { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
@@ -13,7 +14,6 @@ const CLOUD_ACTION_TIMEOUT_MS = 15000;
export default function APIPageClient({ machineId }) {
const t = useTranslations("endpoint");
const tc = useTranslations("common");
const [providerConnections, setProviderConnections] = useState([]);
const [loading, setLoading] = useState(true);
// Endpoints / models state
@@ -30,13 +30,16 @@ export default function APIPageClient({ machineId }) {
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
const [cloudBaseUrl, setCloudBaseUrl] = useState(CLOUD_URL); // dynamic cloud URL from API response
const [viewTab, setViewTab] = useState("api");
const [mcpStatus, setMcpStatus] = useState<any>(null);
const [a2aStatus, setA2aStatus] = useState<any>(null);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
loadCloudSettings();
fetchModels();
Promise.allSettled([loadCloudSettings(), fetchModels(), fetchProtocolStatus()]).finally(() => {
setLoading(false);
});
}, []);
const fetchModels = async () => {
@@ -51,6 +54,24 @@ export default function APIPageClient({ machineId }) {
}
};
const fetchProtocolStatus = async () => {
try {
const [mcpRes, a2aRes] = await Promise.allSettled([
fetch("/api/mcp/status"),
fetch("/api/a2a/status"),
]);
if (mcpRes.status === "fulfilled" && mcpRes.value.ok) {
setMcpStatus(await mcpRes.value.json());
}
if (a2aRes.status === "fulfilled" && a2aRes.value.ok) {
setA2aStatus(await a2aRes.value.json());
}
} catch {
// Ignore status failures; protocols panel has fallback text.
}
};
// Categorize models by endpoint type
// Filter out parent models (models with parent field set) to avoid showing duplicates
const endpointData = useMemo(() => {
@@ -68,34 +89,6 @@ export default function APIPageClient({ machineId }) {
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
}, [allModels]);
const providerStats = useMemo(() => {
return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => {
const connections = providerConnections.filter((conn) => conn.provider === providerId);
const connected = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "active" ||
conn.testStatus === "success" ||
conn.testStatus === "unknown")
).length;
const errors = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "error" ||
conn.testStatus === "expired" ||
conn.testStatus === "unavailable")
).length;
return {
id: providerId,
provider: providerInfo,
total: connections.length,
connected,
errors,
};
});
}, [providerConnections]);
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -130,22 +123,6 @@ export default function APIPageClient({ machineId }) {
}
};
const fetchData = async () => {
try {
const providersRes = await fetch("/api/providers");
const providersData = await providersRes.json();
if (providersRes.ok) {
setProviderConnections(providersData.connections || []);
}
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleCloudToggle = (checked) => {
if (checked) {
setShowCloudModal(true);
@@ -162,6 +139,11 @@ export default function APIPageClient({ machineId }) {
}
}, [cloudStatus]);
useEffect(() => {
const interval = setInterval(fetchProtocolStatus, 30000);
return () => clearInterval(interval);
}, []);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));
};
@@ -201,10 +183,6 @@ export default function APIPageClient({ machineId }) {
});
}
// Refresh keys list if new key was created
if (data.createdKey) {
await fetchData();
}
// Update cloud URL from API response (fixes undefined/v1 when env var not set)
if (data.cloudUrl) {
setCloudBaseUrl(data.cloudUrl);
@@ -260,24 +238,6 @@ export default function APIPageClient({ machineId }) {
}
};
const handleSyncCloud = async () => {
if (!cloudEnabled) return;
setCloudSyncing(true);
try {
const { ok, data } = await postCloudAction("sync");
if (ok) {
setCloudStatus({ type: "success", message: t("syncedSuccess") });
} else {
setCloudStatus({ type: "error", message: data.error || t("syncFailed") });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message || t("syncFailed") });
} finally {
setCloudSyncing(false);
}
};
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = cloudBaseUrl ? `${cloudBaseUrl}/v1` : null;
@@ -299,6 +259,10 @@ export default function APIPageClient({ machineId }) {
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled && cloudEndpointNew ? cloudEndpointNew : baseUrl;
const mcpOnline = Boolean(mcpStatus?.online);
const a2aOnline = a2aStatus?.status === "ok";
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
return (
<div className="flex flex-col gap-8">
@@ -387,219 +351,408 @@ export default function APIPageClient({ machineId }) {
</div>
</Card>
{/* Available Endpoints */}
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<h2 className="text-lg font-semibold">{t("sectionTitle") || "Integration Surface"}</h2>
<p className="text-sm text-text-muted">
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce((acc, models) => acc + models.length, 0),
endpoints: [
endpointData.chat,
endpointData.embeddings,
endpointData.images,
endpointData.rerank,
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length + 2,
})}
{t("sectionDescription") ||
"OpenAI-compatible APIs and operational protocol endpoints"}
</p>
</div>
</div>
{/* Core APIs */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore") || "Core APIs"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Chat Completions */}
<EndpointSection
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title={t("chatCompletions")}
path="/v1/chat/completions"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Responses API */}
<EndpointSection
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses") || "Responses API"}
path="/v1/responses"
description={t("responsesDesc") || "OpenAI Responses API for Codex and advanced agentic workflows"}
models={endpointData.chat}
expanded={expandedEndpoint === "responses"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "responses" ? null : "responses")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Media & Multi-Modal */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia") || "Media & Multi-Modal"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Embeddings */}
<EndpointSection
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title={t("embeddings")}
path="/v1/embeddings"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Image Generation */}
<EndpointSection
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title={t("imageGeneration")}
path="/v1/images/generations"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Transcription */}
<EndpointSection
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
setExpandedEndpoint(
expandedEndpoint === "audioTranscription" ? null : "audioTranscription"
)
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Speech (TTS) */}
<EndpointSection
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("textToSpeech")}
path="/v1/audio/speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Utility & Management */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility") || "Utility & Management"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Rerank */}
<EndpointSection
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title={t("rerank")}
path="/v1/rerank"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Moderations */}
<EndpointSection
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("moderations")}
path="/v1/moderations"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* List Models */}
<EndpointSection
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels") || "List Models"}
path="/v1/models"
description={t("listModelsDesc") || "List all available models across all connected providers"}
models={[]}
expanded={expandedEndpoint === "models"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "models" ? null : "models")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
<SegmentedControl
options={[
{ value: "api", label: t("tabApis") || "OpenAI-compatible APIs", icon: "api" },
{ value: "protocols", label: t("tabProtocols") || "Protocols", icon: "hub" },
]}
value={viewTab}
onChange={setViewTab}
aria-label={t("tabsAria") || "Endpoint sections"}
/>
</div>
</Card>
{viewTab === "api" ? (
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<p className="text-sm text-text-muted">
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce(
(acc, models) => acc + models.length,
0
),
endpoints:
[
endpointData.chat,
endpointData.embeddings,
endpointData.images,
endpointData.rerank,
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length + 2,
})}
</p>
</div>
</div>
{/* Core APIs */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore") || "Core APIs"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Chat Completions */}
<EndpointSection
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title={t("chatCompletions")}
path="/v1/chat/completions"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Responses API */}
<EndpointSection
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses") || "Responses API"}
path="/v1/responses"
description={
t("responsesDesc") ||
"OpenAI Responses API for Codex and advanced agentic workflows"
}
models={endpointData.chat}
expanded={expandedEndpoint === "responses"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "responses" ? null : "responses")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Media & Multi-Modal */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia") || "Media & Multi-Modal"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Embeddings */}
<EndpointSection
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title={t("embeddings")}
path="/v1/embeddings"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Image Generation */}
<EndpointSection
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title={t("imageGeneration")}
path="/v1/images/generations"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Transcription */}
<EndpointSection
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
setExpandedEndpoint(
expandedEndpoint === "audioTranscription" ? null : "audioTranscription"
)
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Speech (TTS) */}
<EndpointSection
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("textToSpeech")}
path="/v1/audio/speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Utility & Management */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility") || "Utility & Management"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Rerank */}
<EndpointSection
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title={t("rerank")}
path="/v1/rerank"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Moderations */}
<EndpointSection
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("moderations")}
path="/v1/moderations"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* List Models */}
<EndpointSection
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels") || "List Models"}
path="/v1/models"
description={
t("listModelsDesc") || "List all available models across all connected providers"
}
models={[]}
expanded={expandedEndpoint === "models"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "models" ? null : "models")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
</Card>
) : (
<Card>
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold">{t("protocolsTitle") || "Protocols"}</h2>
<p className="text-sm text-text-muted mt-1">
{t("protocolsDescription") ||
"MCP and A2A are first-class endpoints with dedicated observability and controls."}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">
hub
</span>
{t("mcpCardTitle") || "MCP Server"}
</h3>
<p className="text-xs text-text-muted mt-1">
{t("mcpCardDescription") || "Model Context Protocol over stdio"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-semibold ${
mcpOnline ? "bg-green-500/15 text-green-500" : "bg-red-500/15 text-red-500"
}`}
>
{mcpOnline ? tc("active") : tc("inactive")}
</span>
</div>
<div className="mt-3 text-xs text-text-muted space-y-1">
<p>
{t("protocolToolsLabel") || "Tools"}:{" "}
<span className="text-text-main font-semibold">{mcpToolCount || 16}</span>
</p>
<p>
{t("protocolLastActivity") || "Last activity"}:{" "}
<span className="text-text-main">
{mcpStatus?.activity?.lastCallAt
? new Date(mcpStatus.activity.lastCallAt).toLocaleString()
: "—"}
</span>
</p>
</div>
<div className="mt-3 rounded-lg bg-bg p-3 border border-border/70">
<p className="text-xs font-semibold mb-1">{t("quickStart") || "Quick Start"}</p>
<code className="text-xs font-mono break-all">omniroute --mcp</code>
</div>
<div className="mt-3">
<Link
href="/dashboard/mcp"
className="text-sm text-primary hover:text-primary-hover transition-colors"
>
{t("openMcpDashboard") || "Open MCP management"}
</Link>
</div>
</div>
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">
group_work
</span>
{t("a2aCardTitle") || "A2A Server"}
</h3>
<p className="text-xs text-text-muted mt-1">
{t("a2aCardDescription") || "Agent2Agent JSON-RPC endpoint"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-semibold ${
a2aOnline ? "bg-green-500/15 text-green-500" : "bg-red-500/15 text-red-500"
}`}
>
{a2aOnline ? tc("active") : tc("inactive")}
</span>
</div>
<div className="mt-3 text-xs text-text-muted space-y-1">
<p>
{t("protocolTasksLabel") || "Tasks"}:{" "}
<span className="text-text-main font-semibold">
{a2aStatus?.tasks?.total || 0}
</span>
</p>
<p>
{t("protocolActiveStreamsLabel") || "Active streams"}:{" "}
<span className="text-text-main font-semibold">{a2aActiveStreams}</span>
</p>
</div>
<div className="mt-3 rounded-lg bg-bg p-3 border border-border/70">
<p className="text-xs font-semibold mb-1">{t("quickStart") || "Quick Start"}</p>
<code className="text-xs font-mono break-all">
{baseUrl.replace(/\/v1$/, "")}/a2a
</code>
</div>
<div className="mt-3">
<Link
href="/dashboard/a2a"
className="text-sm text-primary hover:text-primary-hover transition-colors"
>
{t("openA2aDashboard") || "Open A2A management"}
</Link>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<h4 className="font-semibold mb-2">
{t("mcpQuickStartTitle") || "MCP Quick Start"}
</h4>
<ol className="text-sm text-text-muted space-y-1 list-decimal list-inside">
<li>{t("mcpQuickStartStep1") || "Run the MCP server via `omniroute --mcp`."}</li>
<li>
{t("mcpQuickStartStep2") ||
"Configure your MCP client to connect over stdio transport."}
</li>
<li>
{t("mcpQuickStartStep3") ||
"Invoke tools such as `omniroute_get_health` and `omniroute_list_combos`."}
</li>
</ol>
</div>
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<h4 className="font-semibold mb-2">
{t("a2aQuickStartTitle") || "A2A Quick Start"}
</h4>
<ol className="text-sm text-text-muted space-y-1 list-decimal list-inside">
<li>
{t("a2aQuickStartStep1") ||
"Discover the agent card at `/.well-known/agent.json`."}
</li>
<li>
{t("a2aQuickStartStep2") ||
"Send JSON-RPC requests to `POST /a2a` using `message/send` or `message/stream`."}
</li>
<li>
{t("a2aQuickStartStep3") ||
"Track and control tasks using `tasks/get` and `tasks/cancel`."}
</li>
</ol>
</div>
</div>
</div>
</Card>
)}
{/* Cloud Enable Modal */}
<Modal
isOpen={showCloudModal}

View File

@@ -1,7 +1,387 @@
import { getMachineId } from "@/shared/utils/machine";
import EndpointPageClient from "./EndpointPageClient";
"use client";
export default async function EndpointPage() {
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
import { useState, useEffect, useCallback } from "react";
import { SegmentedControl } from "@/shared/components";
import EndpointPageClient from "./EndpointPageClient";
import McpDashboardPage from "../mcp/page";
import A2ADashboardPage from "../a2a/page";
import ApiEndpointsTab from "./ApiEndpointsTab";
import { useTranslations } from "next-intl";
type ServiceStatus = {
online: boolean;
loading: boolean;
};
type McpTransport = "stdio" | "sse" | "streamable-http";
/* ────── Toggle Switch ────── */
function ServiceToggle({
label,
status,
enabled,
onToggle,
toggling,
}: {
label: string;
status: ServiceStatus;
enabled: boolean;
onToggle: () => void;
toggling: boolean;
}) {
return (
<div className="flex items-center gap-3 ml-auto">
<div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
style={{
borderColor: status.loading
? "var(--color-border)"
: status.online
? "rgba(34,197,94,0.3)"
: "rgba(239,68,68,0.3)",
background: status.loading
? "transparent"
: status.online
? "rgba(34,197,94,0.1)"
: "rgba(239,68,68,0.1)",
color: status.loading
? "var(--color-text-muted)"
: status.online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
}}
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background: status.loading
? "var(--color-text-muted)"
: status.online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
animation: status.online ? "pulse 2s infinite" : "none",
}}
/>
{status.loading ? "..." : status.online ? "Online" : "Offline"}
</div>
<button
onClick={onToggle}
disabled={toggling}
className="relative inline-flex items-center h-7 w-[52px] rounded-full transition-all duration-300 focus:outline-none border"
style={{
background: enabled ? "rgb(34,197,94)" : "var(--color-bg-tertiary)",
borderColor: enabled ? "rgba(34,197,94,0.5)" : "var(--color-border)",
opacity: toggling ? 0.6 : 1,
cursor: toggling ? "wait" : "pointer",
}}
title={enabled ? `Disable ${label}` : `Enable ${label}`}
>
<span
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
style={{
transform: enabled ? "translateX(26px)" : "translateX(3px)",
background: enabled ? "#fff" : "var(--color-text-muted)",
}}
/>
</button>
<span
className="text-xs font-medium min-w-[24px]"
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
>
{toggling ? "..." : enabled ? "ON" : "OFF"}
</span>
</div>
);
}
/* ────── Transport Selector ────── */
function TransportSelector({
value,
onChange,
disabled,
baseUrl,
}: {
value: McpTransport;
onChange: (t: McpTransport) => void;
disabled: boolean;
baseUrl: string;
}) {
const options: { value: McpTransport; label: string; desc: string }[] = [
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
{ value: "streamable-http", label: "Streamable HTTP", desc: "Remote — Modern bidirectional HTTP" },
];
const urlMap: Record<McpTransport, string> = {
stdio: "omniroute --mcp",
sse: `${baseUrl}/api/mcp/sse`,
"streamable-http": `${baseUrl}/api/mcp/stream`,
};
return (
<div
className="rounded-lg border p-4 mt-3"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg-secondary)" }}
>
<div className="flex items-center gap-2 mb-3">
<span
className="material-symbols-rounded text-base"
style={{ color: "var(--color-primary)" }}
>
swap_horiz
</span>
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
Transport Mode
</span>
</div>
<div className="flex gap-2 flex-wrap">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
disabled={disabled}
className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left"
style={{
borderColor:
value === opt.value ? "var(--color-primary)" : "var(--color-border)",
background:
value === opt.value
? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)"
: "transparent",
opacity: disabled ? 0.5 : 1,
cursor: disabled ? "wait" : "pointer",
}}
>
<span
className="text-sm font-semibold"
style={{
color: value === opt.value ? "var(--color-primary)" : "var(--color-text)",
}}
>
{opt.label}
</span>
<span
className="text-xs mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{opt.desc}
</span>
</button>
))}
</div>
{/* Connection info */}
<div
className="mt-3 rounded-md px-3 py-2 flex items-center gap-2"
style={{ background: "var(--color-bg-tertiary)" }}
>
<span
className="material-symbols-rounded text-sm"
style={{ color: "var(--color-text-muted)" }}
>
{value === "stdio" ? "terminal" : "link"}
</span>
<code
className="text-xs break-all"
style={{ color: "var(--color-text-muted)" }}
>
{urlMap[value]}
</code>
{value !== "stdio" && (
<button
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-muted)",
}}
onClick={() => void navigator.clipboard.writeText(urlMap[value])}
title="Copy URL"
>
Copy
</button>
)}
</div>
</div>
);
}
/* ────── Main Page ────── */
export default function EndpointPage() {
const [activeTab, setActiveTab] = useState("endpoint-proxy");
const t = useTranslations("endpoints");
const [mcpStatus, setMcpStatus] = useState<ServiceStatus>({ online: false, loading: true });
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ online: false, loading: true });
const [mcpEnabled, setMcpEnabled] = useState(false);
const [a2aEnabled, setA2aEnabled] = useState(false);
const [mcpToggling, setMcpToggling] = useState(false);
const [a2aToggling, setA2aToggling] = useState(false);
const [mcpTransport, setMcpTransport] = useState<McpTransport>("stdio");
const [transportSaving, setTransportSaving] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
// Detect base URL from browser
useEffect(() => {
if (typeof window !== "undefined") {
setBaseUrl(`${window.location.protocol}//${window.location.host}`);
}
}, []);
// Fetch initial settings
useEffect(() => {
const fetchSettings = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setMcpEnabled(!!data.mcpEnabled);
setA2aEnabled(!!data.a2aEnabled);
setMcpTransport((data.mcpTransport as McpTransport) || "stdio");
}
} catch {
// defaults stay
}
};
void fetchSettings();
}, []);
const patchSetting = useCallback(async (body: Record<string, unknown>) => {
return fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}, []);
const toggleService = useCallback(
async (service: "mcp" | "a2a") => {
const setToggling = service === "mcp" ? setMcpToggling : setA2aToggling;
const setEnabled = service === "mcp" ? setMcpEnabled : setA2aEnabled;
const currentlyEnabled = service === "mcp" ? mcpEnabled : a2aEnabled;
const newValue = !currentlyEnabled;
setToggling(true);
try {
const res = await patchSetting({
[service === "mcp" ? "mcpEnabled" : "a2aEnabled"]: newValue,
});
if (res.ok) setEnabled(newValue);
} catch {
// keep current state
} finally {
setToggling(false);
}
},
[mcpEnabled, a2aEnabled, patchSetting],
);
const changeTransport = useCallback(
async (newTransport: McpTransport) => {
setTransportSaving(true);
try {
const res = await patchSetting({ mcpTransport: newTransport });
if (res.ok) setMcpTransport(newTransport);
} catch {
// keep current
} finally {
setTransportSaving(false);
}
},
[patchSetting],
);
const refreshMcpStatus = useCallback(async () => {
setMcpStatus((prev) => ({ ...prev, loading: true }));
try {
const res = await fetch("/api/mcp/status");
if (res.ok) {
const data = await res.json();
setMcpStatus({ online: !!data.online, loading: false });
} else {
setMcpStatus({ online: false, loading: false });
}
} catch {
setMcpStatus({ online: false, loading: false });
}
}, []);
const refreshA2aStatus = useCallback(async () => {
setA2aStatus((prev) => ({ ...prev, loading: true }));
try {
const res = await fetch("/api/a2a/status");
if (res.ok) {
const data = await res.json();
setA2aStatus({ online: data.status === "ok", loading: false });
} else {
setA2aStatus({ online: false, loading: false });
}
} catch {
setA2aStatus({ online: false, loading: false });
}
}, []);
useEffect(() => {
const load = () => {
void refreshMcpStatus();
void refreshA2aStatus();
};
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, [refreshMcpStatus, refreshA2aStatus]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
options={[
{ value: "endpoint-proxy", label: t("tabProxy"), icon: "api" },
{ value: "mcp", label: "MCP", icon: "hub" },
{ value: "a2a", label: "A2A", icon: "group_work" },
{ value: "api-endpoints", label: t("tabApiEndpoints"), icon: "code" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{activeTab === "mcp" && (
<ServiceToggle
label="MCP"
status={mcpStatus}
enabled={mcpEnabled}
onToggle={() => void toggleService("mcp")}
toggling={mcpToggling}
/>
)}
{activeTab === "a2a" && (
<ServiceToggle
label="A2A"
status={a2aStatus}
enabled={a2aEnabled}
onToggle={() => void toggleService("a2a")}
toggling={a2aToggling}
/>
)}
</div>
{/* Transport selector for MCP */}
{activeTab === "mcp" && mcpEnabled && (
<TransportSelector
value={mcpTransport}
onChange={(t) => void changeTransport(t)}
disabled={transportSaving}
baseUrl={baseUrl}
/>
)}
{activeTab === "endpoint-proxy" && <EndpointPageClient machineId="" />}
{activeTab === "mcp" && <McpDashboardPage />}
{activeTab === "a2a" && <A2ADashboardPage />}
{activeTab === "api-endpoints" && <ApiEndpointsTab />}
</div>
);
}

View File

@@ -0,0 +1,642 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
type McpStatusResponse = {
status: "online" | "offline";
online: boolean;
heartbeatPath: string;
heartbeat: {
pid: number;
startedAt: string;
lastHeartbeatAt: string;
version: string;
transport: "stdio";
scopesEnforced: boolean;
allowedScopes: string[];
toolCount: number;
pidAlive: boolean;
heartbeatAgeMs: number | null;
uptimeMs: number | null;
} | null;
activity: {
totalCalls24h: number;
successRate: number;
avgDurationMs: number;
topTools: Array<{ tool: string; count: number }>;
lastCallAt: string | null;
lastCallTool: string | null;
};
};
type McpTool = {
name: string;
description: string;
scopes: string[];
phase: 1 | 2;
auditLevel: "none" | "basic" | "full";
sourceEndpoints: string[];
};
type McpAuditEntry = {
id: number;
toolName: string;
inputHash: string;
outputSummary: string;
durationMs: number;
apiKeyId: string | null;
success: boolean;
errorCode: string | null;
createdAt: string;
};
type McpAuditResponse = {
entries: McpAuditEntry[];
total: number;
limit: number;
offset: number;
};
type Combo = {
id: string;
name: string;
isActive?: boolean;
};
const AUDIT_PAGE_SIZE = 20;
const RESILIENCE_PRESETS = {
aggressive: {
profiles: {
oauth: {
transientCooldown: 3000,
rateLimitCooldown: 30000,
maxBackoffLevel: 4,
circuitBreakerThreshold: 2,
circuitBreakerReset: 30000,
},
apikey: {
transientCooldown: 2000,
rateLimitCooldown: 0,
maxBackoffLevel: 3,
circuitBreakerThreshold: 3,
circuitBreakerReset: 15000,
},
},
defaults: {
requestsPerMinute: 180,
minTimeBetweenRequests: 100,
concurrentRequests: 16,
},
},
balanced: {
profiles: {
oauth: {
transientCooldown: 5000,
rateLimitCooldown: 60000,
maxBackoffLevel: 8,
circuitBreakerThreshold: 3,
circuitBreakerReset: 60000,
},
apikey: {
transientCooldown: 3000,
rateLimitCooldown: 0,
maxBackoffLevel: 5,
circuitBreakerThreshold: 5,
circuitBreakerReset: 30000,
},
},
defaults: {
requestsPerMinute: 100,
minTimeBetweenRequests: 200,
concurrentRequests: 10,
},
},
conservative: {
profiles: {
oauth: {
transientCooldown: 8000,
rateLimitCooldown: 120000,
maxBackoffLevel: 10,
circuitBreakerThreshold: 8,
circuitBreakerReset: 120000,
},
apikey: {
transientCooldown: 5000,
rateLimitCooldown: 30000,
maxBackoffLevel: 8,
circuitBreakerThreshold: 8,
circuitBreakerReset: 60000,
},
},
defaults: {
requestsPerMinute: 60,
minTimeBetweenRequests: 350,
concurrentRequests: 6,
},
},
} as const;
function formatDuration(ms: number | null | undefined) {
if (typeof ms !== "number" || !Number.isFinite(ms)) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatPercent(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return "0%";
return `${(value * 100).toFixed(1)}%`;
}
export default function McpDashboardPage() {
const t = useTranslations("mcpDashboard");
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<McpStatusResponse | null>(null);
const [tools, setTools] = useState<McpTool[]>([]);
const [combos, setCombos] = useState<Combo[]>([]);
const [toolFilter, setToolFilter] = useState("");
const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all");
const [apiKeyFilter, setApiKeyFilter] = useState("");
const [auditOffset, setAuditOffset] = useState(0);
const [auditData, setAuditData] = useState<McpAuditResponse>({
entries: [],
total: 0,
limit: AUDIT_PAGE_SIZE,
offset: 0,
});
const [auditLoading, setAuditLoading] = useState(false);
const [selectedComboId, setSelectedComboId] = useState("");
const [selectedProfile, setSelectedProfile] =
useState<keyof typeof RESILIENCE_PRESETS>("balanced");
const [actionBusy, setActionBusy] = useState<null | "switch" | "resilience" | "reset">(null);
const [actionMessage, setActionMessage] = useState<string>("");
const selectedCombo = useMemo(
() => combos.find((combo) => combo.id === selectedComboId) || null,
[combos, selectedComboId]
);
const refreshSummary = useCallback(async () => {
try {
const [statusRes, toolsRes, combosRes] = await Promise.all([
fetch("/api/mcp/status"),
fetch("/api/mcp/tools"),
fetch("/api/combos"),
]);
if (statusRes.ok) {
const json = await statusRes.json();
setStatus(json);
}
if (toolsRes.ok) {
const json = await toolsRes.json();
setTools(Array.isArray(json.tools) ? json.tools : []);
}
if (combosRes.ok) {
const json = await combosRes.json();
const nextCombos = Array.isArray(json?.combos) ? json.combos : [];
setCombos(nextCombos);
if (!selectedComboId && nextCombos.length > 0) {
setSelectedComboId(nextCombos[0].id);
}
}
} finally {
setLoading(false);
}
}, [selectedComboId]);
const refreshAudit = useCallback(async () => {
setAuditLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(AUDIT_PAGE_SIZE));
params.set("offset", String(auditOffset));
if (toolFilter) params.set("tool", toolFilter);
if (successFilter !== "all") params.set("success", successFilter);
if (apiKeyFilter) params.set("apiKeyId", apiKeyFilter);
const response = await fetch(`/api/mcp/audit?${params.toString()}`);
if (!response.ok) return;
const json = await response.json();
setAuditData({
entries: Array.isArray(json.entries) ? json.entries : [],
total: Number(json.total || 0),
limit: Number(json.limit || AUDIT_PAGE_SIZE),
offset: Number(json.offset || 0),
});
} finally {
setAuditLoading(false);
}
}, [auditOffset, toolFilter, successFilter, apiKeyFilter]);
useEffect(() => {
refreshSummary();
const interval = setInterval(refreshSummary, 30000);
return () => clearInterval(interval);
}, [refreshSummary]);
useEffect(() => {
refreshAudit();
}, [refreshAudit]);
const handleSwitchCombo = async () => {
if (!selectedCombo) return;
const nextState = selectedCombo.isActive === false;
const confirmLabel = nextState ? t("activate") : t("deactivate");
if (
!globalThis.confirm(
t("confirmSwitchCombo", { action: confirmLabel, combo: selectedCombo.name })
)
)
return;
setActionBusy("switch");
setActionMessage("");
try {
const response = await fetch(`/api/combos/${selectedCombo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: nextState }),
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("switchComboFailed"));
return;
}
setActionMessage(t("switchComboSuccess", { combo: selectedCombo.name }));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const handleApplyResilience = async () => {
const preset = RESILIENCE_PRESETS[selectedProfile];
const profileLabelById: Record<keyof typeof RESILIENCE_PRESETS, string> = {
aggressive: t("profileAggressive"),
balanced: t("profileBalanced"),
conservative: t("profileConservative"),
};
const profileLabel = profileLabelById[selectedProfile];
if (!globalThis.confirm(t("confirmApplyProfile", { profile: profileLabel }))) return;
setActionBusy("resilience");
setActionMessage("");
try {
const response = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(preset),
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("applyProfileFailed"));
return;
}
setActionMessage(t("applyProfileSuccess", { profile: profileLabel }));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const handleResetCircuitBreakers = async () => {
if (!globalThis.confirm(t("confirmResetBreakers"))) return;
setActionBusy("reset");
setActionMessage("");
try {
const response = await fetch("/api/monitoring/health", { method: "DELETE" });
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("resetBreakersFailed"));
return;
}
const json = await response.json().catch(() => ({}));
setActionMessage(json?.message || t("resetBreakersSuccess"));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const totalPages = Math.max(1, Math.ceil((auditData.total || 0) / AUDIT_PAGE_SIZE));
const currentPage = Math.floor((auditData.offset || 0) / AUDIT_PAGE_SIZE) + 1;
const topTools = status?.activity?.topTools || [];
if (loading) {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="text-sm text-text-muted">{t("loading")}</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard label={t("processStatus")} value={status?.online ? t("online") : t("offline")} />
<StatCard label={t("pid")} value={status?.heartbeat?.pid ?? "—"} />
<StatCard
label={t("sessionUptime")}
value={formatDuration(status?.heartbeat?.uptimeMs ?? null)}
/>
<StatCard
label={t("lastHeartbeat")}
value={formatDuration(status?.heartbeat?.heartbeatAgeMs ?? null)}
/>
</div>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("activity24h")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<StatCard label={t("totalCalls")} value={status?.activity?.totalCalls24h ?? 0} compact />
<StatCard
label={t("successRate")}
value={formatPercent(status?.activity?.successRate)}
compact
/>
<StatCard
label={t("avgLatency")}
value={formatDuration(status?.activity?.avgDurationMs ?? null)}
compact
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-3">
<h3 className="text-sm font-semibold mb-2">{t("topTools")}</h3>
{topTools.length === 0 ? (
<p className="text-sm text-text-muted">{t("noToolCalls24h")}</p>
) : (
<ul className="space-y-1 text-sm">
{topTools.map((entry) => (
<li key={entry.tool} className="flex justify-between">
<span className="font-mono text-xs">{entry.tool}</span>
<span>{entry.count}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-border p-3">
<h3 className="text-sm font-semibold mb-2">{t("runtimeDetails")}</h3>
<div className="text-sm space-y-1">
<p>
{t("transport")}:{" "}
<span className="font-mono">{status?.heartbeat?.transport || "—"}</span>
</p>
<p>
{t("scopesEnforced")}:{" "}
<span className="font-semibold">
{status?.heartbeat?.scopesEnforced ? t("yes") : t("no")}
</span>
</p>
<p>
{t("lastCall")}:{" "}
<span className="font-mono text-xs">
{status?.activity?.lastCallTool || "—"}{" "}
{status?.activity?.lastCallAt
? `(${new Date(status.activity.lastCallAt).toLocaleString()})`
: ""}
</span>
</p>
<p>
{t("heartbeatPath")}:{" "}
<span className="font-mono text-xs break-all">{status?.heartbeatPath || "—"}</span>
</p>
</div>
</div>
</div>
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("operationalControls")}</h2>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("switchCombo")}</p>
<select
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={selectedComboId}
onChange={(event) => setSelectedComboId(event.target.value)}
>
{combos.map((combo) => (
<option key={combo.id} value={combo.id}>
{combo.name} ({combo.isActive === false ? t("inactive") : t("active")})
</option>
))}
</select>
<Button
size="sm"
variant="secondary"
onClick={handleSwitchCombo}
disabled={!selectedCombo || actionBusy === "switch"}
>
{selectedCombo?.isActive === false ? t("activateCombo") : t("deactivateCombo")}
</Button>
</div>
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("applyResilienceProfile")}</p>
<select
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={selectedProfile}
onChange={(event) =>
setSelectedProfile(event.target.value as keyof typeof RESILIENCE_PRESETS)
}
>
<option value="aggressive">{t("profileAggressive")}</option>
<option value="balanced">{t("profileBalanced")}</option>
<option value="conservative">{t("profileConservative")}</option>
</select>
<Button
size="sm"
variant="secondary"
onClick={handleApplyResilience}
disabled={actionBusy === "resilience"}
>
{t("applyProfile")}
</Button>
</div>
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("resetCircuitBreakers")}</p>
<p className="text-xs text-text-muted">{t("resetCircuitBreakersHelp")}</p>
<Button
size="sm"
onClick={handleResetCircuitBreakers}
className="bg-red-500! hover:bg-red-600! text-white!"
disabled={actionBusy === "reset"}
>
{t("resetAllBreakers")}
</Button>
</div>
</div>
{actionMessage && <p className="text-sm text-text-muted mt-3">{actionMessage}</p>}
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("toolsAndScopes")}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2">{t("tableTool")}</th>
<th className="text-left py-2 pr-2">{t("tableScopes")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
<th className="text-left py-2">{t("tableAudit")}</th>
</tr>
</thead>
<tbody>
{tools.map((tool) => (
<tr key={tool.name} className="border-b border-border/40">
<td className="py-2 pr-2 font-mono text-xs">{tool.name}</td>
<td className="py-2 pr-2 text-xs">{tool.scopes.join(", ") || "—"}</td>
<td className="py-2 pr-2">{tool.phase}</td>
<td className="py-2">{tool.auditLevel}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card className="p-5">
<div className="flex flex-wrap gap-2 items-end justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">{t("auditLog")}</h2>
<p className="text-sm text-text-muted">
{t("auditSummary", { total: auditData.total, page: currentPage, totalPages })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={toolFilter}
onChange={(event) => {
setAuditOffset(0);
setToolFilter(event.target.value);
}}
>
<option value="">{t("allTools")}</option>
{tools.map((tool) => (
<option key={tool.name} value={tool.name}>
{tool.name}
</option>
))}
</select>
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={successFilter}
onChange={(event) => {
setAuditOffset(0);
setSuccessFilter(event.target.value as "all" | "true" | "false");
}}
>
<option value="all">{t("allResults")}</option>
<option value="true">{t("success")}</option>
<option value="false">{t("failure")}</option>
</select>
<input
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
placeholder={t("apiKeyIdPlaceholder")}
value={apiKeyFilter}
onChange={(event) => {
setAuditOffset(0);
setApiKeyFilter(event.target.value);
}}
/>
</div>
</div>
{auditLoading ? (
<p className="text-sm text-text-muted">{t("loadingAuditEntries")}</p>
) : auditData.entries.length === 0 ? (
<p className="text-sm text-text-muted">{t("noAuditEntriesForFilters")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2">{t("tableTimestamp")}</th>
<th className="text-left py-2 pr-2">{t("tableTool")}</th>
<th className="text-left py-2 pr-2">{t("tableDuration")}</th>
<th className="text-left py-2 pr-2">{t("tableResult")}</th>
<th className="text-left py-2">{t("tableApiKey")}</th>
</tr>
</thead>
<tbody>
{auditData.entries.map((entry) => (
<tr key={entry.id} className="border-b border-border/40">
<td className="py-2 pr-2 text-xs">
{new Date(entry.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-2 font-mono text-xs">{entry.toolName}</td>
<td className="py-2 pr-2">{entry.durationMs}ms</td>
<td className="py-2 pr-2">
<span className={entry.success ? "text-green-500" : "text-red-500"}>
{entry.success ? t("success") : entry.errorCode || t("failed")}
</span>
</td>
<td className="py-2">{entry.apiKeyId || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center justify-end gap-2 mt-4">
<Button
size="sm"
variant="secondary"
disabled={auditOffset === 0}
onClick={() => setAuditOffset((current) => Math.max(0, current - AUDIT_PAGE_SIZE))}
>
{t("previous")}
</Button>
<Button
size="sm"
variant="secondary"
disabled={auditOffset + AUDIT_PAGE_SIZE >= auditData.total}
onClick={() =>
setAuditOffset((current) =>
current + AUDIT_PAGE_SIZE < auditData.total ? current + AUDIT_PAGE_SIZE : current
)
}
>
{t("next")}
</Button>
</div>
</Card>
</div>
);
}
function StatCard({
label,
value,
compact = false,
}: {
label: string;
value: string | number;
compact?: boolean;
}) {
return (
<div className={`rounded-lg border border-border bg-bg p-4 ${compact ? "" : "min-h-[84px]"}`}>
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
<p className="text-xl font-semibold mt-1">{value}</p>
</div>
);
}

View File

@@ -1,24 +1,34 @@
"use client";
export default function ProvidersError({
error,
error: _error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div
className="flex flex-col items-center justify-center min-h-[400px] p-6"
role="alert"
aria-live="assertive"
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load providers
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading provider data."}
<p className="text-text-muted max-w-md">
We could not load provider data right now. Check your connection and try again.
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
)}
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
</button>

View File

@@ -1,12 +1,14 @@
"use client";
import { CardSkeleton, Skeleton } from "@/shared/components/Loading";
export default function ProvidersLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="space-y-6 p-6" role="status" aria-live="polite" aria-busy="true">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
{[0, 1, 2].map((index) => (
<CardSkeleton key={index} />
))}
</div>
</div>

View File

@@ -5,8 +5,8 @@ import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function ModelAliasesTab() {
const [builtIn, setBuiltIn] = useState({});
const [custom, setCustom] = useState({});
const [builtIn, setBuiltIn] = useState<Record<string, string>>({});
const [custom, setCustom] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
@@ -49,7 +49,7 @@ export default function ModelAliasesTab() {
}
};
const removeAlias = async (from) => {
const removeAlias = async (from: string) => {
setSaving(true);
try {
const res = await fetch("/api/settings/model-aliases", {
@@ -97,9 +97,7 @@ export default function ModelAliasesTab() {
{/* Add custom alias */}
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<p className="text-sm font-medium mb-3">
{t("addCustomAlias") || "Add Custom Alias"}
</p>
<p className="text-sm font-medium mb-3">{t("addCustomAlias") || "Add Custom Alias"}</p>
<div className="flex items-center gap-2">
<input
type="text"

View File

@@ -1,24 +1,34 @@
"use client";
export default function SettingsError({
error,
error: _error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
<div
className="flex flex-col items-center justify-center min-h-[400px] p-6"
role="alert"
aria-live="assertive"
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load settings
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
{error.message || "An unexpected error occurred while loading settings."}
<p className="text-text-muted max-w-md">
We could not load settings right now. Please retry in a few seconds.
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
)}
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
</button>

View File

@@ -1,14 +1,16 @@
"use client";
import { Skeleton } from "@/shared/components/Loading";
export default function SettingsLoading() {
return (
<div className="space-y-6 animate-pulse p-6">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-36" />
<div className="space-y-6 p-6" role="status" aria-live="polite" aria-busy="true">
<Skeleton className="h-8 w-36" />
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded" />
{[0, 1, 2, 3].map((index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>

View File

@@ -212,7 +212,7 @@ export default function EvalsTab() {
return (
<div className="flex flex-col gap-6">
{/* Hero Section — always visible */}
<HeroSection />
<HeroSection t={t} />
<EmptyState
icon="science"
title={t("noEvalSuitesFound")}

View File

@@ -0,0 +1,105 @@
/**
* Agent Card Endpoint — /.well-known/agent.json
*
* Serves the OmniRoute A2A Agent Card for discovery by other agents.
* Conforms to A2A Protocol v0.3.
*
* The Agent Card is dynamically generated to include the current version
* from package.json and skills based on available combos.
*/
import { NextResponse } from "next/server";
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
/**
* GET /.well-known/agent.json
*
* Returns the OmniRoute Agent Card that describes this gateway's
* capabilities as an A2A agent.
*/
export async function GET() {
const agentCard = {
name: "OmniRoute AI Gateway",
description:
"Intelligent AI routing gateway with 36+ providers, smart fallback, " +
"quota tracking, format translation, and auto-managed combos. " +
"Routes AI requests to the optimal provider based on cost, latency, " +
"quota availability, and task requirements.",
url: `${BASE_URL}/a2a`,
version: PACKAGE_VERSION,
capabilities: {
streaming: true,
pushNotifications: false,
},
skills: [
{
id: "smart-routing",
name: "Smart Request Routing",
description:
"Routes AI requests to the optimal provider based on quota, cost, " +
"latency, and reliability. Supports combo-based routing with " +
"multiple strategies: priority, weighted, round-robin, cost-optimized.",
tags: ["routing", "llm", "optimization", "fallback"],
examples: [
"Route this coding task to the fastest available model",
"Send this review to an analytical model under $0.50 budget",
"Find the cheapest provider with available quota",
],
},
{
id: "quota-management",
name: "Quota & Cost Management",
description:
"Tracks and manages API quotas across 36+ providers with " +
"auto-fallback when quotas are exhausted. Provides real-time " +
"cost tracking and budget enforcement.",
tags: ["quota", "cost", "monitoring", "budget"],
examples: [
"Check remaining quota for all providers",
"Which provider has the most available quota?",
"Generate a cost report for today",
],
},
{
id: "auto-combo",
name: "Auto-Managed Model Combos",
description:
"Self-healing model chains that dynamically adapt to provider " +
"health and quota availability. Uses a scoring function based on " +
"quota, health, cost, latency, task fitness, and stability.",
tags: ["combo", "auto", "self-healing", "adaptive"],
examples: [
"Create an auto-managed combo for coding tasks",
"Switch to cost-saver mode",
"Show the Auto-Combo scoring breakdown",
],
},
{
id: "format-translation",
name: "Format Translation",
description:
"Transparently translates between OpenAI, Claude (Anthropic), " +
"Gemini (Google), and Responses API formats. Supports streaming " +
"translation for all format pairs.",
tags: ["translation", "openai", "claude", "gemini", "responses"],
examples: [
"Send an OpenAI-format request to Claude",
"Translate this Gemini response to OpenAI format",
],
},
],
authentication: {
schemes: ["api-key"],
apiKeyHeader: "Authorization",
},
};
return NextResponse.json(agentCard, {
headers: {
"Cache-Control": "public, max-age=3600",
"Content-Type": "application/json",
},
});
}

19
src/app/400/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function BadRequestPage() {
return (
<ErrorPageScaffold
code="400"
icon="rule"
title="Bad Request"
description="The request payload is invalid or incomplete."
suggestions={[
"Review required fields and payload format before retrying.",
"If you are using the API, validate the JSON schema locally.",
"If this keeps happening, open the request in Translator Playground to inspect the payload.",
]}
primaryAction={{ href: "/docs", label: "Open Documentation" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
/>
);
}

19
src/app/401/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function UnauthorizedPage() {
return (
<ErrorPageScaffold
code="401"
icon="lock"
title="Unauthorized"
description="Authentication is required to access this resource."
suggestions={[
"Sign in again and retry the operation.",
"For API calls, confirm the Bearer token is present and valid.",
"If the token was recently rotated, update your client credentials.",
]}
primaryAction={{ href: "/login", label: "Go to Login" }}
secondaryAction={{ href: "/dashboard/api-manager", label: "Manage API Keys" }}
/>
);
}

22
src/app/403/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function ForbiddenStatusPage() {
return (
<ErrorPageScaffold
code="403"
icon="gpp_bad"
title="Forbidden"
description="Your request was understood, but access is denied by policy."
suggestions={[
"Check IP allowlist/blocklist rules in settings.",
"Verify model and budget policies assigned to your API key.",
"Ask an administrator to grant the required permission scope.",
]}
primaryAction={{ href: "/forbidden", label: "Open Access Help" }}
secondaryAction={{
href: "/dashboard/settings?tab=security",
label: "Open Security Settings",
}}
/>
);
}

19
src/app/408/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function RequestTimeoutPage() {
return (
<ErrorPageScaffold
code="408"
icon="timer_off"
title="Request Timeout"
description="The server did not receive a complete request in time."
suggestions={[
"Retry the request with a smaller payload.",
"Check your network stability and VPN/proxy latency.",
"For long operations, enable streaming or split the request.",
]}
primaryAction={{ href: "/dashboard/endpoint", label: "Open Endpoint Guide" }}
secondaryAction={{ href: "/status", label: "Check Network Status" }}
/>
);
}

22
src/app/429/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function TooManyRequestsPage() {
return (
<ErrorPageScaffold
code="429"
icon="hourglass_top"
title="Too Many Requests"
description="Rate limits were exceeded for this client, key, or provider."
suggestions={[
"Wait for cooldown and retry after the suggested interval.",
"Switch to a combo with fallback providers.",
"Tune provider resilience/rate-limit profiles in settings.",
]}
primaryAction={{
href: "/dashboard/settings?tab=resilience",
label: "Open Resilience Settings",
}}
secondaryAction={{ href: "/dashboard/combos", label: "Open Combos" }}
/>
);
}

19
src/app/500/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function InternalServerErrorPage() {
return (
<ErrorPageScaffold
code="500"
icon="warning"
title="Internal Server Error"
description="An unexpected server-side error occurred while processing your request."
suggestions={[
"Retry once in a few seconds.",
"Check health telemetry and server logs for correlated request IDs.",
"If persistent, report the issue with timestamp and request context.",
]}
primaryAction={{ href: "/dashboard/health", label: "Open Health Dashboard" }}
secondaryAction={{ href: "/dashboard/logs", label: "Open Logs" }}
/>
);
}

19
src/app/502/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function BadGatewayPage() {
return (
<ErrorPageScaffold
code="502"
icon="hub"
title="Bad Gateway"
description="Upstream provider or gateway integration returned an invalid response."
suggestions={[
"Retry with another provider or active combo route.",
"Check provider credentials and model availability.",
"Inspect translator output if format conversion is involved.",
]}
primaryAction={{ href: "/dashboard/providers", label: "Open Providers" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
/>
);
}

19
src/app/503/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
export default function ServiceUnavailablePage() {
return (
<ErrorPageScaffold
code="503"
icon="build_circle"
title="Service Unavailable"
description="The service is temporarily unavailable due to maintenance or degraded dependencies."
suggestions={[
"Wait a moment and retry.",
"Check maintenance notices and system status.",
"Use fallback providers if your workflow is latency-sensitive.",
]}
primaryAction={{ href: "/maintenance", label: "Maintenance Details" }}
secondaryAction={{ href: "/status", label: "System Status" }}
/>
);
}

247
src/app/a2a/route.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* A2A JSON-RPC 2.0 Router — `/a2a` endpoint
*
* Methods:
* - message/send — Synchronous task execution
* - message/stream — SSE streaming execution
* - tasks/get — Query task by ID
* - tasks/cancel — Cancel task by ID
*
* Auth: Bearer token via Authorization header
*/
import { NextRequest, NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { executeSmartRouting } from "@/lib/a2a/skills/smartRouting";
import { executeQuotaManagement } from "@/lib/a2a/skills/quotaManagement";
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
// ============ Skill Registry ============
const SKILL_HANDLERS: Record<string, (task: any) => Promise<any>> = {
"smart-routing": executeSmartRouting,
"quota-management": executeQuotaManagement,
};
type A2AMessage = { role: string; content: string };
function toMessageArray(raw: unknown): A2AMessage[] | null {
if (Array.isArray(raw)) {
const normalized = raw
.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const msg = entry as Record<string, unknown>;
const role = typeof msg.role === "string" && msg.role.trim() ? msg.role : "user";
const content = typeof msg.content === "string" ? msg.content : null;
if (!content) return null;
return { role, content };
})
.filter((entry): entry is A2AMessage => !!entry);
return normalized.length > 0 ? normalized : null;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const message = raw as Record<string, unknown>;
const role = typeof message.role === "string" && message.role.trim() ? message.role : "user";
// Canonical A2A shape: { message: { role, content } }
if (typeof message.content === "string" && message.content.trim()) {
return [{ role, content: message.content }];
}
// Legacy compatibility: { message: { parts: [...] } }
if (Array.isArray(message.parts)) {
const text = message.parts
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object" || Array.isArray(part)) return "";
const chunk = part as Record<string, unknown>;
if (typeof chunk.content === "string") return chunk.content;
if (typeof chunk.text === "string") return chunk.text;
return "";
})
.filter((chunk) => chunk.trim().length > 0)
.join("\n");
if (text) return [{ role, content: text }];
}
return null;
}
// ============ Auth ============
function authenticate(req: NextRequest): boolean {
// If no API key is configured, allow all requests
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (!configuredKey) return true;
const authHeader = req.headers.get("authorization") || "";
const token = authHeader.replace(/^Bearer\s+/i, "");
return token === configuredKey;
}
// ============ JSON-RPC Helpers ============
function jsonRpcError(id: string | number | null, code: number, message: string, data?: unknown) {
return NextResponse.json(
{ jsonrpc: "2.0", id, error: { code, message, data } },
{ status: code === -32600 ? 400 : code === -32601 ? 404 : code === -32603 ? 500 : 200 }
);
}
function jsonRpcResult(id: string | number | null, result: unknown) {
return NextResponse.json({ jsonrpc: "2.0", id, result });
}
// ============ Route Handler ============
export async function POST(req: NextRequest) {
// Auth check
if (!authenticate(req)) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
}
// Parse JSON-RPC body
let body: any;
try {
body = await req.json();
} catch {
return jsonRpcError(null, -32700, "Parse error: invalid JSON");
}
const { jsonrpc, id, method, params } = body;
if (jsonrpc !== "2.0" || !method) {
return jsonRpcError(id || null, -32600, "Invalid request: missing jsonrpc or method");
}
const tm = getTaskManager();
switch (method) {
// ── message/send ──────────────────────────────────────
case "message/send": {
const skill = params?.skill || "smart-routing";
const messages = toMessageArray(params?.messages) || toMessageArray(params?.message);
if (!messages) {
return jsonRpcError(
id,
-32602,
"Invalid params: provide `messages[]` or `message.content`"
);
}
const handler = SKILL_HANDLERS[skill];
if (!handler) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
try {
tm.updateTask(task.id, "working");
const result = await handler(task);
tm.updateTask(task.id, "completed", result.artifacts);
// Log routing decision
if (skill === "smart-routing" && result.metadata) {
logRoutingDecision({
taskType: (params?.metadata?.role as string) || "general",
comboId: (params?.metadata?.combo as string) || "default",
providerSelected:
result.metadata?.routing_explanation?.match(/"([^"]+)"/)?.[1] || "unknown",
modelUsed: (params?.metadata?.model as string) || "auto",
score: 1,
factors: [],
fallbacksTriggered: [],
success: true,
latencyMs: 0,
cost: result.metadata?.cost_envelope?.actual || 0,
});
}
return jsonRpcResult(id, {
task: { id: task.id, state: "completed" },
artifacts: result.artifacts,
metadata: result.metadata,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
return jsonRpcError(id, -32603, `Skill execution failed: ${msg}`);
}
}
// ── message/stream ────────────────────────────────────
case "message/stream": {
const skill = params?.skill || "smart-routing";
const messages = toMessageArray(params?.messages) || toMessageArray(params?.message);
if (!messages) {
return jsonRpcError(
id,
-32602,
"Invalid params: provide `messages[]` or `message.content`"
);
}
const handler = SKILL_HANDLERS[skill];
if (!handler) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
tm.updateTask(task.id, "working");
const stream = createA2AStream(
task,
async (t) => executeA2ATaskWithState(tm, t, handler),
req.signal,
{
onStart: () => tm.beginStream(),
onEnd: () => tm.endStream(),
}
);
return new Response(stream, { headers: SSE_HEADERS });
}
// ── tasks/get ─────────────────────────────────────────
case "tasks/get": {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
const task = tm.getTask(taskId);
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
return jsonRpcResult(id, { task });
}
// ── tasks/cancel ──────────────────────────────────────
case "tasks/cancel": {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
try {
const task = tm.cancelTask(taskId);
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return jsonRpcError(id, -32603, msg);
}
}
default:
return jsonRpcError(id, -32601, `Method not found: ${method}`);
}
}
// Agent Card discovery via OPTIONS
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
Allow: "POST, OPTIONS",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function GET() {
try {
const tm = getTaskManager();
const stats = tm.getStats();
let agentCard: any = null;
try {
const agentModule = await import("@/app/.well-known/agent.json/route");
const cardResponse = await agentModule.GET();
agentCard = await cardResponse.json();
} catch {
agentCard = null;
}
return NextResponse.json({
status: "ok",
tasks: stats,
agent: agentCard
? {
name: agentCard.name,
description: agentCard.description,
version: agentCard.version,
url: agentCard.url,
}
: null,
capabilities: agentCard?.capabilities || null,
skills: Array.isArray(agentCard?.skills) ? agentCard.skills : [],
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A status";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.cancelTask(id);
return NextResponse.json({ task: { id: task.id, state: task.state } });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.getTask(id);
if (!task) {
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A task";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
const VALID_TASK_STATES = new Set<TaskState>([
"submitted",
"working",
"completed",
"failed",
"cancelled",
]);
function parseIntParam(value: string | null, fallback: number): number {
if (typeof value !== "string") return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const stateParam = searchParams.get("state");
const skill = searchParams.get("skill") || undefined;
const limit = Math.max(1, Math.min(200, parseIntParam(searchParams.get("limit"), 50)));
const offset = Math.max(0, parseIntParam(searchParams.get("offset"), 0));
const state =
typeof stateParam === "string" && VALID_TASK_STATES.has(stateParam as TaskState)
? (stateParam as TaskState)
: undefined;
const tm = getTaskManager();
const total = tm.countTasks({ state, skill });
const tasks = tm.listTasks({ state, skill, limit, offset });
return NextResponse.json({
tasks,
total,
limit,
offset,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list A2A tasks";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -3,7 +3,8 @@ import { getSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
import { cookies } from "next/headers";
import { loginSchema, validateBody } from "@/shared/validation/schemas";
import { loginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
@@ -25,13 +26,16 @@ export async function POST(request) {
// Zod validation
const validation = validateBody(loginSchema, rawBody);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { password } = validation.data;
const password = typeof validation.data.password === "string" ? validation.data.password : "";
if (!password) {
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
}
const settings = await getSettings();
const storedHash = settings.password;
const storedHash = typeof settings.password === "string" ? settings.password : "";
let isValid = false;
if (storedHash) {
@@ -73,6 +77,7 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("[AUTH] Login failed:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -2,6 +2,8 @@
import { NextResponse } from "next/server";
import { getMitmAlias, setMitmAliasAll } from "@/models";
import { cliMitmAliasUpdateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET - Get MITM aliases for a tool
export async function GET(request) {
@@ -18,12 +20,27 @@ export async function GET(request) {
// PUT - Save MITM aliases for a specific tool
export async function PUT(request) {
let rawBody;
try {
const { tool, mappings } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!tool || !mappings || typeof mappings !== "object") {
return NextResponse.json({ error: "tool and mappings required" }, { status: 400 });
try {
const validation = validateBody(cliMitmAliasUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { tool, mappings } = validation.data;
const filtered: Record<string, string> = {};
for (const [alias, model] of Object.entries(mappings)) {

View File

@@ -8,6 +8,8 @@ import {
getCachedPassword,
setCachedPassword,
} from "@/mitm/manager";
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET - Check MITM status
export async function GET() {
@@ -28,8 +30,27 @@ export async function GET() {
// POST - Start MITM proxy
export async function POST(request) {
let rawBody;
try {
const { apiKey, sudoPassword } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(cliMitmStartSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { apiKey, sudoPassword } = validation.data;
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";
@@ -59,8 +80,27 @@ export async function POST(request) {
// DELETE - Stop MITM proxy
export async function DELETE(request) {
let rawBody;
try {
const { sudoPassword } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(cliMitmStopSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { sudoPassword } = validation.data;
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";

View File

@@ -3,6 +3,8 @@
import { NextResponse } from "next/server";
import { listBackups, restoreBackup, deleteBackup } from "@/shared/services/backupService";
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { cliBackupMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
@@ -35,19 +37,33 @@ export async function GET(request) {
// POST /api/cli-tools/backups { tool, backupId } — restore a backup
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const body = await request.json();
const tool = body.tool || body.toolId;
const backupId = body.backupId;
if (!tool || !backupId) {
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
const validation = validateBody(cliBackupMutationSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const tool = validation.data.tool || validation.data.toolId;
const { backupId } = validation.data;
if (!VALID_TOOLS.includes(tool)) {
return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 });
@@ -70,14 +86,28 @@ export async function POST(request) {
// DELETE /api/cli-tools/backups { tool, backupId } — delete a backup
export async function DELETE(request) {
let rawBody;
try {
const body = await request.json();
const tool = body.tool || body.toolId;
const backupId = body.backupId;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!tool || !backupId) {
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
try {
const validation = validateBody(cliBackupMutationSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const tool = validation.data.tool || validation.data.toolId;
const { backupId } = validation.data;
if (!VALID_TOOLS.includes(tool)) {
return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 });

View File

@@ -10,6 +10,8 @@ import {
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// Get claude settings path based on OS
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
@@ -71,17 +73,32 @@ export async function GET() {
// POST - Backup old fields and write new settings
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { env } = await request.json();
if (!env || typeof env !== "object") {
return NextResponse.json({ error: "Invalid env object" }, { status: 400 });
const validation = validateBody(cliSettingsEnvSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { env } = validation.data;
const settingsPath = getClaudeSettingsPath();
const claudeDir = path.dirname(settingsPath);

View File

@@ -7,6 +7,8 @@ import os from "os";
import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
@@ -98,17 +100,32 @@ export async function GET() {
// POST - Configure Cline to use OmniRoute as OpenAI-compatible provider
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
// Ensure directory exists
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });

View File

@@ -5,6 +5,8 @@ import fs from "fs/promises";
import path from "path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime";
import { resolveDataDir } from "@/lib/dataPaths";
import { codexProfileIdSchema, codexProfileNameSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const PROFILES_DIR = path.join(resolveDataDir(), "codex-profiles");
@@ -79,17 +81,32 @@ export async function GET() {
// POST - Save current config as a named profile
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { name } = await request.json();
if (!name || typeof name !== "string" || !name.trim()) {
return NextResponse.json({ error: "Profile name is required" }, { status: 400 });
const validation = validateBody(codexProfileNameSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name } = validation.data;
const paths = getCliConfigPaths("codex");
if (!paths) {
@@ -150,17 +167,32 @@ export async function POST(request) {
// PUT - Activate a saved profile (restore its config + auth)
export async function PUT(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { profileId } = await request.json();
if (!profileId) {
return NextResponse.json({ error: "profileId is required" }, { status: 400 });
const validation = validateBody(codexProfileIdSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { profileId } = validation.data;
const profilePath = path.join(PROFILES_DIR, `${profileId}.json`);
let profile;
@@ -206,12 +238,27 @@ export async function PUT(request) {
// DELETE - Remove a saved profile
export async function DELETE(request) {
let rawBody;
try {
const { profileId } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!profileId) {
return NextResponse.json({ error: "profileId is required" }, { status: 400 });
try {
const validation = validateBody(codexProfileIdSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { profileId } = validation.data;
const profilePath = path.join(PROFILES_DIR, `${profileId}.json`);
try {

View File

@@ -10,6 +10,8 @@ import {
} from "@/shared/services/cliRuntime";
import { createMultiBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const getCodexConfigPath = () => getCliConfigPaths("codex").config;
const getCodexAuthPath = () => getCliConfigPaths("codex").auth;
@@ -139,15 +141,33 @@ export async function GET() {
// POST - Update OmniRoute settings (merge with existing config)
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !apiKey || !model) {
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
if (!apiKey) {
return NextResponse.json(
{ error: "baseUrl, apiKey and model are required" },
{ status: 400 }

View File

@@ -10,6 +10,8 @@ import {
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -74,17 +76,32 @@ export async function GET() {
// POST - Update OmniRoute customModels (merge with existing settings)
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const droidDir = getDroidDir();
const settingsPath = getDroidSettingsPath();

View File

@@ -3,6 +3,8 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/cli-tools/guide-settings/:toolId
@@ -11,13 +13,28 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
* Currently supports: continue
*/
export async function POST(request, { params }) {
const { toolId } = await params;
const { baseUrl, apiKey, model } = await request.json();
if (!model) {
return NextResponse.json({ error: "Model is required" }, { status: 400 });
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
const { toolId } = await params;
const validation = validateBody(guideSettingsSaveSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
try {
switch (toolId) {
case "continue":

View File

@@ -7,6 +7,8 @@ import os from "os";
import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
@@ -106,17 +108,32 @@ export async function GET() {
// POST - Configure Kilo Code to use OmniRoute as OpenAI-compatible provider
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
// Ensure directories exist
await fs.mkdir(KILO_DATA_DIR, { recursive: true });

View File

@@ -10,6 +10,8 @@ import {
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
@@ -74,17 +76,32 @@ export async function GET() {
// POST - Update OmniRoute settings (merge with existing settings)
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const openclawDir = getOpenClawDir();
const settingsPath = getOpenClawSettingsPath();

View File

@@ -0,0 +1,67 @@
/**
* OpenClaw Integration — Dynamic provider.order based on Auto-Combo scores.
*
* GET /api/cli-tools/openclaw/auto-order
*/
import { NextResponse } from "next/server";
const OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
export async function GET() {
try {
// Fetch current health and combos to determine best provider ordering
const [healthRes, combosRes] = await Promise.allSettled([
fetch(`${OMNIROUTE_BASE_URL}/api/monitoring/health`, { signal: AbortSignal.timeout(5000) }),
fetch(`${OMNIROUTE_BASE_URL}/api/combos`, { signal: AbortSignal.timeout(5000) }),
]);
const health = healthRes.status === "fulfilled" ? await healthRes.value.json() : {};
const combos = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
// Build provider scores from circuit breaker state
const breakers: any[] = health?.circuitBreakers || [];
const providerScores = new Map<string, number>();
// Start all providers with base score
const allProviders = new Set<string>();
if (Array.isArray(combos)) {
for (const combo of combos) {
for (const model of combo.models || combo.data?.models || []) {
allProviders.add(model.provider);
providerScores.set(model.provider, (providerScores.get(model.provider) || 0) + 1);
}
}
}
// Adjust by circuit breaker state
for (const cb of breakers) {
const current = providerScores.get(cb.provider) || 0;
if (cb.state === "OPEN") providerScores.set(cb.provider, current * 0.1);
else if (cb.state === "HALF_OPEN") providerScores.set(cb.provider, current * 0.5);
}
// Sort by score descending
const ordered = [...providerScores.entries()]
.sort((a, b) => b[1] - a[1])
.map(([provider]) => provider);
return NextResponse.json({
provider: {
order: ordered,
allow_fallbacks: true,
},
generated_at: new Date().toISOString(),
source: "omniroute-auto-combo",
});
} catch {
return NextResponse.json({
provider: {
order: ["anthropic", "google", "openai"],
allow_fallbacks: true,
},
generated_at: new Date().toISOString(),
source: "omniroute-fallback",
});
}
}

View File

@@ -27,6 +27,10 @@ export async function POST(request) {
return value.slice(0, 4) + "****" + value.slice(-4);
}
function toOptionalString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
// Map connections — NEVER expose raw credentials
const mappedConnections = connections.map((conn) => ({
provider: conn.provider,
@@ -34,7 +38,7 @@ export async function POST(request) {
hasApiKey: !!conn.apiKey,
hasAccessToken: !!conn.accessToken,
hasRefreshToken: !!conn.refreshToken,
maskedApiKey: maskSecret(conn.apiKey),
maskedApiKey: maskSecret(toOptionalString(conn.apiKey)),
projectId: conn.projectId || null,
expiresAt: conn.expiresAt,
priority: conn.priority,

View File

@@ -1,8 +1,20 @@
import { NextResponse } from "next/server";
import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models";
import { cloudCredentialUpdateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// Update provider credentials (for cloud token refresh)
export async function PUT(request) {
export async function PUT(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{ error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } },
{ status: 400 }
);
}
try {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
@@ -10,12 +22,11 @@ export async function PUT(request) {
}
const apiKey = authHeader.slice(7);
const body = await request.json();
const { provider, credentials } = body;
if (!provider || !credentials) {
return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 });
const validation = validateBody(cloudCredentialUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, credentials } = validation.data;
// Validate API key
const isValid = await validateApiKey(apiKey);
@@ -35,7 +46,7 @@ export async function PUT(request) {
}
// Update credentials
const updateData: Record<string, any> = {};
const updateData: Record<string, unknown> = {};
if (credentials.accessToken) {
updateData.accessToken = credentials.accessToken;
}
@@ -46,7 +57,11 @@ export async function PUT(request) {
updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString();
}
await updateProviderConnection(connection.id, updateData);
const connectionId = typeof connection.id === "string" ? connection.id : null;
if (!connectionId) {
return NextResponse.json({ error: "Invalid provider connection ID" }, { status: 500 });
}
await updateProviderConnection(connectionId, updateData);
return NextResponse.json({
success: true,

View File

@@ -1,8 +1,20 @@
import { NextResponse } from "next/server";
import { validateApiKey, getModelAliases } from "@/models";
import { cloudResolveAliasSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// Resolve model alias to provider/model
export async function POST(request) {
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{ error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } },
{ status: 400 }
);
}
try {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
@@ -10,13 +22,11 @@ export async function POST(request) {
}
const apiKey = authHeader.slice(7);
const body = await request.json();
const { alias } = body;
if (!alias) {
return NextResponse.json({ error: "Missing alias" }, { status: 400 });
const validation = validateBody(cloudResolveAliasSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { alias } = validation.data;
// Validate API key
const isValid = await validateApiKey(apiKey);
@@ -26,7 +36,8 @@ export async function POST(request) {
// Get model aliases
const modelAliases = await getModelAliases();
const resolved = modelAliases[alias];
const resolvedValue = modelAliases[alias];
const resolved = typeof resolvedValue === "string" ? resolvedValue : null;
if (resolved) {
// Parse provider/model

View File

@@ -2,9 +2,21 @@ import { NextResponse } from "next/server";
import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { cloudModelAliasUpdateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
export async function PUT(request) {
export async function PUT(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{ error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } },
{ status: 400 }
);
}
try {
const authHeader = request.headers.get("authorization");
const apiKey = authHeader?.replace("Bearer ", "");
@@ -18,12 +30,11 @@ export async function PUT(request) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
const validation = validateBody(cloudModelAliasUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, alias } = validation.data;
// Check if alias already exists for different model
const aliases = await getModelAliases();

View File

@@ -10,9 +10,8 @@ import {
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
import { updateComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/combos/[id] - Get combo by ID
export async function GET(request, { params }) {
@@ -33,20 +32,31 @@ export async function GET(request, { params }) {
// PUT /api/combos/[id] - Update combo
export async function PUT(request, { params }) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { id } = await params;
const body = await request.json();
const validation = validateBody(updateComboSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
// Validate name format if provided
// Check if name already exists (exclude current combo)
if (body.name) {
if (!VALID_NAME_REGEX.test(body.name)) {
return NextResponse.json(
{ error: "Name can only contain letters, numbers, - and _" },
{ status: 400 }
);
}
// Check if name already exists (exclude current combo)
const existing = await getComboByName(body.name);
if (existing && existing.id !== id) {
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });

View File

@@ -0,0 +1,94 @@
/**
* Auto-Combo REST API — `/api/combos/auto`
*
* POST — Create auto-combo
* GET — List all auto-combos
*
* Note: Auto-combo state is managed in-memory by the engine module.
* The open-sse/services/autoCombo module is outside Next.js src/,
* so we use a lightweight in-memory store here that mirrors the engine API.
*/
import { NextRequest, NextResponse } from "next/server";
import { createAutoComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// ── In-memory auto-combo store (mirrors open-sse/services/autoCombo/engine.ts) ──
interface ScoringWeights {
quota: number;
health: number;
costInv: number;
latencyInv: number;
taskFit: number;
stability: number;
}
const DEFAULT_WEIGHTS: ScoringWeights = {
quota: 0.2,
health: 0.25,
costInv: 0.2,
latencyInv: 0.15,
taskFit: 0.1,
stability: 0.1,
};
interface AutoComboConfig {
id: string;
name: string;
type: "auto";
candidatePool: string[];
weights: ScoringWeights;
modePack?: string;
budgetCap?: number;
explorationRate: number;
}
const autoCombos = new Map<string, AutoComboConfig>();
export async function POST(req: NextRequest) {
let rawBody: unknown;
try {
rawBody = await req.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(createAutoComboSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { id, name, candidatePool, weights, modePack, budgetCap, explorationRate } =
validation.data;
const config: AutoComboConfig = {
id,
name,
type: "auto",
candidatePool,
weights: weights ?? DEFAULT_WEIGHTS,
modePack,
budgetCap,
explorationRate,
};
autoCombos.set(id, config);
return NextResponse.json(config, { status: 201 });
} catch (err) {
console.log("Error creating auto-combo:", err);
return NextResponse.json({ error: "Failed to create auto-combo" }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({ combos: [...autoCombos.values()] });
}

View File

@@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createComboSchema, validateBody } from "@/shared/validation/schemas";
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
import { createComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/combos - Get all combos
export async function GET() {
@@ -23,7 +24,7 @@ export async function POST(request) {
// Zod validation (covers name format, length, etc.)
const validation = validateBody(createComboSchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, models, strategy, config } = validation.data;

View File

@@ -1,17 +1,34 @@
import { NextResponse } from "next/server";
import { getComboByName } from "@/lib/localDb";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/combos/test - Quick test a combo
* Sends a minimal request through each model in the combo to verify availability
*/
export async function POST(request) {
let rawBody;
try {
const { comboName } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!comboName) {
return NextResponse.json({ error: "comboName is required" }, { status: 400 });
try {
const validation = validateBody(testComboSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { comboName } = validation.data;
const combo = await getComboByName(comboName);
if (!combo) {

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb";
import { dbBackupRestoreSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* PUT /api/db-backups — Trigger a manual backup snapshot.
@@ -35,13 +37,27 @@ export async function GET() {
* Body: { backupId: "db_2026-02-11T14-00-00-000Z_pre-write.json" }
*/
export async function POST(request) {
let rawBody;
try {
const body = await request.json();
const { backupId } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!backupId) {
return NextResponse.json({ error: "backupId is required" }, { status: 400 });
try {
const validation = validateBody(dbBackupRestoreSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { backupId } = validation.data;
const result = await restoreDbBackup(backupId);
return NextResponse.json(result);

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { listSuites, runSuite } from "@/lib/evals/evalRunner";
import { evalRunSuiteSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
@@ -11,14 +13,27 @@ export async function GET() {
}
export async function POST(request) {
let rawBody;
try {
const { suiteId, outputs } = await request.json();
if (!suiteId || !outputs) {
return NextResponse.json(
{ error: "suiteId and outputs (Record<caseId, actualOutput>) are required" },
{ status: 400 }
);
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(evalRunSuiteSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { suiteId, outputs } = validation.data;
const result = runSuite(suiteId, outputs);
return NextResponse.json(result);
} catch (error) {

View File

@@ -1,44 +1,75 @@
import { NextResponse } from "next/server";
import {
getAllFallbackChains,
registerFallback,
removeFallback,
} from "@/domain/fallbackPolicy";
import { getAllFallbackChains, registerFallback, removeFallback } from "@/domain/fallbackPolicy";
import { registerFallbackSchema, removeFallbackSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
const chains = getAllFallbackChains();
return NextResponse.json(chains);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error fetching fallback chains:", error);
return NextResponse.json({ error: "Failed to fetch fallback chains" }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
const { model, chain } = await request.json();
if (!model || !Array.isArray(chain)) {
return NextResponse.json(
{ error: "model (string) and chain (array of {provider, priority?, enabled?}) are required" },
{ status: 400 }
);
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(registerFallbackSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, chain } = validation.data;
registerFallback(model, chain);
return NextResponse.json({ success: true, model });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error registering fallback chain:", error);
return NextResponse.json({ error: "Failed to register fallback chain" }, { status: 500 });
}
}
export async function DELETE(request) {
let rawBody;
try {
const { model } = await request.json();
if (!model) {
return NextResponse.json({ error: "model is required" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(removeFallbackSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model } = validation.data;
const removed = removeFallback(model);
return NextResponse.json({ success: true, removed });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error removing fallback chain:", error);
return NextResponse.json({ error: "Failed to remove fallback chain" }, { status: 500 });
}
}

View File

@@ -7,6 +7,8 @@ import {
} from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { updateKeyPermissionsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/keys/[id] - Get single API key
export async function GET(request, { params }) {
@@ -19,9 +21,10 @@ export async function GET(request, { params }) {
}
// Mask the key value
const keyValue = typeof key.key === "string" ? key.key : null;
return NextResponse.json({
...key,
key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null,
key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null,
});
} catch (error) {
console.log("Error fetching key:", error);
@@ -29,26 +32,32 @@ export async function GET(request, { params }) {
}
}
// PATCH /api/keys/[id] - Update API key permissions
// PATCH /api/keys/[id] - Update API key permissions/privacy controls
export async function PATCH(request, { params }) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { id } = await params;
const body = await request.json();
const { allowedModels } = body;
// Validate allowedModels is an array
if (!Array.isArray(allowedModels)) {
return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 });
const validation = validateBody(updateKeyPermissionsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { allowedModels, noLog } = validation.data;
// Validate each model ID is a string
for (const model of allowedModels) {
if (typeof model !== "string") {
return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 });
}
}
const updated = await updateApiKeyPermissions(id, allowedModels);
const updated = await updateApiKeyPermissions(id, { allowedModels, noLog });
if (!updated) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
@@ -57,8 +66,9 @@ export async function PATCH(request, { params }) {
await syncKeysToCloudIfEnabled();
return NextResponse.json({
message: "Permissions updated successfully",
message: "API key settings updated successfully",
allowedModels,
noLog,
});
} catch (error) {
console.log("Error updating key permissions:", error);

View File

@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createKeySchema, validateBody } from "@/shared/validation/schemas";
import { createKeySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/keys - List API keys
export async function GET() {
@@ -11,7 +12,7 @@ export async function GET() {
// Mask key values — users should never see full keys after creation
const maskedKeys = keys.map((k) => ({
...k,
key: k.key ? k.key.slice(0, 8) + "****" + k.key.slice(-4) : null,
key: typeof k.key === "string" ? k.key.slice(0, 8) + "****" + k.key.slice(-4) : null,
}));
return NextResponse.json({ keys: maskedKeys });
} catch (error) {
@@ -27,7 +28,7 @@ export async function POST(request) {
// Zod validation
const validation = validateBody(createKeySchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name } = validation.data;

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { queryAuditEntries } from "@omniroute/open-sse/mcp-server/audit";
function parseBooleanParam(value: string | null): boolean | undefined {
if (value === "true" || value === "1") return true;
if (value === "false" || value === "0") return false;
return undefined;
}
function parseNumberParam(value: string | null, fallback: number): number {
if (typeof value !== "string") return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseNumberParam(searchParams.get("limit"), 50);
const offset = parseNumberParam(searchParams.get("offset"), 0);
const tool = searchParams.get("tool") || undefined;
const success = parseBooleanParam(searchParams.get("success"));
const apiKeyId = searchParams.get("apiKeyId") || undefined;
const result = await queryAuditEntries({
limit,
offset,
tool,
success,
apiKeyId,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP audit log";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getAuditStats } from "@omniroute/open-sse/mcp-server/audit";
export async function GET() {
try {
const stats = await getAuditStats();
return NextResponse.json(stats);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP audit stats";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,41 @@
/**
* MCP SSE Transport — /api/mcp/sse
*
* Endpoints:
* GET — open SSE stream for bidirectional communication
* POST — send JSON-RPC messages to the MCP server
*/
import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/lib/db/settings";
import { handleMcpSSE } from "../../../../../open-sse/mcp-server/httpTransport";
async function guardEnabled(): Promise<NextResponse | null> {
const settings = await getSettings();
if (!settings.mcpEnabled) {
return NextResponse.json(
{ error: "MCP server is disabled. Enable it from the Endpoints page." },
{ status: 503 },
);
}
const transport = (settings.mcpTransport as string) || "stdio";
if (transport !== "sse") {
return NextResponse.json(
{ error: `MCP transport is set to "${transport}", not "sse". Change it from Settings.` },
{ status: 400 },
);
}
return null;
}
export async function GET(request: NextRequest) {
const blocked = await guardEnabled();
if (blocked) return blocked;
return handleMcpSSE(request);
}
export async function POST(request: NextRequest) {
const blocked = await guardEnabled();
if (blocked) return blocked;
return handleMcpSSE(request);
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { getAuditStats, queryAuditEntries } from "@omniroute/open-sse/mcp-server/audit";
import {
isMcpHeartbeatOnline,
isProcessAlive,
readMcpHeartbeat,
resolveMcpHeartbeatPath,
} from "@omniroute/open-sse/mcp-server/runtimeHeartbeat";
import { getMcpHttpStatus } from "../../../../../open-sse/mcp-server/httpTransport";
import { getSettings } from "@/lib/db/settings";
export async function GET() {
try {
const [heartbeat, stats, lastCallPage, settings] = await Promise.all([
readMcpHeartbeat(),
getAuditStats(),
queryAuditEntries({ limit: 1, offset: 0 }),
getSettings(),
]);
const mcpEnabled = !!settings.mcpEnabled;
const mcpTransport = (settings.mcpTransport as string) || "stdio";
// Check HTTP transport (SSE / Streamable HTTP) if active
const httpStatus = getMcpHttpStatus();
// stdio uses heartbeat file; HTTP transports use in-process state
const stdioOnline = isMcpHeartbeatOnline(heartbeat, { requireLivePid: true });
const online =
mcpTransport === "stdio" ? stdioOnline : httpStatus.online;
const lastCall = lastCallPage.entries[0] || null;
const now = Date.now();
const lastHeartbeatAtMs = heartbeat ? new Date(heartbeat.lastHeartbeatAt).getTime() : null;
const startedAtMs = heartbeat ? new Date(heartbeat.startedAt).getTime() : null;
const heartbeatAgeMs =
typeof lastHeartbeatAtMs === "number" && Number.isFinite(lastHeartbeatAtMs)
? Math.max(0, now - lastHeartbeatAtMs)
: null;
const uptimeMs =
typeof startedAtMs === "number" && Number.isFinite(startedAtMs)
? Math.max(0, now - startedAtMs)
: null;
return NextResponse.json({
status: online ? "online" : "offline",
online,
enabled: mcpEnabled,
transport: mcpTransport,
heartbeatPath: resolveMcpHeartbeatPath(),
heartbeat: heartbeat
? {
...heartbeat,
pidAlive: isProcessAlive(heartbeat.pid),
heartbeatAgeMs,
uptimeMs,
}
: null,
httpTransport: httpStatus,
activity: {
totalCalls24h: stats.totalCalls,
successRate: stats.successRate,
avgDurationMs: stats.avgDurationMs,
topTools: stats.topTools,
lastCallAt: lastCall?.createdAt || null,
lastCallTool: lastCall?.toolName || null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP status";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,48 @@
/**
* MCP Streamable HTTP Transport — /api/mcp/stream
*
* Endpoints:
* POST — send JSON-RPC messages to the MCP server
* GET — open SSE stream for server-initiated messages
* DELETE — end session
*/
import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/lib/db/settings";
import { handleMcpStreamableHTTP } from "../../../../../open-sse/mcp-server/httpTransport";
async function guardEnabled(): Promise<NextResponse | null> {
const settings = await getSettings();
if (!settings.mcpEnabled) {
return NextResponse.json(
{ error: "MCP server is disabled. Enable it from the Endpoints page." },
{ status: 503 },
);
}
const transport = (settings.mcpTransport as string) || "stdio";
if (transport !== "streamable-http") {
return NextResponse.json(
{ error: `MCP transport is set to "${transport}", not "streamable-http". Change it from Settings.` },
{ status: 400 },
);
}
return null;
}
export async function POST(request: NextRequest) {
const blocked = await guardEnabled();
if (blocked) return blocked;
return handleMcpStreamableHTTP(request);
}
export async function GET(request: NextRequest) {
const blocked = await guardEnabled();
if (blocked) return blocked;
return handleMcpStreamableHTTP(request);
}
export async function DELETE(request: NextRequest) {
const blocked = await guardEnabled();
if (blocked) return blocked;
return handleMcpStreamableHTTP(request);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { MCP_TOOLS, MCP_TOOL_MAP } from "@omniroute/open-sse/mcp-server/schemas/tools";
export async function GET() {
try {
return NextResponse.json({
total: MCP_TOOLS.length,
mappedTotal: Object.keys(MCP_TOOL_MAP).length,
tools: MCP_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
scopes: [...tool.scopes],
phase: tool.phase,
auditLevel: tool.auditLevel,
sourceEndpoints: [...tool.sourceEndpoints],
})),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP tools";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -3,6 +3,8 @@ import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { cloudModelAliasUpdateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/models/alias - Get all aliases
export async function GET(request) {
@@ -22,18 +24,32 @@ export async function GET(request) {
// PUT /api/models/alias - Set model alias
export async function PUT(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
const validation = validateBody(cloudModelAliasUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, alias } = validation.data;
await setModelAlias(alias, model);
await syncToCloudIfEnabled();

View File

@@ -4,6 +4,8 @@ import {
clearModelUnavailability,
getUnavailableCount,
} from "@/domain/modelAvailability";
import { clearModelAvailabilitySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
@@ -11,19 +13,38 @@ export async function GET() {
const count = getUnavailableCount();
return NextResponse.json({ unavailableCount: count, models: report });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error getting model availability:", error);
return NextResponse.json({ error: "Failed to get model availability" }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
const { provider, model } = await request.json();
if (!provider || !model) {
return NextResponse.json({ error: "provider and model are required" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(clearModelAvailabilitySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, model } = validation.data;
const removed = clearModelUnavailability(provider, model);
return NextResponse.json({ success: true, removed });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error clearing model availability:", error);
return NextResponse.json({ error: "Failed to clear model availability" }, { status: 500 });
}
}

View File

@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getModelAliases, setModelAlias, getProviderConnections } from "@/models";
import { AI_MODELS } from "@/shared/constants/config";
import { updateModelAliasSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/models - Get models with aliases (only from active providers by default)
export async function GET(request: Request) {
@@ -42,13 +44,27 @@ export async function GET(request: Request) {
// PUT /api/models - Update model alias
export async function PUT(request) {
let rawBody;
try {
const body = await request.json();
const { model, alias } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
try {
const validation = validateBody(updateModelAliasSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, alias } = validation.data;
const modelAliases = await getModelAliases();

View File

@@ -53,7 +53,7 @@ export async function GET() {
});
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({ status: "error", error: error.message }, { status: 500 });
return NextResponse.json({ status: "error", error: "Health check failed" }, { status: 500 });
}
}
@@ -82,6 +82,6 @@ export async function DELETE() {
});
} catch (error) {
console.error("[API] DELETE /api/monitoring/health error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: "Failed to reset circuit breakers" }, { status: 500 });
}
}

View File

@@ -6,12 +6,23 @@ import {
requestDeviceCode,
pollForToken,
} from "@/lib/oauth/providers";
import { createProviderConnection, updateProviderConnection, getProviderConnections, isCloudEnabled } from "@/models";
import {
createProviderConnection,
updateProviderConnection,
getProviderConnections,
isCloudEnabled,
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { getProxyConfig } from "@/lib/localDb";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import {
jsonObjectSchema,
oauthExchangeSchema,
oauthPollSchema,
} from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// Use globalThis to persist callback server state across Next.js HMR reloads
if (!globalThis.__codexCallbackState) {
@@ -152,15 +163,47 @@ export async function POST(
) {
try {
const { provider, action } = await params;
const body = await request.json();
let rawBody: any = {};
try {
rawBody = await request.json();
} catch {
if (action !== "poll-callback") {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
}
let body: any = rawBody;
if (action === "exchange") {
const validation = validateBody(oauthExchangeSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
body = validation.data;
} else if (action === "poll") {
const validation = validateBody(oauthPollSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
body = validation.data;
} else if (action === "poll-callback") {
const validation = validateBody(jsonObjectSchema, rawBody || {});
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
body = validation.data;
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state } = body;
if (!code || !redirectUri || !codeVerifier) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Resolve proxy for this provider (provider-level → global → direct)
const proxyConfig = await getProxyConfig();
const proxy = proxyConfig.providers?.[provider] || proxyConfig.global || null;
@@ -178,9 +221,12 @@ export async function POST(
let connection: any;
if (tokenData.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => c.email === tokenData.email && c.authType === "oauth");
if (match) {
connection = await updateProviderConnection(match.id, {
const match = existing.find(
(c: any) => c.email === tokenData.email && c.authType === "oauth"
);
const matchId = typeof match?.id === "string" ? match.id : null;
if (matchId) {
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
@@ -215,10 +261,6 @@ export async function POST(
if (action === "poll") {
const { deviceCode, codeVerifier, extraData } = body;
if (!deviceCode) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
// For providers that don't use PKCE (like GitHub, Kiro, Kimi Coding), don't pass codeVerifier
let result;
if (provider === "github" || provider === "kimi-coding" || provider === "kilocode") {
@@ -243,9 +285,12 @@ export async function POST(
let connection: any;
if (result.tokens.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => c.email === result.tokens.email && c.authType === "oauth");
if (match) {
connection = await updateProviderConnection(match.id, {
const match = existing.find(
(c: any) => c.email === result.tokens.email && c.authType === "oauth"
);
const matchId = typeof match?.id === "string" ? match.id : null;
if (matchId) {
connection = await updateProviderConnection(matchId, {
...result.tokens,
expiresAt,
testStatus: "active",
@@ -354,9 +399,12 @@ export async function POST(
let connection: any;
if (tokenData.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => c.email === tokenData.email && c.authType === "oauth");
if (match) {
connection = await updateProviderConnection(match.id, {
const match = existing.find(
(c: any) => c.email === tokenData.email && c.authType === "oauth"
);
const matchId = typeof match?.id === "string" ? match.id : null;
if (matchId) {
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",

View File

@@ -3,6 +3,8 @@ import { CursorService } from "@/lib/oauth/services/cursor";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { cursorImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/oauth/cursor/import
@@ -13,16 +15,27 @@ import { syncToCloud } from "@/lib/cloudSync";
* - machineId: string - Machine ID from storage.serviceMachineId
*/
export async function POST(request: any) {
let rawBody;
try {
const { accessToken, machineId } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!accessToken || typeof accessToken !== "string") {
return NextResponse.json({ error: "Access token is required" }, { status: 400 });
}
if (!machineId || typeof machineId !== "string") {
return NextResponse.json({ error: "Machine ID is required" }, { status: 400 });
try {
const validation = validateBody(cursorImportSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { accessToken, machineId } = validation.data;
const cursorService = new CursorService();

View File

@@ -3,18 +3,35 @@ import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { kiroImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/oauth/kiro/import
* Import and validate refresh token from Kiro IDE
*/
export async function POST(request: any) {
let rawBody;
try {
const { refreshToken } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!refreshToken || typeof refreshToken !== "string") {
return NextResponse.json({ error: "Refresh token is required" }, { status: 400 });
try {
const validation = validateBody(kiroImportSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { refreshToken } = validation.data;
const kiroService = new KiroService();

View File

@@ -3,23 +3,36 @@ import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { kiroSocialExchangeSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/oauth/kiro/social-exchange
* Exchange authorization code for tokens (Google/GitHub social login)
* Callback URL will be in format: kiro://kiro.kiroAgent/authenticate-success?code=XXX&state=YYY
*/
export async function POST(request: any) {
export async function POST(request: Request) {
let rawBody;
try {
const { code, codeVerifier, provider } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!code || !codeVerifier) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (!provider || !["google", "github"].includes(provider)) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
try {
const validation = validateBody(kiroSocialExchangeSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { code, codeVerifier, provider } = validation.data;
const kiroService = new KiroService();

View File

@@ -1,11 +1,8 @@
import { NextResponse } from "next/server";
import {
getAllCircuitBreakerStatuses,
} from "@/shared/utils/circuitBreaker";
import {
getLockedIdentifiers,
forceUnlock,
} from "@/domain/lockoutPolicy";
import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker";
import { getLockedIdentifiers, forceUnlock } from "@/domain/lockoutPolicy";
import { policyActionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
@@ -13,13 +10,33 @@ export async function GET() {
const lockedIdentifiers = getLockedIdentifiers();
return NextResponse.json({ circuitBreakers, lockedIdentifiers });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error loading policies:", error);
return NextResponse.json({ error: "Failed to load policies" }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
const { action, identifier } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(policyActionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { action, identifier } = validation.data;
if (action === "unlock" && identifier) {
forceUnlock(identifier);
@@ -28,6 +45,7 @@ export async function POST(request) {
return NextResponse.json({ error: "Unknown action. Supported: unlock" }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating policies:", error);
return NextResponse.json({ error: "Failed to update policies" }, { status: 500 });
}
}

View File

@@ -2,6 +2,20 @@ import { NextResponse } from "next/server";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getAllCustomModels, getPricing } from "@/lib/localDb";
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asModelArray(value: unknown): Array<{ id?: string; name?: string }> {
if (!Array.isArray(value)) return [];
return value.filter((item) => item && typeof item === "object") as Array<{
id?: string;
name?: string;
}>;
}
/**
* GET /api/pricing/models
* Returns the full model catalog merged from three sources:
@@ -33,14 +47,15 @@ export async function GET() {
}
// ── 2. Custom models (DB) ───────────────────────────────────────
let customModelsMap: Record<string, any[]> = {};
let customModelsMap: Record<string, unknown> = {};
try {
customModelsMap = await getAllCustomModels();
customModelsMap = asRecord(await getAllCustomModels());
} catch {
/* DB may not be ready */
}
for (const [providerId, models] of Object.entries(customModelsMap)) {
for (const [providerId, rawModels] of Object.entries(customModelsMap)) {
const models = asModelArray(rawModels);
// Resolve alias — check if a registry entry maps this providerId
let alias = providerId;
for (const entry of Object.values(REGISTRY)) {
@@ -63,13 +78,17 @@ export async function GET() {
const existingIds = new Set(catalog[alias].models.map((m) => m.id));
for (const model of models) {
if (!existingIds.has(model.id)) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId || existingIds.has(modelId)) {
continue;
}
if (!existingIds.has(modelId)) {
catalog[alias].models.push({
id: model.id,
name: model.name || model.id,
id: modelId,
name: typeof model.name === "string" && model.name.trim() ? model.name : modelId,
custom: true,
});
existingIds.add(model.id);
existingIds.add(modelId);
}
}
}

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb";
import { updatePricingSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/pricing
@@ -21,51 +23,27 @@ export async function GET() {
* Body: { provider: { model: { input: number, output: number, cached: number, ... } } }
*/
export async function PATCH(request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
// Validate body structure
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Invalid pricing data format" }, { status: 400 });
}
// Validate pricing structure
for (const [provider, models] of Object.entries(body)) {
if (typeof models !== "object" || models === null) {
return NextResponse.json(
{ error: `Invalid pricing for provider: ${provider}` },
{ status: 400 }
);
}
for (const [model, pricing] of Object.entries(models)) {
if (typeof pricing !== "object" || pricing === null) {
return NextResponse.json(
{ error: `Invalid pricing for model: ${provider}/${model}` },
{ status: 400 }
);
}
// Validate pricing fields
const validFields = ["input", "output", "cached", "reasoning", "cache_creation"];
for (const [key, value] of Object.entries(pricing)) {
if (!validFields.includes(key)) {
return NextResponse.json(
{ error: `Invalid pricing field: ${key} for ${provider}/${model}` },
{ status: 400 }
);
}
if (typeof value !== "number" || isNaN(value) || value < 0) {
return NextResponse.json(
{
error: `Invalid pricing value for ${key} in ${provider}/${model}: must be non-negative number`,
},
{ status: 400 }
);
}
}
}
try {
const validation = validateBody(updatePricingSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const updatedPricing = await updatePricing(body);
return NextResponse.json(updatedPricing);

View File

@@ -1,6 +1,17 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
type JsonRecord = Record<string, unknown>;
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
/**
* GET /api/providers/metrics — Aggregate per-provider stats from call_logs
* Returns: { metrics: { [provider]: { totalRequests, totalSuccesses, successRate, avgLatencyMs } } }
@@ -19,16 +30,30 @@ export async function GET() {
WHERE provider IS NOT NULL AND provider != '-'
GROUP BY provider`
)
.all();
.all() as JsonRecord[];
const metrics = {};
const metrics: Record<
string,
{
totalRequests: number;
totalSuccesses: number;
successRate: number;
avgLatencyMs: number;
}
> = {};
for (const row of rows) {
metrics[row.provider] = {
totalRequests: row.totalRequests,
totalSuccesses: row.totalSuccesses,
successRate:
row.totalRequests > 0 ? Math.round((row.totalSuccesses / row.totalRequests) * 100) : 0,
avgLatencyMs: row.avgLatencyMs || 0,
const provider =
typeof row.provider === "string" && row.provider.trim().length > 0
? row.provider
: "unknown";
const totalRequests = toNumber(row.totalRequests);
const totalSuccesses = toNumber(row.totalSuccesses);
const avgLatencyMs = toNumber(row.avgLatencyMs);
metrics[provider] = {
totalRequests,
totalSuccesses,
successRate: totalRequests > 0 ? Math.round((totalSuccesses / totalRequests) * 100) : 0,
avgLatencyMs,
};
}

View File

@@ -5,6 +5,8 @@ import {
removeCustomModel,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/provider-models?provider=<id>
@@ -39,6 +41,16 @@ export async function GET(request) {
* Body: { provider, modelId, modelName? }
*/
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "validation_error" } },
{ status: 400 }
);
}
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
@@ -48,21 +60,18 @@ export async function POST(request) {
);
}
const body = await request.json();
const { provider, modelId, modelName, source } = body;
if (!provider || !modelId) {
return Response.json(
{ error: { message: "provider and modelId are required", type: "validation_error" } },
{ status: 400 }
);
const validation = validateBody(providerModelMutationSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, source } = validation.data;
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
return Response.json({ model });
} catch (error) {
console.error("Error adding provider model:", error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: "Failed to add provider model", type: "server_error" } },
{ status: 500 }
);
}
@@ -100,8 +109,9 @@ export async function DELETE(request) {
const removed = await removeCustomModel(provider, modelId);
return Response.json({ removed });
} catch (error) {
console.error("Error removing provider model:", error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: "Failed to remove provider model", type: "server_error" } },
{ status: 500 }
);
}

View File

@@ -7,27 +7,45 @@ import {
updateProviderConnection,
updateProviderNode,
} from "@/models";
import { updateProviderNodeSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
// PUT /api/provider-nodes/[id] - Update provider node
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { id } = await params;
const body = await request.json();
const { name, prefix, apiType, baseUrl } = body;
const validation = validateBody(updateProviderNodeSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, prefix, apiType, baseUrl } = validation.data;
const node: any = await getProviderNodeById(id);
if (!node) {
return NextResponse.json({ error: "Provider node not found" }, { status: 404 });
}
if (!name?.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (!prefix?.trim()) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
}
// Only validate apiType for OpenAI Compatible nodes
if (
node.type === "openai-compatible" &&
@@ -36,10 +54,6 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
}
if (!baseUrl?.trim()) {
return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
}
let sanitizedBaseUrl = baseUrl.trim();
// Sanitize Base URL for Anthropic Compatible
@@ -50,7 +64,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
}
const updates: Record<string, any> = {
const updates: Record<string, unknown> = {
name: name.trim(),
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
@@ -64,17 +78,27 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
const connections = await getProviderConnections({ provider: id });
await Promise.all(
connections.map((connection) =>
updateProviderConnection(connection.id, {
providerSpecificData: {
...(connection.providerSpecificData || {}),
prefix: prefix.trim(),
apiType: node.type === "openai-compatible" ? apiType : undefined,
baseUrl: sanitizedBaseUrl,
nodeName: updated.name,
},
})
)
connections.flatMap((connectionRaw) => {
const connection = asRecord(connectionRaw);
const connectionId = typeof connection.id === "string" ? connection.id : "";
if (!connectionId) return [];
const providerSpecificData = {
...asRecord(connection.providerSpecificData),
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
nodeName: updated.name,
} as JsonRecord;
if (node.type === "openai-compatible") {
providerSpecificData.apiType = apiType;
}
return [
updateProviderConnection(connectionId, {
providerSpecificData,
}),
];
})
);
return NextResponse.json({ node: updated });

View File

@@ -5,6 +5,8 @@ import {
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { generateId } from "@/shared/utils";
import { createProviderNodeSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
@@ -27,26 +29,32 @@ export async function GET() {
// POST /api/provider-nodes - Create provider node
export async function POST(request) {
let rawBody;
try {
const body = await request.json();
const { name, prefix, apiType, baseUrl, type } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!name?.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (!prefix?.trim()) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
try {
const validation = validateBody(createProviderNodeSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, prefix, apiType, baseUrl, type } = validation.data;
// Determine type
const nodeType = type || "openai-compatible";
if (nodeType === "openai-compatible") {
if (!apiType || !["chat", "responses"].includes(apiType)) {
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
}
const node = await createProviderNode({
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
type: "openai-compatible",

View File

@@ -1,14 +1,30 @@
import { NextResponse } from "next/server";
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// POST /api/provider-nodes/validate - Validate API key against base URL
export async function POST(request) {
let rawBody;
try {
const body = await request.json();
const { baseUrl, apiKey, type } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 });
try {
const validation = validateBody(providerNodeValidateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, type } = validation.data;
// Anthropic Compatible Validation
if (type === "anthropic-compatible") {

View File

@@ -5,6 +5,29 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function getProviderBaseUrl(providerSpecificData: unknown): string | null {
const data = asRecord(providerSpecificData);
const baseUrl = data.baseUrl;
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null;
}
type ProviderModelsConfigEntry = {
url: string;
method: "GET" | "POST";
headers: Record<string, string>;
authHeader?: string;
authPrefix?: string;
authQuery?: string;
body?: unknown;
parseResponse: (data: any) => any;
};
// Providers that return hardcoded models (no remote /models API)
const STATIC_MODEL_PROVIDERS = {
deepgram: () => [
@@ -33,7 +56,7 @@ const STATIC_MODEL_PROVIDERS = {
};
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = {
const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
claude: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
@@ -238,8 +261,20 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
if (isOpenAICompatibleProvider(connection.provider)) {
const baseUrl = connection.providerSpecificData?.baseUrl;
const provider =
typeof connection.provider === "string" && connection.provider.trim().length > 0
? connection.provider
: null;
if (!provider) {
return NextResponse.json({ error: "Invalid connection provider" }, { status: 400 });
}
const connectionId = typeof connection.id === "string" ? connection.id : id;
const apiKey = typeof connection.apiKey === "string" ? connection.apiKey : "";
const accessToken = typeof connection.accessToken === "string" ? connection.accessToken : "";
if (isOpenAICompatibleProvider(provider)) {
const baseUrl = getProviderBaseUrl(connection.providerSpecificData);
if (!baseUrl) {
return NextResponse.json(
{ error: "No base URL configured for OpenAI compatible provider" },
@@ -260,13 +295,13 @@ export async function GET(request, { params }) {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${connection.apiKey}`,
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
console.log(`Error fetching models from ${provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
@@ -277,14 +312,14 @@ export async function GET(request, { params }) {
const models = data.data || data.models || [];
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
provider,
connectionId,
models,
});
}
if (isAnthropicCompatibleProvider(connection.provider)) {
let baseUrl = connection.providerSpecificData?.baseUrl;
if (isAnthropicCompatibleProvider(provider)) {
let baseUrl = getProviderBaseUrl(connection.providerSpecificData);
if (!baseUrl) {
return NextResponse.json(
{ error: "No base URL configured for Anthropic compatible provider" },
@@ -302,15 +337,15 @@ export async function GET(request, { params }) {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-api-key": connection.apiKey,
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Authorization: `Bearer ${connection.apiKey}`,
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
console.log(`Error fetching models from ${provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
@@ -321,32 +356,38 @@ export async function GET(request, { params }) {
const models = data.data || data.models || [];
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
provider,
connectionId,
models,
});
}
// Static model providers (no remote /models API)
const staticModelsFn = STATIC_MODEL_PROVIDERS[connection.provider];
const staticModelsFn =
provider in STATIC_MODEL_PROVIDERS
? STATIC_MODEL_PROVIDERS[provider as keyof typeof STATIC_MODEL_PROVIDERS]
: undefined;
if (staticModelsFn) {
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
provider,
connectionId,
models: staticModelsFn(),
});
}
const config = PROVIDER_MODELS_CONFIG[connection.provider];
const config =
provider in PROVIDER_MODELS_CONFIG
? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG]
: undefined;
if (!config) {
return NextResponse.json(
{ error: `Provider ${connection.provider} does not support models listing` },
{ error: `Provider ${provider} does not support models listing` },
{ status: 400 }
);
}
// Get auth token
const token = connection.accessToken || connection.apiKey;
const token = accessToken || apiKey;
if (!token) {
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
}
@@ -377,7 +418,7 @@ export async function GET(request, { params }) {
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
console.log(`Error fetching models from ${provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
@@ -388,8 +429,8 @@ export async function GET(request, { params }) {
const models = config.parseResponse(data);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
provider,
connectionId,
models,
});
} catch (error) {

View File

@@ -7,6 +7,8 @@ import {
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/providers/[id] - Get single connection
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -34,9 +36,28 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// PUT /api/providers/[id] - Update connection
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { id } = await params;
const body = await request.json();
const validation = validateBody(updateProviderConnectionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const {
name,
priority,

View File

@@ -531,6 +531,21 @@ export async function testSingleConnection(connectionId: string) {
return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 };
}
const provider = typeof connection.provider === "string" ? connection.provider : "";
if (!provider) {
return {
valid: false,
error: "Connection provider is invalid",
diagnosis: makeDiagnosis(
"validation_error",
"local",
"Connection provider is invalid",
"provider_invalid"
),
latencyMs: 0,
};
}
// Resolve proxy for this connection (key → combo → provider → global → direct)
let proxyInfo: any = null;
try {
@@ -541,7 +556,7 @@ export async function testSingleConnection(connectionId: string) {
let result;
const startTime = Date.now();
const runtime = await getProviderRuntimeStatus(connection.provider);
const runtime = await getProviderRuntimeStatus(provider);
if ((runtime as any)?.diagnosis) {
result = {
@@ -611,7 +626,7 @@ export async function testSingleConnection(connectionId: string) {
path: "/api/providers/test",
status: result.valid ? 200 : result.statusCode || 401,
model: "connection-test",
provider: connection.provider,
provider,
connectionId,
duration: latencyMs,
error: result.valid ? null : result.error || null,
@@ -627,8 +642,8 @@ export async function testSingleConnection(connectionId: string) {
proxy: proxyInfo?.proxy || null,
level: proxyInfo?.level || "provider-test",
levelId: proxyInfo?.levelId || null,
provider: connection.provider,
targetUrl: `${connection.provider}/connection-test`,
provider,
targetUrl: `${provider}/connection-test`,
latencyMs,
error: result.valid ? null : result.error || null,
connectionId,

View File

@@ -12,7 +12,8 @@ import {
} from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createProviderSchema, validateBody } from "@/shared/validation/schemas";
import { createProviderSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// GET /api/providers - List all connections
export async function GET() {
@@ -42,7 +43,7 @@ export async function POST(request: Request) {
// Zod validation
const validation = validateBody(createProviderSchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } =

View File

@@ -8,6 +8,8 @@ import {
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { testSingleConnection } from "../[id]/test/route";
import { providersBatchTestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// Determine auth type group for a provider id
function getAuthGroup(providerId) {
@@ -33,13 +35,27 @@ function isCompatibleProvider(providerId) {
// POST /api/providers/test-batch - Test multiple connections by group
export async function POST(request) {
let rawBody;
try {
const body = await request.json();
const { mode, providerId } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!mode) {
return NextResponse.json({ error: "mode is required" }, { status: 400 });
try {
const validation = validateBody(providersBatchTestSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { mode, providerId } = validation.data;
// Fetch all active connections
const allConnections = await getProviderConnections({ isActive: true });

View File

@@ -5,16 +5,32 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { validateProviderApiKeySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
let rawBody;
try {
const body = await request.json();
const { provider, apiKey } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!provider || !apiKey) {
return NextResponse.json({ error: "Provider and API key required" }, { status: 400 });
try {
const validation = validateBody(validateProviderApiKeySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey } = validation.data;
let providerSpecificData = {};

View File

@@ -8,6 +8,14 @@ import {
getRateLimitStatus,
getAllRateLimitStatus,
} from "@omniroute/open-sse/services/rateLimitManager.ts";
import { toggleRateLimitSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
/**
* GET /api/rate-limits — Consolidated rate-limit status
@@ -21,13 +29,23 @@ import {
export async function GET() {
try {
const connections = await getProviderConnections();
const statuses = connections.map((conn) => ({
connectionId: conn.id,
provider: conn.provider,
name: conn.name || conn.email || conn.id.slice(0, 8),
rateLimitProtection: !!conn.rateLimitProtection,
...getRateLimitStatus(conn.provider, conn.id),
}));
const statuses = connections.map((connRaw) => {
const conn = asRecord(connRaw);
const connectionId = typeof conn.id === "string" ? conn.id : "";
const provider = typeof conn.provider === "string" ? conn.provider : "unknown";
const name =
(typeof conn.name === "string" && conn.name.trim()) ||
(typeof conn.email === "string" && conn.email.trim()) ||
(connectionId ? connectionId.slice(0, 8) : "unknown");
return {
connectionId,
provider,
name,
rateLimitProtection: conn.rateLimitProtection === true,
...getRateLimitStatus(provider, connectionId),
};
});
const lockouts = getAllModelLockouts();
const cacheStats = getCacheStats();
@@ -49,12 +67,27 @@ export async function GET() {
* Body: { connectionId: string, enabled: boolean }
*/
export async function POST(request) {
let rawBody;
try {
const { connectionId, enabled } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!connectionId) {
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
try {
const validation = validateBody(toggleRateLimitSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { connectionId, enabled } = validation.data;
// Update in-memory state
if (enabled) {

View File

@@ -1,5 +1,17 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { updateResilienceSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
/**
* GET /api/resilience — Get current resilience configuration and status
@@ -19,14 +31,17 @@ export async function GET() {
return NextResponse.json({
profiles: settings.providerProfiles || PROVIDER_PROFILES,
defaults: { ...DEFAULT_API_LIMITS, ...(settings.rateLimitDefaults || {}) },
defaults: {
...DEFAULT_API_LIMITS,
...asRecord(settings.rateLimitDefaults),
},
circuitBreakers,
rateLimitStatus,
});
} catch (err) {
} catch (err: unknown) {
console.error("[API] GET /api/resilience error:", err);
return NextResponse.json(
{ error: err.message || "Failed to load resilience status" },
{ error: getErrorMessage(err, "Failed to load resilience status") },
{ status: 500 }
);
}
@@ -36,59 +51,27 @@ export async function GET() {
* PATCH /api/resilience — Update provider resilience profiles and/or rate limit defaults
*/
export async function PATCH(request) {
let rawBody;
try {
const body = await request.json();
const { profiles, defaults } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!profiles && !defaults) {
return NextResponse.json({ error: "Must provide profiles or defaults" }, { status: 400 });
}
// Validate profiles if provided
if (profiles) {
if (typeof profiles !== "object") {
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
}
for (const [key, profile] of Object.entries(profiles)) {
if (!["oauth", "apikey"].includes(key)) {
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
}
const required = [
"transientCooldown",
"rateLimitCooldown",
"maxBackoffLevel",
"circuitBreakerThreshold",
"circuitBreakerReset",
];
for (const field of required) {
if (typeof profile[field] !== "number" || profile[field] < 0) {
return NextResponse.json(
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
{ status: 400 }
);
}
}
}
}
// Validate defaults if provided
if (defaults) {
if (typeof defaults !== "object") {
return NextResponse.json({ error: "Invalid defaults payload" }, { status: 400 });
}
const validKeys = ["requestsPerMinute", "minTimeBetweenRequests", "concurrentRequests"];
for (const key of validKeys) {
if (
defaults[key] !== undefined &&
(typeof defaults[key] !== "number" || defaults[key] < 1)
) {
return NextResponse.json(
{ error: `Invalid defaults.${key}: must be a positive number` },
{ status: 400 }
);
}
}
try {
const validation = validateBody(updateResilienceSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { profiles, defaults } = validation.data;
const updates: Record<string, any> = {};
if (profiles) updates.providerProfiles = profiles;
@@ -101,10 +84,10 @@ export async function PATCH(request) {
...(profiles ? { profiles } : {}),
...(defaults ? { defaults } : {}),
});
} catch (err) {
} catch (err: unknown) {
console.error("[API] PATCH /api/resilience error:", err);
return NextResponse.json(
{ error: err.message || "Failed to save resilience settings" },
{ error: getErrorMessage(err, "Failed to save resilience settings") },
{ status: 500 }
);
}

View File

@@ -5,6 +5,8 @@ import {
resetStats,
} from "@omniroute/open-sse/services/backgroundTaskDetector.ts";
import { updateSettings } from "@/lib/db/settings";
import { jsonObjectSchema, resetStatsActionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/settings/background-degradation
@@ -25,8 +27,28 @@ export async function GET() {
* Body: { enabled?: boolean, degradationMap?: {...}, detectionPatterns?: [...] }
*/
export async function PUT(request) {
let rawBody;
try {
const config = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(jsonObjectSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const config = validation.data;
setBackgroundDegradationConfig(config);
// Persist to database (excluding stats)
@@ -46,8 +68,28 @@ export async function PUT(request) {
* Body: { action: "reset-stats" }
*/
export async function POST(request) {
let rawBody;
try {
const { action } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(resetStatsActionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { action } = validation.data;
if (action === "reset-stats") {
resetStats();
return NextResponse.json({ success: true, stats: getBackgroundDegradationConfig().stats });

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { updateComboDefaultsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/settings/combo-defaults
@@ -33,8 +35,28 @@ export async function GET() {
* Body: { comboDefaults?: {...}, providerOverrides?: {...} }
*/
export async function PATCH(request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateComboDefaultsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const updates: Record<string, any> = {};
if (body.comboDefaults) {
@@ -44,10 +66,6 @@ export async function PATCH(request) {
updates.providerOverrides = body.providerOverrides;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
const settings: any = await updateSettings(updates);
return NextResponse.json({
comboDefaults: settings.comboDefaults || {},

View File

@@ -9,18 +9,40 @@ import {
tempBanIP,
removeTempBan,
} from "@omniroute/open-sse/services/ipFilter.ts";
import { updateIpFilterSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
return NextResponse.json(getIPFilterConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error getting IP filter config:", error);
return NextResponse.json({ error: "Failed to get IP filter config" }, { status: 500 });
}
}
export async function PUT(request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateIpFilterSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
// Configure entire filter
if (body.enabled !== undefined || body.mode || body.blacklist || body.whitelist) {
@@ -35,12 +57,17 @@ export async function PUT(request) {
// Temp bans
if (body.tempBan) {
tempBanIP(body.tempBan.ip, body.tempBan.durationMs || 3600000, body.tempBan.reason || "Manual ban");
tempBanIP(
body.tempBan.ip,
body.tempBan.durationMs || 3600000,
body.tempBan.reason || "Manual ban"
);
}
if (body.removeBan) removeTempBan(body.removeBan);
return NextResponse.json(getIPFilterConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating IP filter config:", error);
return NextResponse.json({ error: "Failed to update IP filter config" }, { status: 500 });
}
}

View File

@@ -8,6 +8,12 @@ import {
removeCustomAlias,
} from "@omniroute/open-sse/services/modelDeprecation.ts";
import { getSettings, updateSettings } from "@/lib/db/settings";
import {
addModelAliasSchema,
removeModelAliasSchema,
updateModelAliasesSchema,
} from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/settings/model-aliases
@@ -32,11 +38,27 @@ export async function GET() {
* Body: { aliases: { "old-model": "new-model", ... } }
*/
export async function PUT(request) {
let rawBody;
try {
const { aliases } = await request.json();
if (!aliases || typeof aliases !== "object") {
return NextResponse.json({ error: "Missing or invalid 'aliases' object" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateModelAliasesSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { aliases } = validation.data;
setCustomAliases(aliases);
await updateSettings({ modelAliases: JSON.stringify(aliases) });
return NextResponse.json({ success: true, custom: getCustomAliases() });
@@ -52,11 +74,27 @@ export async function PUT(request) {
* Body: { from: "old-model", to: "new-model" }
*/
export async function POST(request) {
let rawBody;
try {
const { from, to } = await request.json();
if (!from || !to) {
return NextResponse.json({ error: "Missing 'from' or 'to'" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(addModelAliasSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { from, to } = validation.data;
addCustomAlias(from, to);
await updateSettings({ modelAliases: JSON.stringify(getCustomAliases()) });
return NextResponse.json({ success: true, custom: getCustomAliases() });
@@ -72,11 +110,27 @@ export async function POST(request) {
* Body: { from: "old-model" }
*/
export async function DELETE(request) {
let rawBody;
try {
const { from } = await request.json();
if (!from) {
return NextResponse.json({ error: "Missing 'from'" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(removeModelAliasSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { from } = validation.data;
const removed = removeCustomAlias(from);
if (!removed) {
return NextResponse.json({ error: "Alias not found" }, { status: 404 });

View File

@@ -6,8 +6,15 @@ import {
resolveProxyForConnection,
} from "../../../../lib/localDb";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import type { z } from "zod";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
type UpdateProxyConfigInput = z.infer<typeof updateProxyConfigSchema>;
type ProxyConfigInput = NonNullable<UpdateProxyConfigInput["proxy"]>;
type ProxyMapInput = Record<string, ProxyConfigInput | null>;
type ApiRouteError = Error & { status?: number; type?: string };
function isSocks5Enabled() {
return process.env.ENABLE_SOCKS5_PROXY === "true";
@@ -24,20 +31,30 @@ function supportedTypesMessage() {
return isSocks5Enabled() ? "http, https, or socks5" : "http or https";
}
function createInvalidProxyError(message: string) {
const error: any = new Error(message);
function createInvalidProxyError(message: string): ApiRouteError {
const error = new Error(message) as ApiRouteError;
error.status = 400;
error.type = "invalid_request";
return error;
}
function normalizeAndValidateProxy(proxy, pathLabel) {
function toApiRouteError(error: unknown): ApiRouteError {
if (error instanceof Error) {
return error as ApiRouteError;
}
return new Error("Unexpected error") as ApiRouteError;
}
function normalizeAndValidateProxy(
proxy: ProxyConfigInput | null | undefined,
pathLabel: string
): ProxyConfigInput | null | undefined {
if (proxy === null || proxy === undefined) return proxy;
if (typeof proxy !== "object" || Array.isArray(proxy)) {
throw createInvalidProxyError(`${pathLabel} must be an object`);
}
const type = String(proxy.type || "http").toLowerCase();
const type = String(proxy.type || "http").toLowerCase() as NonNullable<ProxyConfigInput["type"]>;
if (type === "socks5" && !isSocks5Enabled()) {
throw createInvalidProxyError(
"SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
@@ -50,23 +67,27 @@ function normalizeAndValidateProxy(proxy, pathLabel) {
throw createInvalidProxyError(`${pathLabel}.type must be ${supportedTypesMessage()}`);
}
return { ...proxy, type };
return { ...proxy, type } as ProxyConfigInput;
}
function normalizeAndValidateProxyMap(proxyMap, mapName) {
function normalizeAndValidateProxyMap(
proxyMap: ProxyMapInput | undefined,
mapName: string
): ProxyMapInput | undefined {
if (proxyMap === undefined) return undefined;
if (proxyMap === null || typeof proxyMap !== "object" || Array.isArray(proxyMap)) {
throw createInvalidProxyError(`${mapName} must be an object`);
}
const normalizedMap = { ...proxyMap };
for (const [id, proxy] of Object.entries(proxyMap)) {
normalizedMap[id] = normalizeAndValidateProxy(proxy, `${mapName}.${id}`);
const normalizedMap: ProxyMapInput = { ...proxyMap };
for (const [id, proxy] of Object.entries(proxyMap) as Array<[string, ProxyConfigInput | null]>) {
const normalizedProxy = normalizeAndValidateProxy(proxy, `${mapName}.${id}`);
normalizedMap[id] = normalizedProxy ?? null;
}
return normalizedMap;
}
function normalizeProxyPayload(body) {
function normalizeProxyPayload(body: UpdateProxyConfigInput): UpdateProxyConfigInput {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw createInvalidProxyError("Request body must be an object");
}
@@ -91,7 +112,7 @@ function normalizeProxyPayload(body) {
* Optional query params: ?level=global|provider|combo|key&id=xxx
* Or: ?resolve=connectionId to resolve effective proxy
*/
export async function GET(request) {
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const level = searchParams.get("level");
@@ -114,8 +135,9 @@ export async function GET(request) {
const config = await getProxyConfig();
return Response.json(config);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
}
@@ -125,17 +147,41 @@ export async function GET(request) {
* PUT /api/settings/proxy — update proxy configuration
* Body: { level, id?, proxy } or legacy { global?, providers? }
*/
export async function PUT(request) {
export async function PUT(request: Request) {
let rawBody: unknown;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
}
try {
const validation = validateBody(updateProxyConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
}
const body = validation.data;
const normalizedBody = normalizeProxyPayload(body);
const updated = await setProxyConfig(normalizedBody);
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const status = Number(error?.status) || 500;
const type = error?.type || (status === 400 ? "invalid_request" : "server_error");
return Response.json({ error: { message: error.message, type } }, { status });
const routeError = toApiRouteError(error);
const status = Number(routeError.status) || 500;
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
return Response.json({ error: { message: routeError.message, type } }, { status });
}
}
@@ -143,7 +189,7 @@ export async function PUT(request) {
* DELETE /api/settings/proxy — remove proxy at a level
* Query: ?level=provider&id=xxx
*/
export async function DELETE(request) {
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const level = searchParams.get("level");
@@ -160,8 +206,9 @@ export async function DELETE(request) {
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
}

View File

@@ -5,9 +5,18 @@ import {
proxyConfigToUrl,
proxyUrlForLogs,
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
import { testProxySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
function getErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
function getSupportedProxyTypes() {
if (isSocks5ProxyEnabled()) {
return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]);
@@ -24,16 +33,32 @@ function supportedTypesMessage() {
* Body: { proxy: { type, host, port, username?, password? } }
* Returns: { success, publicIp?, latencyMs?, error? }
*/
export async function POST(request) {
export async function POST(request: Request) {
let rawBody: unknown;
try {
const { proxy } = await request.json();
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
}
if (!proxy || !proxy.host || !proxy.port) {
try {
const validation = validateBody(testProxySchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{ error: { message: "proxy.host and proxy.port are required", type: "invalid_request" } },
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
}
const { proxy } = validation.data;
const proxyType = String(proxy.type || "http").toLowerCase();
if (proxyType === "socks5" && !isSocks5ProxyEnabled()) {
@@ -70,9 +95,9 @@ export async function POST(request) {
);
}
let proxyUrl;
let proxyUrl: string;
try {
proxyUrl = proxyConfigToUrl(
const normalizedProxyUrl = proxyConfigToUrl(
{
type: proxyType,
host: proxy.host,
@@ -82,11 +107,23 @@ export async function POST(request) {
},
{ allowSocks5: isSocks5ProxyEnabled() }
);
if (!normalizedProxyUrl) {
return Response.json(
{
error: {
message: "Invalid proxy configuration",
type: "invalid_request",
},
},
{ status: 400 }
);
}
proxyUrl = normalizedProxyUrl;
} catch (proxyError) {
return Response.json(
{
error: {
message: proxyError.message || "Invalid proxy configuration",
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
type: "invalid_request",
},
},
@@ -110,12 +147,17 @@ export async function POST(request) {
bodyTimeout: 10000,
});
const rawBody = await result.body.text();
let parsed;
const responseText = await result.body.text();
let parsed: { ip?: string };
try {
parsed = JSON.parse(rawBody);
const parsedJson = JSON.parse(responseText);
if (parsedJson && typeof parsedJson === "object") {
parsed = parsedJson as { ip?: string };
} else {
parsed = { ip: String(parsedJson) };
}
} catch {
parsed = { ip: rawBody.trim() };
parsed = { ip: responseText.trim() };
}
return Response.json({
@@ -128,9 +170,9 @@ export async function POST(request) {
return Response.json({
success: false,
error:
fetchError.name === "AbortError"
fetchError instanceof Error && fetchError.name === "AbortError"
? "Connection timeout (10s)"
: fetchError.message || "Connection failed",
: getErrorMessage(fetchError, "Connection failed"),
latencyMs: Date.now() - startTime,
proxyUrl: publicProxyUrl,
});
@@ -138,9 +180,7 @@ export async function POST(request) {
clearTimeout(timeout);
}
} catch (error) {
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ status: 500 }
);
const message = getErrorMessage(error, "Unexpected server error");
return Response.json({ error: { message, type: "server_error" } }, { status: 500 });
}
}

View File

@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import { updateRequireLoginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
@@ -17,8 +19,27 @@ export async function GET() {
* Used by the onboarding wizard security step.
*/
export async function POST(request: Request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateRequireLoginSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const { requireLogin, password } = body;
const updates: Record<string, any> = {};
@@ -27,18 +48,9 @@ export async function POST(request: Request) {
updates.requireLogin = requireLogin;
}
if (password && typeof password === "string" && password.length >= 4) {
if (password) {
const hashedPassword = await bcrypt.hash(password, 12);
updates.password = hashedPassword;
} else if (password) {
return NextResponse.json(
{ error: "Password must be at least 4 characters" },
{ status: 400 }
);
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid fields to update" }, { status: 400 });
}
await updateSettings(updates);

View File

@@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import bcrypt from "bcryptjs";
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
@@ -23,7 +24,7 @@ export async function GET() {
});
} catch (error) {
console.log("Error getting settings:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: "Failed to load settings" }, { status: 500 });
}
}
@@ -33,15 +34,15 @@ export async function PATCH(request) {
// Zod validation
const validation = validateBody(updateSettingsSchema, rawBody);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
const body: typeof validation.data & { password?: string } = { ...validation.data };
// If updating password, hash it
if (body.newPassword) {
const settings = await getSettings();
const currentHash = settings.password;
const currentHash = typeof settings.password === "string" ? settings.password : "";
// Verify current password if it exists
if (currentHash) {
@@ -77,6 +78,6 @@ export async function PATCH(request) {
return NextResponse.json(safeSettings);
} catch (error) {
console.log("Error updating settings:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}
}

View File

@@ -4,28 +4,47 @@ import {
getSystemPromptConfig,
} from "@omniroute/open-sse/services/systemPrompt.ts";
import { updateSettings } from "@/lib/localDb";
import { updateSystemPromptSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
return NextResponse.json(getSystemPromptConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error reading system prompt config:", error);
return NextResponse.json({ error: "Failed to read system prompt config" }, { status: 500 });
}
}
export async function PUT(request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (body.prompt !== undefined && typeof body.prompt !== "string") {
return NextResponse.json({ error: "prompt must be a string" }, { status: 400 });
try {
const validation = validateBody(updateSystemPromptSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
setSystemPromptConfig(body);
await updateSettings({ systemPrompt: body });
return NextResponse.json(getSystemPromptConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating system prompt config:", error);
return NextResponse.json({ error: "Failed to update system prompt config" }, { status: 500 });
}
}

View File

@@ -5,49 +5,41 @@ import {
getThinkingBudgetConfig,
ThinkingMode,
} from "@omniroute/open-sse/services/thinkingBudget.ts";
import { updateThinkingBudgetSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
const config = getThinkingBudgetConfig();
return NextResponse.json(config);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error reading thinking budget config:", error);
return NextResponse.json({ error: "Failed to read thinking budget config" }, { status: 500 });
}
}
export async function PUT(request) {
let rawBody;
try {
const body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
// Validate mode
const validModes = Object.values(ThinkingMode);
if (body.mode && !validModes.includes(body.mode)) {
return NextResponse.json(
{ error: `Invalid mode. Must be one of: ${validModes.join(", ")}` },
{ status: 400 }
);
}
// Validate customBudget
if (body.customBudget !== undefined) {
const budget = parseInt(body.customBudget, 10);
if (isNaN(budget) || budget < 0 || budget > 131072) {
return NextResponse.json(
{ error: "customBudget must be between 0 and 131072" },
{ status: 400 }
);
}
body.customBudget = budget;
}
// Validate effortLevel
const validEfforts = ["none", "low", "medium", "high"];
if (body.effortLevel && !validEfforts.includes(body.effortLevel)) {
return NextResponse.json(
{ error: `Invalid effortLevel. Must be one of: ${validEfforts.join(", ")}` },
{ status: 400 }
);
try {
const validation = validateBody(updateThinkingBudgetSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
// Apply config in-memory
setThinkingBudgetConfig(body);
@@ -57,6 +49,7 @@ export async function PUT(request) {
return NextResponse.json(getThinkingBudgetConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating thinking budget config:", error);
return NextResponse.json({ error: "Failed to update thinking budget config" }, { status: 500 });
}
}

View File

@@ -5,6 +5,8 @@ import { syncToCloud, fetchWithTimeout, CLOUD_URL } from "@/lib/cloudSync";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { cloudSyncActionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* GET /api/sync/cloud
@@ -58,9 +60,22 @@ export async function GET() {
* Sync data with Cloud
*/
export async function POST(request: any) {
let rawBody;
try {
const body = await request.json();
const { action } = body;
rawBody = await request.json();
} catch {
return NextResponse.json(
{ error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } },
{ status: 400 }
);
}
try {
const validation = validateBody(cloudSyncActionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { action } = validation.data;
// Always get machineId from server, don't trust client
const machineId = await getConsistentMachineId();

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { detectFormat } from "@omniroute/open-sse/services/provider.ts";
import { translatorDetectSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
/**
* POST /api/translator/detect
@@ -8,15 +10,28 @@ import { detectFormat } from "@omniroute/open-sse/services/provider.ts";
* Returns: { format, label }
*/
export async function POST(request) {
let rawBody;
try {
const { body } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
success: false,
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!body || typeof body !== "object") {
return NextResponse.json(
{ success: false, error: "Body must be a JSON object" },
{ status: 400 }
);
try {
const validation = validateBody(translatorDetectSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
const { body } = validation.data;
const format = detectFormat(body);
@@ -26,6 +41,6 @@ export async function POST(request) {
});
} catch (error) {
console.error("Error detecting format:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json({ success: false, error: "Failed to detect format" }, { status: 500 });
}
}

View File

@@ -1,17 +1,32 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { translatorSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function POST(request) {
let rawBody;
try {
const { file, content } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
success: false,
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!file || content === undefined) {
return NextResponse.json(
{ success: false, error: "File and content required" },
{ status: 400 }
);
try {
const validation = validateBody(translatorSaveSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
const { file, content } = validation.data;
// Security: only allow specific filenames
const allowedFiles = [
@@ -39,6 +54,6 @@ export async function POST(request) {
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error saving file:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json({ success: false, error: "Failed to save file" }, { status: 500 });
}
}

View File

@@ -8,18 +8,39 @@ import {
import { getProviderConnections } from "@/lib/localDb";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { logTranslationEvent } from "@/lib/translatorEvents";
import { translatorSendSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function getProviderBaseUrl(providerSpecificData: unknown): string | undefined {
if (!providerSpecificData || typeof providerSpecificData !== "object") return undefined;
const baseUrl = (providerSpecificData as Record<string, unknown>).baseUrl;
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : undefined;
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
success: false,
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const startedAt = Date.now();
const { provider, body } = await request.json();
if (!provider || !body) {
return NextResponse.json(
{ success: false, error: "Provider and body required" },
{ status: 400 }
);
const validation = validateBody(translatorSendSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
const { provider, body } = validation.data;
const sourceFormat = detectFormat(body);
const targetFormat = getTargetFormat(provider);
@@ -60,7 +81,7 @@ export async function POST(request) {
// Build URL and headers using provider service
const url = buildProviderUrl(provider, body.model || "test-model", true, {
baseUrlIndex: 0,
baseUrl: connection.providerSpecificData?.baseUrl,
baseUrl: getProviderBaseUrl(connection.providerSpecificData),
});
const headers = buildProviderHeaders(provider, credentials, true, body);
@@ -120,6 +141,6 @@ export async function POST(request) {
});
} catch (error) {
console.error("Error sending request:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json({ success: false, error: "Failed to send request" }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More