diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts new file mode 100644 index 0000000000..588ebe45e8 --- /dev/null +++ b/src/app/a2a/route.ts @@ -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 Promise> = { + "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", + }, + }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3b7bc97284..334036baea 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }); } } diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts index 14c25d9476..0b35a4b9d1 100644 --- a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts @@ -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 = {}; for (const [alias, model] of Object.entries(mappings)) { diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index 5852a3084c..2ae3a8db9f 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -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() || ""; diff --git a/src/app/api/cli-tools/backups/route.ts b/src/app/api/cli-tools/backups/route.ts index 136fdba42c..98ad1ab21d 100644 --- a/src/app/api/cli-tools/backups/route.ts +++ b/src/app/api/cli-tools/backups/route.ts @@ -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 }); diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index a0cf27ec95..f9f82d1d19 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -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); diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index 418233fe39..3848de9f39 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -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 }); diff --git a/src/app/api/cli-tools/codex-profiles/route.ts b/src/app/api/cli-tools/codex-profiles/route.ts index 3749a4e931..29efa2c599 100644 --- a/src/app/api/cli-tools/codex-profiles/route.ts +++ b/src/app/api/cli-tools/codex-profiles/route.ts @@ -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 { diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index bb01c0cb62..9158c0895f 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -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 } diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index de0c72d15c..25e0ae5aee 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -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(); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 8b65f7b6a6..2c5e93aa2b 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -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": diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index 38a1feceda..5d829069fe 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -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 }); diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index ca6d370e1c..3009d9d31f 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -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(); diff --git a/src/app/api/cli-tools/openclaw/auto-order/route.ts b/src/app/api/cli-tools/openclaw/auto-order/route.ts new file mode 100644 index 0000000000..0a66c42cc6 --- /dev/null +++ b/src/app/api/cli-tools/openclaw/auto-order/route.ts @@ -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(); + + // Start all providers with base score + const allProviders = new Set(); + 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", + }); + } +} diff --git a/src/app/api/cloud/credentials/update/route.ts b/src/app/api/cloud/credentials/update/route.ts index 6494c7d6d6..9ff00e5fd4 100644 --- a/src/app/api/cloud/credentials/update/route.ts +++ b/src/app/api/cloud/credentials/update/route.ts @@ -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); diff --git a/src/app/api/cloud/model/resolve/route.ts b/src/app/api/cloud/model/resolve/route.ts index e5f319ac7a..96887e05fe 100644 --- a/src/app/api/cloud/model/resolve/route.ts +++ b/src/app/api/cloud/model/resolve/route.ts @@ -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); diff --git a/src/app/api/cloud/models/alias/route.ts b/src/app/api/cloud/models/alias/route.ts index 4903989a85..44dd544198 100644 --- a/src/app/api/cloud/models/alias/route.ts +++ b/src/app/api/cloud/models/alias/route.ts @@ -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(); diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index 898bd42f6b..9cd8bff87c 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -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 }); diff --git a/src/app/api/combos/auto/route.ts b/src/app/api/combos/auto/route.ts new file mode 100644 index 0000000000..0ec47145af --- /dev/null +++ b/src/app/api/combos/auto/route.ts @@ -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(); + +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()] }); +} diff --git a/src/app/api/combos/route.ts b/src/app/api/combos/route.ts index 2639cf835e..58a0257031 100644 --- a/src/app/api/combos/route.ts +++ b/src/app/api/combos/route.ts @@ -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; diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index e52b25505d..eadd73d7a1 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -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) { diff --git a/src/app/api/db-backups/route.ts b/src/app/api/db-backups/route.ts index ece76011f9..6d25246b97 100644 --- a/src/app/api/db-backups/route.ts +++ b/src/app/api/db-backups/route.ts @@ -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); diff --git a/src/app/api/evals/route.ts b/src/app/api/evals/route.ts index cd02d0057a..bc62637e19 100644 --- a/src/app/api/evals/route.ts +++ b/src/app/api/evals/route.ts @@ -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) 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) { diff --git a/src/app/api/fallback/chains/route.ts b/src/app/api/fallback/chains/route.ts index 60e00a883e..97cdecec25 100644 --- a/src/app/api/fallback/chains/route.ts +++ b/src/app/api/fallback/chains/route.ts @@ -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 }); } } diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index d20b2083ff..b1b99c55b5 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -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); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 2ac585d6f8..977b3181d2 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -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; diff --git a/src/app/api/models/alias/route.ts b/src/app/api/models/alias/route.ts index 002a060dc3..5a2625336c 100644 --- a/src/app/api/models/alias/route.ts +++ b/src/app/api/models/alias/route.ts @@ -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(); diff --git a/src/app/api/models/availability/route.ts b/src/app/api/models/availability/route.ts index 433430d048..7acb463a31 100644 --- a/src/app/api/models/availability/route.ts +++ b/src/app/api/models/availability/route.ts @@ -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 }); } } diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index a90b890981..ef972953c0 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -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(); diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index cdf0b0d8f4..bd2bb2a943 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -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 }); } } diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index d3596ec507..777b16bd83 100644 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -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, diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 06941a7b7f..c7bfa3dd26 100644 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -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(); diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index e6d26eca19..771c6bbc19 100644 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -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(); diff --git a/src/app/api/oauth/kiro/social-exchange/route.ts b/src/app/api/oauth/kiro/social-exchange/route.ts index fb50e786e5..af9484a7da 100644 --- a/src/app/api/oauth/kiro/social-exchange/route.ts +++ b/src/app/api/oauth/kiro/social-exchange/route.ts @@ -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(); diff --git a/src/app/api/policies/route.ts b/src/app/api/policies/route.ts index c5be4dcefa..1d90b1d097 100644 --- a/src/app/api/policies/route.ts +++ b/src/app/api/policies/route.ts @@ -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 }); } } diff --git a/src/app/api/pricing/route.ts b/src/app/api/pricing/route.ts index 1936bc5147..0c083d54b2 100644 --- a/src/app/api/pricing/route.ts +++ b/src/app/api/pricing/route.ts @@ -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); diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 39b1fcf37d..eed7d7bdbf 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -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= @@ -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 } ); } diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index 67eb968a7c..44de9c5585 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -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 diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index d7274f53ed..2f616a2cc9 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -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", diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 85a7d28c16..48831c0a5e 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -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") { diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 376c823521..3b5cd35f71 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -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, diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 51fb528819..c4a256eb96 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -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 } = diff --git a/src/app/api/providers/test-batch/route.ts b/src/app/api/providers/test-batch/route.ts index c9d292ecaf..b61a7aca4d 100644 --- a/src/app/api/providers/test-batch/route.ts +++ b/src/app/api/providers/test-batch/route.ts @@ -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 }); diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 1d36f1925c..7facec108b 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -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 = {}; diff --git a/src/app/api/rate-limits/route.ts b/src/app/api/rate-limits/route.ts index ecb5be918c..ae89136bfb 100644 --- a/src/app/api/rate-limits/route.ts +++ b/src/app/api/rate-limits/route.ts @@ -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) { diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index 95ec96f26a..068c9efc80 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -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 = {}; if (profiles) updates.providerProfiles = profiles; diff --git a/src/app/api/settings/background-degradation/route.ts b/src/app/api/settings/background-degradation/route.ts index 49b4da2c40..75a285d869 100644 --- a/src/app/api/settings/background-degradation/route.ts +++ b/src/app/api/settings/background-degradation/route.ts @@ -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 }); diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 601c249c5b..a484dd3a34 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -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 = {}; 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 || {}, diff --git a/src/app/api/settings/ip-filter/route.ts b/src/app/api/settings/ip-filter/route.ts index 2d41acf0a9..b7b4932077 100644 --- a/src/app/api/settings/ip-filter/route.ts +++ b/src/app/api/settings/ip-filter/route.ts @@ -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 }); } } diff --git a/src/app/api/settings/model-aliases/route.ts b/src/app/api/settings/model-aliases/route.ts index 9d307db110..010e5d3029 100644 --- a/src/app/api/settings/model-aliases/route.ts +++ b/src/app/api/settings/model-aliases/route.ts @@ -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 }); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index 6ec8ea8474..d9bd8792f8 100644 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -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; +type ProxyConfigInput = NonNullable; +type ProxyMapInput = Record; +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; 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 } ); } diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index c78fe61577..786e8989d1 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -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 }); } } diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index abd62fe0d8..6b23e9c129 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -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 = {}; @@ -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); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index f6171c1ebd..b9d047e998 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -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 }); } } diff --git a/src/app/api/settings/system-prompt/route.ts b/src/app/api/settings/system-prompt/route.ts index 0869342086..9fc16786c0 100644 --- a/src/app/api/settings/system-prompt/route.ts +++ b/src/app/api/settings/system-prompt/route.ts @@ -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 }); } } diff --git a/src/app/api/settings/thinking-budget/route.ts b/src/app/api/settings/thinking-budget/route.ts index a32a25135f..7b98f411fa 100644 --- a/src/app/api/settings/thinking-budget/route.ts +++ b/src/app/api/settings/thinking-budget/route.ts @@ -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 }); } } diff --git a/src/app/api/sync/cloud/route.ts b/src/app/api/sync/cloud/route.ts index 8e6df37f41..0faaa7d51d 100644 --- a/src/app/api/sync/cloud/route.ts +++ b/src/app/api/sync/cloud/route.ts @@ -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(); diff --git a/src/app/api/translator/detect/route.ts b/src/app/api/translator/detect/route.ts index 889cd7b240..b252dd339e 100644 --- a/src/app/api/translator/detect/route.ts +++ b/src/app/api/translator/detect/route.ts @@ -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 }); } } diff --git a/src/app/api/translator/save/route.ts b/src/app/api/translator/save/route.ts index 72cb956fe4..52fec48eed 100644 --- a/src/app/api/translator/save/route.ts +++ b/src/app/api/translator/save/route.ts @@ -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 }); } } diff --git a/src/app/api/translator/send/route.ts b/src/app/api/translator/send/route.ts index d39cb97fb2..f53b0bc574 100644 --- a/src/app/api/translator/send/route.ts +++ b/src/app/api/translator/send/route.ts @@ -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 }); } } diff --git a/src/app/api/translator/translate/route.ts b/src/app/api/translator/translate/route.ts index b4d8baa5ab..2c3dacb4b0 100644 --- a/src/app/api/translator/translate/route.ts +++ b/src/app/api/translator/translate/route.ts @@ -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; + +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 } + ); } } diff --git a/src/app/api/usage/budget/route.ts b/src/app/api/usage/budget/route.ts index b051ed3a16..e3b484eedf 100644 --- a/src/app/api/usage/budget/route.ts +++ b/src/app/api/usage/budget/route.ts @@ -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 }); } } diff --git a/src/app/api/usage/quota/route.ts b/src/app/api/usage/quota/route.ts new file mode 100644 index 0000000000..43edd28947 --- /dev/null +++ b/src/app/api/usage/quota/route.ts @@ -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; + +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 | null, + rateStatus: Record +): 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 }); + } +} diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 9105bd8c93..92f7cd38a2 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -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); diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 2a1ab8e2a2..8c9e85159b 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -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); diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index bd21987075..8ba758f8e3 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -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; diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 5a007fd620..3c78848101 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -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}` + ); } } diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index ee2af46136..5c35c94dcf 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -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; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts new file mode 100644 index 0000000000..6833ab781f --- /dev/null +++ b/src/app/api/v1/models/catalog.ts @@ -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 = {}; + const providerIdToAlias: Record = {}; + + // 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 = { + "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 = {}; + 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 = 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 = {}; + const nodeIdToProviderType: Record = {}; + 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 = 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 } + ); + } +} diff --git a/src/app/api/v1/models/route.ts b/src/app/api/v1/models/route.ts index c8ee5cfa18..2224b13ee6 100644 --- a/src/app/api/v1/models/route.ts +++ b/src/app/api/v1/models/route.ts @@ -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 = {}; - const providerIdToAlias: Record = {}; - - // 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 = {}; - 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 = 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 = {}; - const nodeIdToProviderType: Record = {}; - 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 = 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); } diff --git a/src/app/api/v1/moderations/route.ts b/src/app/api/v1/moderations/route.ts index 753fa37ebb..5299af8613 100644 --- a/src/app/api/v1/moderations/route.ts +++ b/src/app/api/v1/moderations/route.ts @@ -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) diff --git a/src/app/api/v1/music/generations/route.ts b/src/app/api/v1/music/generations/route.ts index f7508fd113..a5cec68c50 100644 --- a/src/app/api/v1/music/generations/route.ts +++ b/src/app/api/v1/music/generations/route.ts @@ -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}` + ); } } diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index dbc8894e7e..f94a884247 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -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) { diff --git a/src/app/api/v1/providers/[provider]/embeddings/route.ts b/src/app/api/v1/providers/[provider]/embeddings/route.ts index c4fe8f0ff4..dea6fc2e81 100644 --- a/src/app/api/v1/providers/[provider]/embeddings/route.ts +++ b/src/app/api/v1/providers/[provider]/embeddings/route.ts @@ -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") { diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index 8eaf3cb6cf..6f73046f02 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -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}`; diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index 6a4b128842..42bb9b3e04 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -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); diff --git a/src/app/api/v1/route.ts b/src/app/api/v1/route.ts index cb4aff1b1a..439003f4d8 100644 --- a/src/app/api/v1/route.ts +++ b/src/app/api/v1/route.ts @@ -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, + }); } diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 24e1255698..7c348f2d22 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -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}` + ); } } diff --git a/src/app/api/v1beta/models/[...path]/route.ts b/src/app/api/v1beta/models/[...path]/route.ts index c21a786047..7ba3ac50aa 100644 --- a/src/app/api/v1beta/models/[...path]/route.ts +++ b/src/app/api/v1beta/models/[...path]/route.ts @@ -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);