mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
Merge branch 'features-agente-mcp-a2a'
# Conflicts: # package-lock.json
This commit is contained in:
555
src/app/(dashboard)/dashboard/a2a/page.tsx
Normal file
555
src/app/(dashboard)/dashboard/a2a/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
253
src/app/(dashboard)/dashboard/auto-combo/page.tsx
Normal file
253
src/app/(dashboard)/dashboard/auto-combo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
46
src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx
Normal file
46
src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
642
src/app/(dashboard)/dashboard/mcp/page.tsx
Normal file
642
src/app/(dashboard)/dashboard/mcp/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
105
src/app/.well-known/agent.json/route.ts
Normal file
105
src/app/.well-known/agent.json/route.ts
Normal 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
19
src/app/400/page.tsx
Normal 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
19
src/app/401/page.tsx
Normal 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
22
src/app/403/page.tsx
Normal 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
19
src/app/408/page.tsx
Normal 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
22
src/app/429/page.tsx
Normal 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
19
src/app/500/page.tsx
Normal 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
19
src/app/502/page.tsx
Normal 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
19
src/app/503/page.tsx
Normal 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
247
src/app/a2a/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
36
src/app/api/a2a/status/route.ts
Normal file
36
src/app/api/a2a/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/a2a/tasks/[id]/cancel/route.ts
Normal file
15
src/app/api/a2a/tasks/[id]/cancel/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/a2a/tasks/[id]/route.ts
Normal file
17
src/app/api/a2a/tasks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/a2a/tasks/route.ts
Normal file
46
src/app/api/a2a/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() || "";
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
67
src/app/api/cli-tools/openclaw/auto-order/route.ts
Normal file
67
src/app/api/cli-tools/openclaw/auto-order/route.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
94
src/app/api/combos/auto/route.ts
Normal file
94
src/app/api/combos/auto/route.ts
Normal 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()] });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
39
src/app/api/mcp/audit/route.ts
Normal file
39
src/app/api/mcp/audit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/mcp/audit/stats/route.ts
Normal file
12
src/app/api/mcp/audit/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
src/app/api/mcp/sse/route.ts
Normal file
41
src/app/api/mcp/sse/route.ts
Normal 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);
|
||||
}
|
||||
73
src/app/api/mcp/status/route.ts
Normal file
73
src/app/api/mcp/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/mcp/stream/route.ts
Normal file
48
src/app/api/mcp/stream/route.ts
Normal 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);
|
||||
}
|
||||
22
src/app/api/mcp/tools/route.ts
Normal file
22
src/app/api/mcp/tools/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 || {},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user