feat: add A2A protocol support and refactor API validation layer

Implement Agent-to-Agent (A2A) JSON-RPC 2.0 endpoint with smart routing
and quota management skills, SSE streaming, and task lifecycle management.

- Add /a2a route with message/send, message/stream, tasks/get, tasks/cancel
- Add /.well-known/agent.json agent card endpoint
- Introduce Zod-based request validation schemas for all v1 API routes
- Extract shared getUnifiedModelsResponse to reduce duplication across
  /models, /v1/models, and /a2a model listing
- Refactor chat, embeddings, moderations, and models routes to use
  centralized validation and error handling
- Add A2A task manager, routing logger, and streaming utilities
This commit is contained in:
diegosouzapw
2026-03-04 18:52:25 -03:00
parent 20860877b8
commit 3510d8c0bc
79 changed files with 2701 additions and 989 deletions

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

@@ -0,0 +1,193 @@
/**
* 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";
// ============ Skill Registry ============
const SKILL_HANDLERS: Record<string, (task: any) => Promise<any>> = {
"smart-routing": executeSmartRouting,
"quota-management": executeQuotaManagement,
};
// ============ 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 = params?.messages || params?.message?.parts;
if (!messages || !Array.isArray(messages)) {
return jsonRpcError(id, -32602, "Invalid params: messages array required");
}
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 = params?.messages || params?.message?.parts;
if (!messages || !Array.isArray(messages)) {
return jsonRpcError(id, -32602, "Invalid params: messages array required");
}
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) => {
const result = await handler(t);
tm.updateTask(t.id, "completed", result.artifacts);
return result;
},
req.signal
);
return new Response(stream, { headers: SSE_HEADERS });
}
// ── tasks/get ─────────────────────────────────────────
case "tasks/get": {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
const task = tm.getTask(taskId);
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
return jsonRpcResult(id, { task });
}
// ── tasks/cancel ──────────────────────────────────────
case "tasks/cancel": {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
try {
const task = tm.cancelTask(taskId);
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return jsonRpcError(id, -32603, msg);
}
}
default:
return jsonRpcError(id, -32601, `Method not found: ${method}`);
}
}
// Agent Card discovery via OPTIONS
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
Allow: "POST, OPTIONS",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}

View File

@@ -3,7 +3,7 @@ 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 { isValidationFailure, loginSchema, validateBody } from "@/shared/validation/schemas";
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
@@ -25,7 +25,7 @@ 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;
@@ -73,6 +73,7 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("[AUTH] Login failed:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,12 @@ import fs from "fs/promises";
import path from "path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime";
import { resolveDataDir } from "@/lib/dataPaths";
import {
codexProfileIdSchema,
codexProfileNameSchema,
isValidationFailure,
validateBody,
} from "@/shared/validation/schemas";
const PROFILES_DIR = path.join(resolveDataDir(), "codex-profiles");
@@ -79,17 +85,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 +171,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 +242,27 @@ export async function PUT(request) {
// DELETE - Remove a saved profile
export async function DELETE(request) {
let rawBody;
try {
const { profileId } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!profileId) {
return NextResponse.json({ error: "profileId is required" }, { status: 400 });
try {
const validation = validateBody(codexProfileIdSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { profileId } = validation.data;
const profilePath = path.join(PROFILES_DIR, `${profileId}.json`);
try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,19 @@
import { NextResponse } from "next/server";
import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models";
import { cloudCredentialUpdateSchema, isValidationFailure, validateBody } from "@/shared/validation/schemas";
// 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 +21,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);

View File

@@ -1,8 +1,19 @@
import { NextResponse } from "next/server";
import { validateApiKey, getModelAliases } from "@/models";
import { cloudResolveAliasSchema, isValidationFailure, validateBody } from "@/shared/validation/schemas";
// 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 +21,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);

View File

@@ -2,9 +2,20 @@ 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, isValidationFailure, validateBody } from "@/shared/validation/schemas";
// 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 +29,11 @@ export async function PUT(request) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
const validation = validateBody(cloudModelAliasUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, alias } = validation.data;
// Check if alias already exists for different model
const aliases = await getModelAliases();

View File

@@ -10,9 +10,7 @@ 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 { isValidationFailure, updateComboSchema, validateBody } from "@/shared/validation/schemas";
// GET /api/combos/[id] - Get combo by ID
export async function GET(request, { params }) {
@@ -33,20 +31,31 @@ export async function GET(request, { params }) {
// PUT /api/combos/[id] - Update combo
export async function PUT(request, { params }) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { id } = await params;
const body = await request.json();
const validation = validateBody(updateComboSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
// Validate name format if provided
// Check if name already exists (exclude current combo)
if (body.name) {
if (!VALID_NAME_REGEX.test(body.name)) {
return NextResponse.json(
{ error: "Name can only contain letters, numbers, - and _" },
{ status: 400 }
);
}
// Check if name already exists (exclude current combo)
const existing = await getComboByName(body.name);
if (existing && existing.id !== id) {
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });

View File

@@ -0,0 +1,76 @@
/**
* 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";
// ── 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) {
try {
const body = await req.json();
const { id, name, candidatePool, weights, modePack, budgetCap, explorationRate } = body;
if (!id || !name) {
return NextResponse.json({ error: "id and name required" }, { status: 400 });
}
const config: AutoComboConfig = {
id,
name,
type: "auto",
candidatePool: candidatePool || [],
weights: weights || DEFAULT_WEIGHTS,
modePack,
budgetCap,
explorationRate: explorationRate ?? 0.05,
};
autoCombos.set(id, config);
return NextResponse.json(config, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({ combos: [...autoCombos.values()] });
}

View File

@@ -2,8 +2,8 @@ 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, isValidationFailure, validateBody } from "@/shared/validation/schemas";
// GET /api/combos - Get all combos
export async function GET() {
@@ -23,7 +23,7 @@ export async function POST(request) {
// Zod validation (covers name format, length, etc.)
const validation = validateBody(createComboSchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, models, strategy, config } = validation.data;

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,11 @@ import {
} from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import {
isValidationFailure,
updateKeyPermissionsSchema,
validateBody,
} from "@/shared/validation/schemas";
// GET /api/keys/[id] - Get single API key
export async function GET(request, { params }) {
@@ -29,26 +34,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 +68,9 @@ export async function PATCH(request, { params }) {
await syncKeysToCloudIfEnabled();
return NextResponse.json({
message: "Permissions updated successfully",
message: "API key settings updated successfully",
allowedModels,
noLog,
});
} catch (error) {
console.log("Error updating key permissions:", error);

View File

@@ -2,7 +2,7 @@ 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, isValidationFailure, validateBody } from "@/shared/validation/schemas";
// GET /api/keys - List API keys
export async function GET() {
@@ -27,7 +27,7 @@ export async function POST(request) {
// Zod validation
const validation = validateBody(createKeySchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name } = validation.data;

View File

@@ -3,6 +3,11 @@ 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,
isValidationFailure,
validateBody,
} from "@/shared/validation/schemas";
// GET /api/models/alias - Get all aliases
export async function GET(request) {
@@ -22,18 +27,32 @@ export async function GET(request) {
// PUT /api/models/alias - Set model alias
export async function PUT(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
const validation = validateBody(cloudModelAliasUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { model, alias } = validation.data;
await setModelAlias(alias, model);
await syncToCloudIfEnabled();

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,24 @@ 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 {
isValidationFailure,
jsonObjectSchema,
oauthExchangeSchema,
oauthPollSchema,
validateBody,
} from "@/shared/validation/schemas";
// Use globalThis to persist callback server state across Next.js HMR reloads
if (!globalThis.__codexCallbackState) {
@@ -152,15 +164,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,7 +222,9 @@ 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");
const match = existing.find(
(c: any) => c.email === tokenData.email && c.authType === "oauth"
);
if (match) {
connection = await updateProviderConnection(match.id, {
...tokenData,
@@ -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,7 +285,9 @@ 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");
const match = existing.find(
(c: any) => c.email === result.tokens.email && c.authType === "oauth"
);
if (match) {
connection = await updateProviderConnection(match.id, {
...result.tokens,
@@ -354,7 +398,9 @@ 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");
const match = existing.find(
(c: any) => c.email === tokenData.email && c.authType === "oauth"
);
if (match) {
connection = await updateProviderConnection(match.id, {
...tokenData,

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
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 { isValidationFailure, policyActionSchema, validateBody } from "@/shared/validation/schemas";
export async function GET() {
try {
@@ -13,13 +9,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 +44,7 @@ export async function POST(request) {
return NextResponse.json({ error: "Unknown action. Supported: unlock" }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating policies:", error);
return NextResponse.json({ error: "Failed to update policies" }, { status: 500 });
}
}

View File

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

View File

@@ -5,6 +5,11 @@ import {
removeCustomModel,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
isValidationFailure,
providerModelMutationSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/provider-models?provider=<id>
@@ -39,6 +44,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 +63,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 +112,9 @@ export async function DELETE(request) {
const removed = await removeCustomModel(provider, modelId);
return Response.json({ removed });
} catch (error) {
console.error("Error removing provider model:", error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: "Failed to remove provider model", type: "server_error" } },
{ status: 500 }
);
}

View File

@@ -7,27 +7,42 @@ import {
updateProviderConnection,
updateProviderNode,
} from "@/models";
import {
isValidationFailure,
updateProviderNodeSchema,
validateBody,
} from "@/shared/validation/schemas";
// 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 +51,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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,11 @@ 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,
isValidationFailure,
validateBody,
} from "@/shared/validation/schemas";
// GET /api/providers - List all connections
export async function GET() {
@@ -42,7 +46,7 @@ export async function POST(request: Request) {
// Zod validation
const validation = validateBody(createProviderSchema, body);
if (!validation.success) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } =

View File

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

View File

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

View File

@@ -8,6 +8,11 @@ import {
getRateLimitStatus,
getAllRateLimitStatus,
} from "@omniroute/open-sse/services/rateLimitManager.ts";
import {
isValidationFailure,
toggleRateLimitSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/rate-limits — Consolidated rate-limit status
@@ -49,12 +54,27 @@ export async function GET() {
* Body: { connectionId: string, enabled: boolean }
*/
export async function POST(request) {
let rawBody;
try {
const { connectionId, enabled } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
if (!connectionId) {
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
try {
const validation = validateBody(toggleRateLimitSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { connectionId, enabled } = validation.data;
// Update in-memory state
if (enabled) {

View File

@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import {
isValidationFailure,
updateResilienceSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/resilience — Get current resilience configuration and status
@@ -36,59 +41,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;

View File

@@ -5,6 +5,12 @@ import {
resetStats,
} from "@omniroute/open-sse/services/backgroundTaskDetector.ts";
import { updateSettings } from "@/lib/db/settings";
import {
isValidationFailure,
jsonObjectSchema,
resetStatsActionSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/settings/background-degradation
@@ -25,8 +31,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 +72,28 @@ export async function PUT(request) {
* Body: { action: "reset-stats" }
*/
export async function POST(request) {
let rawBody;
try {
const { action } = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(resetStatsActionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { action } = validation.data;
if (action === "reset-stats") {
resetStats();
return NextResponse.json({ success: true, stats: getBackgroundDegradationConfig().stats });

View File

@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import {
isValidationFailure,
updateComboDefaultsSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/settings/combo-defaults
@@ -33,8 +38,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 +69,6 @@ export async function PATCH(request) {
updates.providerOverrides = body.providerOverrides;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
const settings: any = await updateSettings(updates);
return NextResponse.json({
comboDefaults: settings.comboDefaults || {},

View File

@@ -9,18 +9,43 @@ import {
tempBanIP,
removeTempBan,
} from "@omniroute/open-sse/services/ipFilter.ts";
import {
isValidationFailure,
updateIpFilterSchema,
validateBody,
} from "@/shared/validation/schemas";
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 +60,17 @@ export async function PUT(request) {
// Temp bans
if (body.tempBan) {
tempBanIP(body.tempBan.ip, body.tempBan.durationMs || 3600000, body.tempBan.reason || "Manual ban");
tempBanIP(
body.tempBan.ip,
body.tempBan.durationMs || 3600000,
body.tempBan.reason || "Manual ban"
);
}
if (body.removeBan) removeTempBan(body.removeBan);
return NextResponse.json(getIPFilterConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating IP filter config:", error);
return NextResponse.json({ error: "Failed to update IP filter config" }, { status: 500 });
}
}

View File

@@ -8,6 +8,13 @@ import {
removeCustomAlias,
} from "@omniroute/open-sse/services/modelDeprecation.ts";
import { getSettings, updateSettings } from "@/lib/db/settings";
import {
addModelAliasSchema,
isValidationFailure,
removeModelAliasSchema,
updateModelAliasesSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* GET /api/settings/model-aliases
@@ -32,11 +39,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 +75,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 +111,27 @@ export async function POST(request) {
* Body: { from: "old-model" }
*/
export async function DELETE(request) {
let rawBody;
try {
const { from } = await request.json();
if (!from) {
return NextResponse.json({ error: "Missing 'from'" }, { status: 400 });
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(removeModelAliasSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { from } = validation.data;
const removed = removeCustomAlias(from);
if (!removed) {
return NextResponse.json({ error: "Alias not found" }, { status: 404 });

View File

@@ -6,8 +6,18 @@ import {
resolveProxyForConnection,
} from "../../../../lib/localDb";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import {
isValidationFailure,
updateProxyConfigSchema,
validateBody,
} from "@/shared/validation/schemas";
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 +34,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 +70,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 +115,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 +138,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 +150,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 +192,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 +209,9 @@ export async function DELETE(request) {
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
}

View File

@@ -5,9 +5,17 @@ import {
proxyConfigToUrl,
proxyUrlForLogs,
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
import { isValidationFailure, testProxySchema, validateBody } from "@/shared/validation/schemas";
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 +32,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 +94,9 @@ export async function POST(request) {
);
}
let proxyUrl;
let proxyUrl: string;
try {
proxyUrl = proxyConfigToUrl(
const normalizedProxyUrl = proxyConfigToUrl(
{
type: proxyType,
host: proxy.host,
@@ -82,11 +106,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 +146,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 +169,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 +179,7 @@ export async function POST(request) {
clearTimeout(timeout);
}
} catch (error) {
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ status: 500 }
);
const message = getErrorMessage(error, "Unexpected server error");
return Response.json({ error: { message, type: "server_error" } }, { status: 500 });
}
}

View File

@@ -1,6 +1,11 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import {
isValidationFailure,
updateRequireLoginSchema,
validateBody,
} from "@/shared/validation/schemas";
export async function GET() {
try {
@@ -17,8 +22,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 +51,9 @@ export async function POST(request: Request) {
updates.requireLogin = requireLogin;
}
if (password && typeof password === "string" && password.length >= 4) {
if (password) {
const hashedPassword = await bcrypt.hash(password, 12);
updates.password = hashedPassword;
} else if (password) {
return NextResponse.json(
{ error: "Password must be at least 4 characters" },
{ status: 400 }
);
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid fields to update" }, { status: 400 });
}
await updateSettings(updates);

View File

@@ -2,8 +2,12 @@ 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 {
isValidationFailure,
updateSettingsSchema,
validateBody,
} from "@/shared/validation/schemas";
export async function GET() {
try {
@@ -23,7 +27,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,10 +37,10 @@ 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) {
@@ -77,6 +81,6 @@ export async function PATCH(request) {
return NextResponse.json(safeSettings);
} catch (error) {
console.log("Error updating settings:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}
}

View File

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

View File

@@ -5,49 +5,44 @@ import {
getThinkingBudgetConfig,
ThinkingMode,
} from "@omniroute/open-sse/services/thinkingBudget.ts";
import {
isValidationFailure,
updateThinkingBudgetSchema,
validateBody,
} from "@/shared/validation/schemas";
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 +52,7 @@ export async function PUT(request) {
return NextResponse.json(getThinkingBudgetConfig());
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error updating thinking budget config:", error);
return NextResponse.json({ error: "Failed to update thinking budget config" }, { status: 500 });
}
}

View File

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

View File

@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { detectFormat } from "@omniroute/open-sse/services/provider.ts";
import {
isValidationFailure,
translatorDetectSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* POST /api/translator/detect
@@ -8,15 +13,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 +44,6 @@ export async function POST(request) {
});
} catch (error) {
console.error("Error detecting format:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json({ success: false, error: "Failed to detect format" }, { status: 500 });
}
}

View File

@@ -1,17 +1,35 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import {
isValidationFailure,
translatorSaveSchema,
validateBody,
} from "@/shared/validation/schemas";
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 +57,6 @@ export async function POST(request) {
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error saving file:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json({ success: false, error: "Failed to save file" }, { status: 500 });
}
}

View File

@@ -8,18 +8,36 @@ import {
import { getProviderConnections } from "@/lib/localDb";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { logTranslationEvent } from "@/lib/translatorEvents";
import {
isValidationFailure,
translatorSendSchema,
validateBody,
} from "@/shared/validation/schemas";
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);
@@ -120,6 +138,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 });
}
}

View File

@@ -8,10 +8,54 @@ import {
import { translateRequest } from "@omniroute/open-sse/translator/index.ts";
import { FORMATS } from "@omniroute/open-sse/translator/formats.ts";
import { getProviderConnections } from "@/lib/localDb";
import {
isValidationFailure,
translatorTranslateSchema,
validateBody,
} from "@/shared/validation/schemas";
type JsonRecord = Record<string, unknown>;
function asJsonRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord;
}
return {};
}
function getActualBody(body: JsonRecord): JsonRecord {
const nested = asJsonRecord(body.body);
return Object.keys(nested).length > 0 ? nested : body;
}
function getModelId(value: JsonRecord): string {
const model = value.model;
return typeof model === "string" && model.trim().length > 0 ? model : "test-model";
}
export async function POST(request) {
let rawBody;
try {
const reqData = 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 }
);
}
try {
const validation = validateBody(translatorTranslateSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
}
const reqData = validation.data;
const {
step,
provider,
@@ -19,18 +63,13 @@ export async function POST(request) {
sourceFormat: reqSourceFormat,
targetFormat: reqTargetFormat,
} = reqData;
if (!body) {
return NextResponse.json({ success: false, error: "Body is required" }, { status: 400 });
}
let result;
// Direct translation mode (Playground): sourceFormat → targetFormat in one shot
if (step === "direct") {
const src = reqSourceFormat || detectFormat(body);
const tgt = reqTargetFormat || (provider ? getTargetFormat(provider) : "openai");
const model = body.model || "test-model";
const model = getModelId(asJsonRecord(body));
const translated = translateRequest(src, tgt, model, body, true, null, provider);
return NextResponse.json({
success: true,
@@ -40,18 +79,11 @@ export async function POST(request) {
});
}
if (!step || !provider) {
return NextResponse.json(
{ success: false, error: "Step and provider are required" },
{ status: 400 }
);
}
switch (step) {
case 1: {
// Step 1: Client → Source (detect format)
// Return format: { timestamp, endpoint, headers, body }
const actualBody = body.body || body;
const actualBody = getActualBody(asJsonRecord(body));
const sourceFormat = detectFormat(actualBody);
result = {
@@ -67,10 +99,10 @@ export async function POST(request) {
case 2: {
// Step 2: Source → OpenAI
// Return format: { timestamp, headers: {}, body }
const actualBody = body.body || body;
const actualBody = getActualBody(asJsonRecord(body));
const sourceFormat = detectFormat(actualBody);
const targetFormat = FORMATS.OPENAI;
const model = actualBody.model || "test-model";
const model = getModelId(actualBody);
const translated = translateRequest(
sourceFormat,
targetFormat,
@@ -92,10 +124,10 @@ export async function POST(request) {
case 3: {
// Step 3: OpenAI → Target
// Return format: { timestamp, body }
const actualBody = body.body || body;
const actualBody = getActualBody(asJsonRecord(body));
const sourceFormat = FORMATS.OPENAI;
const targetFormat = getTargetFormat(provider);
const model = actualBody.model || "test-model";
const model = getModelId(actualBody);
const translated = translateRequest(
sourceFormat,
targetFormat,
@@ -116,8 +148,8 @@ export async function POST(request) {
case 4: {
// Step 4: Build final request with real URL and headers
// Return format: { timestamp, url, headers, body }
const actualBody = body.body || body;
const model = actualBody.model || "test-model";
const actualBody = getActualBody(asJsonRecord(body));
const model = getModelId(actualBody);
// Get provider credentials
const connections = await getProviderConnections({ provider });
@@ -165,6 +197,9 @@ export async function POST(request) {
return NextResponse.json({ success: true, result });
} catch (error) {
console.error("Error translating:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return NextResponse.json(
{ success: false, error: "Failed to translate request" },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getCostSummary, setBudget, checkBudget } from "@/domain/costRules";
import { isValidationFailure, setBudgetSchema, validateBody } from "@/shared/validation/schemas";
export async function GET(request) {
try {
@@ -12,19 +13,38 @@ export async function GET(request) {
const budgetCheck = checkBudget(apiKeyId);
return NextResponse.json({ ...summary, budgetCheck });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error fetching budget summary:", error);
return NextResponse.json({ error: "Failed to fetch budget summary" }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
const { apiKeyId, dailyLimitUsd, monthlyLimitUsd, warningThreshold } = await request.json();
if (!apiKeyId || !dailyLimitUsd) {
return NextResponse.json({ error: "apiKeyId and dailyLimitUsd 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(setBudgetSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { apiKeyId, dailyLimitUsd, monthlyLimitUsd, warningThreshold } = validation.data;
setBudget(apiKeyId, { dailyLimitUsd, monthlyLimitUsd, warningThreshold });
return NextResponse.json({ success: true, apiKeyId, dailyLimitUsd });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("Error setting budget:", error);
return NextResponse.json({ error: "Failed to set budget" }, { status: 500 });
}
}

View File

@@ -0,0 +1,172 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/lib/localDb";
import {
getLearnedLimits,
getRateLimitStatus,
} from "@omniroute/open-sse/services/rateLimitManager.ts";
import {
normalizeQuotaResponse,
sanitizeQuotaProvider,
type QuotaProviderEntry,
type QuotaTokenStatus,
} from "@/shared/contracts/quota";
type ProviderConnectionRecord = Record<string, unknown>;
function toDateMs(value: unknown): number | null {
if (typeof value !== "string" || !value.trim()) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function deriveTokenStatus(connection: ProviderConnectionRecord): QuotaTokenStatus {
const testStatus =
typeof connection.testStatus === "string" ? connection.testStatus.toLowerCase() : "";
if (testStatus === "expired") return "expired";
if (testStatus === "refreshing") return "refreshing";
const expiresAtMs = toDateMs(connection.tokenExpiresAt);
if (expiresAtMs !== null) {
const msRemaining = expiresAtMs - Date.now();
if (msRemaining <= 0) return "expired";
if (msRemaining <= 15 * 60 * 1000) return "expiring";
}
const lastErrorType =
typeof connection.lastErrorType === "string" ? connection.lastErrorType.toLowerCase() : "";
if (lastErrorType.includes("refresh")) return "expiring";
return "valid";
}
function buildQuotaEntry(
connection: ProviderConnectionRecord,
learnedLimit: Record<string, unknown> | null,
rateStatus: Record<string, unknown>
): QuotaProviderEntry {
const provider =
typeof connection.provider === "string" && connection.provider.trim()
? connection.provider
: "unknown";
const connectionId =
typeof connection.id === "string" && connection.id.trim() ? connection.id : "unknown";
const name =
(typeof connection.name === "string" && connection.name.trim()) ||
(typeof connection.email === "string" && connection.email.trim()) ||
provider;
const resetAt =
typeof connection.rateLimitedUntil === "string" && connection.rateLimitedUntil.trim()
? connection.rateLimitedUntil
: null;
let quotaTotal: number | null = null;
let quotaUsed = 0;
let percentRemaining = 100;
const learnedLimitValue =
learnedLimit && typeof learnedLimit.limit === "number" && Number.isFinite(learnedLimit.limit)
? learnedLimit.limit
: null;
const learnedRemainingValue =
learnedLimit &&
typeof learnedLimit.remaining === "number" &&
Number.isFinite(learnedLimit.remaining)
? learnedLimit.remaining
: null;
if (learnedLimitValue !== null && learnedLimitValue > 0) {
quotaTotal = learnedLimitValue;
const remaining =
learnedRemainingValue !== null
? Math.min(Math.max(learnedRemainingValue, 0), learnedLimitValue)
: learnedLimitValue;
quotaUsed = learnedLimitValue - remaining;
percentRemaining = (remaining / learnedLimitValue) * 100;
} else {
const resetAtMs = toDateMs(resetAt);
if (resetAtMs !== null && resetAtMs > Date.now()) {
quotaTotal = 100;
quotaUsed = 100;
percentRemaining = 0;
} else {
// Fallback synthetic signal from queue pressure when limit headers are unavailable.
const queued = typeof rateStatus.queued === "number" ? rateStatus.queued : 0;
const running = typeof rateStatus.running === "number" ? rateStatus.running : 0;
const executing = typeof rateStatus.executing === "number" ? rateStatus.executing : 0;
const syntheticUsage = Math.min(95, queued * 10 + running * 5 + executing * 3);
if (syntheticUsage > 0) {
quotaTotal = 100;
quotaUsed = syntheticUsage;
percentRemaining = 100 - syntheticUsage;
}
}
}
return sanitizeQuotaProvider({
name,
provider,
connectionId,
quotaUsed,
quotaTotal,
percentRemaining,
resetAt,
tokenStatus: deriveTokenStatus(connection),
});
}
/**
* GET /api/usage/quota
*
* Query params:
* - provider (optional): filter by provider slug
* - connectionId (optional): filter by provider connection id
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const providerFilter = searchParams.get("provider");
const connectionIdFilter = searchParams.get("connectionId");
const connectionsRaw = await getProviderConnections({ isActive: true });
let connections = Array.isArray(connectionsRaw) ? connectionsRaw : [];
if (providerFilter) {
connections = connections.filter((conn) => conn.provider === providerFilter);
}
if (connectionIdFilter) {
connections = connections.filter((conn) => conn.id === connectionIdFilter);
}
const learnedLimits = getLearnedLimits();
const providers = connections.map((conn) => {
const learnedLimit = learnedLimits?.[`${conn.provider}:${conn.id}`] || null;
const rateStatus = getRateLimitStatus(conn.provider, conn.id);
return buildQuotaEntry(conn, learnedLimit, rateStatus);
});
const response = normalizeQuotaResponse(
{
providers,
meta: {
generatedAt: new Date().toISOString(),
filters: {
provider: providerFilter,
connectionId: connectionIdFilter,
},
},
},
{
provider: providerFilter,
connectionId: connectionIdFilter,
}
);
return NextResponse.json(response);
} catch (error) {
console.error("[API] GET /api/usage/quota error:", error);
return NextResponse.json({ error: "Failed to fetch quota data" }, { status: 500 });
}
}

View File

@@ -5,6 +5,11 @@ import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
isValidationFailure,
v1AudioSpeechSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -31,16 +36,18 @@ export async function POST(request) {
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
const validation = validateBody(v1AudioSpeechSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);

View File

@@ -57,8 +57,18 @@ export async function POST(request) {
);
}
}
} catch {
// Don't block on guard errors — fail open
} catch (error) {
console.error("[SECURITY] Prompt injection guard failed:", error);
return new Response(
JSON.stringify({
error: {
message: "Security validation temporarily unavailable",
type: "security_guard_unavailable",
code: "SECURITY_002",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
return await handleChat(request);

View File

@@ -10,6 +10,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { isValidationFailure, v1EmbeddingsSchema, validateBody } from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -51,14 +52,20 @@ export async function GET() {
* POST /v1/embeddings — create embeddings
*/
export async function POST(request) {
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
log.warn("EMBED", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1EmbeddingsSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
@@ -71,14 +78,6 @@ export async function POST(request) {
}
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (!body.input) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing input");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;

View File

@@ -1,12 +1,21 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseImageModel, getAllImageModels, getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import {
parseImageModel,
getAllImageModels,
getImageProvider,
} from "@omniroute/open-sse/config/imageRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
isValidationFailure,
v1ImageGenerationSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -48,14 +57,20 @@ export async function GET() {
* POST /v1/images/generations — generate images
*/
export async function POST(request) {
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
log.warn("IMAGE", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1ImageGenerationSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
@@ -68,14 +83,6 @@ export async function POST(request) {
}
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
@@ -97,7 +104,10 @@ export async function POST(request) {
if (providerConfig && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for image provider: ${provider}`);
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for image provider: ${provider}`
);
}
}

View File

@@ -1,4 +1,9 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import {
isValidationFailure,
v1CountTokensSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -11,9 +16,9 @@ export async function OPTIONS() {
* POST /v1/messages/count_tokens - Mock token count response
*/
export async function POST(request) {
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
@@ -21,6 +26,15 @@ export async function POST(request) {
});
}
const validation = validateBody(v1CountTokensSchema, rawBody);
if (isValidationFailure(validation)) {
return new Response(JSON.stringify({ error: validation.error }), {
status: 400,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
const body = validation.data;
// Estimate token count based on content length
const messages = body.messages || [];
let totalChars = 0;

View File

@@ -0,0 +1,406 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import {
getProviderConnections,
getCombos,
getAllCustomModels,
getSettings,
getProviderNodes,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
cc: "claude",
cl: "cline",
cu: "cursor",
cx: "codex",
gc: "gemini-cli",
gh: "github",
if: "iflow",
kc: "kilocode",
kmc: "kimi-coding",
kr: "kiro",
qw: "qwen",
};
function buildAliasMaps() {
const aliasToProviderId: Record<string, string> = {};
const providerIdToAlias: Record<string, string> = {};
// Canonical source for ID/alias pairs used across dashboard/provider config.
for (const provider of Object.values(AI_PROVIDERS)) {
const providerId = provider?.id;
const alias = provider?.alias || providerId;
if (!providerId) continue;
aliasToProviderId[providerId] = providerId;
aliasToProviderId[alias] = providerId;
if (!providerIdToAlias[providerId]) {
providerIdToAlias[providerId] = alias;
}
}
for (const [left, right] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
// Handle both possible directions:
// - providerId -> alias
// - alias -> providerId
if (PROVIDER_MODELS[left]) {
aliasToProviderId[left] = aliasToProviderId[left] || right;
continue;
}
if (PROVIDER_MODELS[right]) {
aliasToProviderId[right] = aliasToProviderId[right] || left;
continue;
}
aliasToProviderId[right] = aliasToProviderId[right] || left;
}
for (const alias of Object.keys(PROVIDER_MODELS)) {
if (!aliasToProviderId[alias]) {
aliasToProviderId[alias] = alias;
}
}
for (const [alias, providerId] of Object.entries(aliasToProviderId)) {
if (!providerIdToAlias[providerId]) {
providerIdToAlias[providerId] = alias;
}
}
// Safety net for environments where alias maps are partially loaded during
// module initialization/circular imports.
for (const [alias, providerId] of Object.entries(FALLBACK_ALIAS_TO_PROVIDER)) {
if (!aliasToProviderId[alias]) aliasToProviderId[alias] = providerId;
if (!aliasToProviderId[providerId]) aliasToProviderId[providerId] = providerId;
if (!providerIdToAlias[providerId]) providerIdToAlias[providerId] = alias;
}
return { aliasToProviderId, providerIdToAlias };
}
/**
* Build unified OpenAI-compatible model catalog response.
* Reused by `/api/v1/models` and `/api/v1` to avoid semantic drift (T09).
*/
export async function getUnifiedModelsResponse(
request: Request,
corsHeaders: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
}
) {
try {
// Issue #100: Optionally require authentication for /models (security hardening)
// When enabled, unauthenticated requests get 401 with proper error response.
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
if (!(await isAuthenticated(request))) {
return Response.json(
{
error: {
message: "Authentication required",
type: "invalid_request_error",
code: "invalid_api_key",
},
},
{ status: 401 }
);
}
}
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders: Set<string> = new Set(
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
);
// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = await getProviderConnections();
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);
} catch (e) {
// If database not available, show no provider models (safe default)
console.log("Could not fetch providers, showing only combos/custom models");
}
// Get provider nodes (for compatible providers with custom prefixes)
let providerNodes = [];
try {
providerNodes = await getProviderNodes();
} catch (e) {
console.log("Could not fetch provider nodes");
}
// Build map of provider node ID to prefix and type for compatible providers
const providerIdToPrefix: Record<string, string> = {};
const nodeIdToProviderType: Record<string, string> = {};
for (const node of providerNodes) {
if (node.prefix) {
providerIdToPrefix[node.id] = node.prefix;
}
if (node.type) {
nodeIdToProviderType[node.id] = node.type;
}
}
// Get combos
let combos = [];
try {
combos = await getCombos();
} catch (e) {
console.log("Could not fetch combos");
}
// Build set of active provider aliases
const activeAliases = new Set();
for (const conn of connections) {
const alias = providerIdToAlias[conn.provider] || conn.provider;
activeAliases.add(alias);
activeAliases.add(conn.provider);
}
// Collect models from active providers (or all if none active)
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false) continue;
models.push({
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: combo.name,
parent: null,
});
}
// Add provider models (chat)
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Skip blocked providers (Issue #96)
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
// Only include models from providers with active connections
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
}
for (const model of providerModels) {
const aliasId = `${alias}/${model.id}`;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: null,
});
// Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model).
// This improves compatibility for clients that expect full provider names.
if (canonicalProviderId !== alias) {
models.push({
id: `${canonicalProviderId}/${model.id}`,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: aliasId,
});
}
}
}
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
return activeAliases.has(alias) || activeAliases.has(provider);
};
// Add embedding models (filtered by active providers)
for (const embModel of getAllEmbeddingModels()) {
if (!isProviderActive(embModel.provider)) continue;
models.push({
id: embModel.id,
object: "model",
created: timestamp,
owned_by: embModel.provider,
type: "embedding",
dimensions: embModel.dimensions,
});
}
// Add image models (filtered by active providers)
for (const imgModel of getAllImageModels()) {
if (!isProviderActive(imgModel.provider)) continue;
models.push({
id: imgModel.id,
object: "model",
created: timestamp,
owned_by: imgModel.provider,
type: "image",
supported_sizes: imgModel.supportedSizes,
});
}
// Add rerank models (filtered by active providers)
for (const rerankModel of getAllRerankModels()) {
if (!isProviderActive(rerankModel.provider)) continue;
models.push({
id: rerankModel.id,
object: "model",
created: timestamp,
owned_by: rerankModel.provider,
type: "rerank",
});
}
// Add audio models (filtered by active providers)
for (const audioModel of getAllAudioModels()) {
if (!isProviderActive(audioModel.provider)) continue;
models.push({
id: audioModel.id,
object: "model",
created: timestamp,
owned_by: audioModel.provider,
type: "audio",
subtype: audioModel.subtype,
});
}
// Add moderation models (filtered by active providers)
for (const modModel of getAllModerationModels()) {
if (!isProviderActive(modModel.provider)) continue;
models.push({
id: modModel.id,
object: "model",
created: timestamp,
owned_by: modModel.provider,
type: "moderation",
});
}
// Add video models (local providers always listed, cloud filtered by active)
for (const videoModel of getAllVideoModels()) {
const vConfig = getVideoProvider(videoModel.provider);
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
models.push({
id: videoModel.id,
object: "model",
created: timestamp,
owned_by: videoModel.provider,
type: "video",
});
}
// Add music models (local providers always listed, cloud filtered by active)
for (const musicModel of getAllMusicModels()) {
const mConfig = getMusicProvider(musicModel.provider);
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
models.push({
id: musicModel.id,
object: "model",
created: timestamp,
owned_by: musicModel.provider,
type: "music",
});
}
// Add custom models (user-defined)
try {
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
// For compatible providers, use the prefix from provider nodes
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Only include if provider is active — check alias, canonical ID, raw providerId,
// or the parent provider type (for compatible providers whose node ID is a UUID)
const parentProviderType = nodeIdToProviderType[providerId];
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId) &&
!(parentProviderType && activeAliases.has(parentProviderType))
)
continue;
for (const model of providerCustomModels) {
// Skip if already added as built-in
const aliasId = `${alias}/${model.id}`;
if (models.some((m) => m.id === aliasId)) continue;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: null,
custom: true,
});
// Only add provider-prefixed version if different from alias
if (canonicalProviderId !== alias && !prefix) {
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
if (models.some((m) => m.id === providerPrefixedId)) continue;
models.push({
id: providerPrefixedId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: aliasId,
custom: true,
});
}
}
}
} catch (e) {
console.log("Could not fetch custom models");
}
return Response.json(
{
object: "list",
data: models,
},
{
headers: corsHeaders,
}
);
} catch (error) {
console.log("Error fetching models:", error);
return Response.json(
{ error: { message: (error as any).message, type: "server_error" } },
{ status: 500 }
);
}
}

View File

@@ -1,90 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import {
getProviderConnections,
getCombos,
getAllCustomModels,
getSettings,
getProviderNodes,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
cc: "claude",
cl: "cline",
cu: "cursor",
cx: "codex",
gc: "gemini-cli",
gh: "github",
if: "iflow",
kc: "kilocode",
kmc: "kimi-coding",
kr: "kiro",
qw: "qwen",
};
function buildAliasMaps() {
const aliasToProviderId: Record<string, string> = {};
const providerIdToAlias: Record<string, string> = {};
// Canonical source for ID/alias pairs used across dashboard/provider config.
for (const provider of Object.values(AI_PROVIDERS)) {
const providerId = provider?.id;
const alias = provider?.alias || providerId;
if (!providerId) continue;
aliasToProviderId[providerId] = providerId;
aliasToProviderId[alias] = providerId;
if (!providerIdToAlias[providerId]) {
providerIdToAlias[providerId] = alias;
}
}
for (const [left, right] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
// Handle both possible directions:
// - providerId -> alias
// - alias -> providerId
if (PROVIDER_MODELS[left]) {
aliasToProviderId[left] = aliasToProviderId[left] || right;
continue;
}
if (PROVIDER_MODELS[right]) {
aliasToProviderId[right] = aliasToProviderId[right] || left;
continue;
}
aliasToProviderId[right] = aliasToProviderId[right] || left;
}
for (const alias of Object.keys(PROVIDER_MODELS)) {
if (!aliasToProviderId[alias]) {
aliasToProviderId[alias] = alias;
}
}
for (const [alias, providerId] of Object.entries(aliasToProviderId)) {
if (!providerIdToAlias[providerId]) {
providerIdToAlias[providerId] = alias;
}
}
// Safety net for environments where alias maps are partially loaded during
// module initialization/circular imports.
for (const [alias, providerId] of Object.entries(FALLBACK_ALIAS_TO_PROVIDER)) {
if (!aliasToProviderId[alias]) aliasToProviderId[alias] = providerId;
if (!aliasToProviderId[providerId]) aliasToProviderId[providerId] = providerId;
if (!providerIdToAlias[providerId]) providerIdToAlias[providerId] = alias;
}
return { aliasToProviderId, providerIdToAlias };
}
import { getUnifiedModelsResponse } from "./catalog";
/**
* Handle CORS preflight
@@ -101,316 +16,7 @@ export async function OPTIONS() {
/**
* GET /v1/models - OpenAI compatible models list
* Returns models from all active providers, combos, embeddings, and image models in OpenAI format
*/
export async function GET(request: Request) {
try {
// Issue #100: Optionally require authentication for /models (security hardening)
// When enabled, unauthenticated requests get 401 with proper error response.
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
if (!(await isAuthenticated(request))) {
return Response.json(
{
error: {
message: "Authentication required",
type: "invalid_request_error",
code: "invalid_api_key",
},
},
{ status: 401 }
);
}
}
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders: Set<string> = new Set(
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
);
// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = await getProviderConnections();
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);
} catch (e) {
// If database not available, show no provider models (safe default)
console.log("Could not fetch providers, showing only combos/custom models");
}
// Get provider nodes (for compatible providers with custom prefixes)
let providerNodes = [];
try {
providerNodes = await getProviderNodes();
} catch (e) {
console.log("Could not fetch provider nodes");
}
// Build map of provider node ID to prefix and type for compatible providers
const providerIdToPrefix: Record<string, string> = {};
const nodeIdToProviderType: Record<string, string> = {};
for (const node of providerNodes) {
if (node.prefix) {
providerIdToPrefix[node.id] = node.prefix;
}
if (node.type) {
nodeIdToProviderType[node.id] = node.type;
}
}
// Get combos
let combos = [];
try {
combos = await getCombos();
} catch (e) {
console.log("Could not fetch combos");
}
// Build set of active provider aliases
const activeAliases = new Set();
for (const conn of connections) {
const alias = providerIdToAlias[conn.provider] || conn.provider;
activeAliases.add(alias);
activeAliases.add(conn.provider);
}
// Collect models from active providers (or all if none active)
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false) continue;
models.push({
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: combo.name,
parent: null,
});
}
// Add provider models (chat)
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Skip blocked providers (Issue #96)
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
// Only include models from providers with active connections
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
}
for (const model of providerModels) {
const aliasId = `${alias}/${model.id}`;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: null,
});
// Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model).
// This improves compatibility for clients that expect full provider names.
if (canonicalProviderId !== alias) {
models.push({
id: `${canonicalProviderId}/${model.id}`,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: aliasId,
});
}
}
}
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
return activeAliases.has(alias) || activeAliases.has(provider);
};
// Add embedding models (filtered by active providers)
for (const embModel of getAllEmbeddingModels()) {
if (!isProviderActive(embModel.provider)) continue;
models.push({
id: embModel.id,
object: "model",
created: timestamp,
owned_by: embModel.provider,
type: "embedding",
dimensions: embModel.dimensions,
});
}
// Add image models (filtered by active providers)
for (const imgModel of getAllImageModels()) {
if (!isProviderActive(imgModel.provider)) continue;
models.push({
id: imgModel.id,
object: "model",
created: timestamp,
owned_by: imgModel.provider,
type: "image",
supported_sizes: imgModel.supportedSizes,
});
}
// Add rerank models (filtered by active providers)
for (const rerankModel of getAllRerankModels()) {
if (!isProviderActive(rerankModel.provider)) continue;
models.push({
id: rerankModel.id,
object: "model",
created: timestamp,
owned_by: rerankModel.provider,
type: "rerank",
});
}
// Add audio models (filtered by active providers)
for (const audioModel of getAllAudioModels()) {
if (!isProviderActive(audioModel.provider)) continue;
models.push({
id: audioModel.id,
object: "model",
created: timestamp,
owned_by: audioModel.provider,
type: "audio",
subtype: audioModel.subtype,
});
}
// Add moderation models (filtered by active providers)
for (const modModel of getAllModerationModels()) {
if (!isProviderActive(modModel.provider)) continue;
models.push({
id: modModel.id,
object: "model",
created: timestamp,
owned_by: modModel.provider,
type: "moderation",
});
}
// Add video models (local providers always listed, cloud filtered by active)
for (const videoModel of getAllVideoModels()) {
const vConfig = getVideoProvider(videoModel.provider);
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
models.push({
id: videoModel.id,
object: "model",
created: timestamp,
owned_by: videoModel.provider,
type: "video",
});
}
// Add music models (local providers always listed, cloud filtered by active)
for (const musicModel of getAllMusicModels()) {
const mConfig = getMusicProvider(musicModel.provider);
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
models.push({
id: musicModel.id,
object: "model",
created: timestamp,
owned_by: musicModel.provider,
type: "music",
});
}
// Add custom models (user-defined)
try {
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
// For compatible providers, use the prefix from provider nodes
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Only include if provider is active — check alias, canonical ID, raw providerId,
// or the parent provider type (for compatible providers whose node ID is a UUID)
const parentProviderType = nodeIdToProviderType[providerId];
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId) &&
!(parentProviderType && activeAliases.has(parentProviderType))
)
continue;
for (const model of providerCustomModels) {
// Skip if already added as built-in
const aliasId = `${alias}/${model.id}`;
if (models.some((m) => m.id === aliasId)) continue;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: null,
custom: true,
});
// Only add provider-prefixed version if different from alias
if (canonicalProviderId !== alias && !prefix) {
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
if (models.some((m) => m.id === providerPrefixedId)) continue;
models.push({
id: providerPrefixedId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: aliasId,
custom: true,
});
}
}
}
} catch (e) {
console.log("Could not fetch custom models");
}
return Response.json(
{
object: "list",
data: models,
},
{
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
},
}
);
} catch (error) {
console.log("Error fetching models:", error);
return Response.json(
{ error: { message: (error as any).message, type: "server_error" } },
{ status: 500 }
);
}
return getUnifiedModelsResponse(request);
}

View File

@@ -5,6 +5,7 @@ import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegis
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { isValidationFailure, v1ModerationSchema, validateBody } from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -31,13 +32,19 @@ export async function POST(request) {
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1ModerationSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
const model = body.model || "omni-moderation-latest";
// Enforce API key policies (model restrictions + budget limits)

View File

@@ -1,12 +1,21 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseMusicModel, getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
import {
parseMusicModel,
getAllMusicModels,
getMusicProvider,
} from "@omniroute/open-sse/config/musicRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
isValidationFailure,
v1ImageGenerationSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -47,14 +56,20 @@ export async function GET() {
* POST /v1/music/generations — generate music
*/
export async function POST(request) {
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
log.warn("MUSIC", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1ImageGenerationSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
@@ -67,14 +82,6 @@ export async function POST(request) {
}
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
@@ -96,7 +103,10 @@ export async function POST(request) {
if (providerConfig && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for music provider: ${provider}`);
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for music provider: ${provider}`
);
}
}

View File

@@ -4,6 +4,11 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
isValidationFailure,
providerChatCompletionSchema,
validateBody,
} from "@/shared/validation/schemas";
let initialized = false;
@@ -46,12 +51,17 @@ export async function POST(request, { params }) {
await ensureInitialized();
// Clone request with provider-prefixed model
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(providerChatCompletionSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Validate model belongs to this provider
if (body.model) {

View File

@@ -6,6 +6,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import * as log from "@/sse/utils/logger";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { isValidationFailure, v1EmbeddingsSchema, validateBody } from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -34,12 +35,17 @@ export async function POST(request, { params }) {
const providerAlias = providerEntry.alias || providerEntry.id;
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1EmbeddingsSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {

View File

@@ -7,6 +7,11 @@ import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
isValidationFailure,
v1ImageGenerationSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -33,12 +38,17 @@ export async function POST(request, { params }) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown image provider: ${rawProvider}`);
}
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1ImageGenerationSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
@@ -48,14 +58,6 @@ export async function POST(request, { params }) {
}
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt");
}
// Ensure model has provider prefix
if (!body.model.includes("/")) {
body.model = `${rawProvider}/${body.model}`;

View File

@@ -5,6 +5,7 @@ import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { isValidationFailure, v1RerankSchema, validateBody } from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -34,16 +35,18 @@ export async function POST(request) {
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
const validation = validateBody(v1RerankSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);

View File

@@ -1,4 +1,5 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { getUnifiedModelsResponse } from "./models/catalog";
/**
* Handle CORS preflight
@@ -9,22 +10,11 @@ export async function OPTIONS() {
/**
* GET /v1 - Return models list (OpenAI compatible)
* Delegates to the same catalog builder as `/api/v1/models` (T09).
*/
export async function GET() {
const models = [
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
{ id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" },
{ id: "gpt-4o", object: "model", owned_by: "openai" },
{ id: "gemini-2.5-pro", object: "model", owned_by: "google" },
];
return new Response(
JSON.stringify({
object: "list",
data: models,
}),
{
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
}
);
export async function GET(request: Request) {
return getUnifiedModelsResponse(request, {
"Content-Type": "application/json",
...CORS_HEADERS,
});
}

View File

@@ -1,12 +1,21 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { parseVideoModel, getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
import {
parseVideoModel,
getAllVideoModels,
getVideoProvider,
} from "@omniroute/open-sse/config/videoRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import {
isValidationFailure,
v1ImageGenerationSchema,
validateBody,
} from "@/shared/validation/schemas";
/**
* Handle CORS preflight
@@ -47,14 +56,20 @@ export async function GET() {
* POST /v1/videos/generations — generate videos
*/
export async function POST(request) {
let body;
let rawBody;
try {
body = await request.json();
rawBody = await request.json();
} catch {
log.warn("VIDEO", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const validation = validateBody(v1ImageGenerationSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
@@ -67,14 +82,6 @@ export async function POST(request) {
}
}
if (!body.model) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
@@ -96,7 +103,10 @@ export async function POST(request) {
if (providerConfig && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for video provider: ${provider}`);
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for video provider: ${provider}`
);
}
}

View File

@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import {
isValidationFailure,
v1betaGeminiGenerateSchema,
validateBody,
} from "@/shared/validation/schemas";
let initialized = false;
@@ -35,6 +40,21 @@ export async function OPTIONS() {
export async function POST(request, { params }) {
await ensureInitialized();
let rawBody;
try {
rawBody = await request.json();
} catch {
return Response.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const { path } = await params;
// path = ["provider", "model:generateContent"] or ["model:generateContent"]
@@ -54,7 +74,11 @@ export async function POST(request, { params }) {
model = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", "");
}
const body = await request.json();
const validation = validateBody(v1betaGeminiGenerateSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json({ error: validation.error }, { status: 400 });
}
const body = validation.data;
// Convert Gemini format to OpenAI/internal format
const convertedBody = convertGeminiToInternal(body, model);