mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
feat: add A2A protocol support and refactor API validation layer
Implement Agent-to-Agent (A2A) JSON-RPC 2.0 endpoint with smart routing and quota management skills, SSE streaming, and task lifecycle management. - Add /a2a route with message/send, message/stream, tasks/get, tasks/cancel - Add /.well-known/agent.json agent card endpoint - Introduce Zod-based request validation schemas for all v1 API routes - Extract shared getUnifiedModelsResponse to reduce duplication across /models, /v1/models, and /a2a model listing - Refactor chat, embeddings, moderations, and models routes to use centralized validation and error handling - Add A2A task manager, routing logger, and streaming utilities
This commit is contained in:
193
src/app/a2a/route.ts
Normal file
193
src/app/a2a/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* A2A JSON-RPC 2.0 Router — `/a2a` endpoint
|
||||
*
|
||||
* Methods:
|
||||
* - message/send — Synchronous task execution
|
||||
* - message/stream — SSE streaming execution
|
||||
* - tasks/get — Query task by ID
|
||||
* - tasks/cancel — Cancel task by ID
|
||||
*
|
||||
* Auth: Bearer token via Authorization header
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTaskManager } from "@/lib/a2a/taskManager";
|
||||
import { executeSmartRouting } from "@/lib/a2a/skills/smartRouting";
|
||||
import { executeQuotaManagement } from "@/lib/a2a/skills/quotaManagement";
|
||||
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
|
||||
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
|
||||
|
||||
// ============ Skill Registry ============
|
||||
|
||||
const SKILL_HANDLERS: Record<string, (task: any) => Promise<any>> = {
|
||||
"smart-routing": executeSmartRouting,
|
||||
"quota-management": executeQuotaManagement,
|
||||
};
|
||||
|
||||
// ============ Auth ============
|
||||
|
||||
function authenticate(req: NextRequest): boolean {
|
||||
// If no API key is configured, allow all requests
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (!configuredKey) return true;
|
||||
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
const token = authHeader.replace(/^Bearer\s+/i, "");
|
||||
return token === configuredKey;
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
|
||||
function jsonRpcError(id: string | number | null, code: number, message: string, data?: unknown) {
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: "2.0", id, error: { code, message, data } },
|
||||
{ status: code === -32600 ? 400 : code === -32601 ? 404 : code === -32603 ? 500 : 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: string | number | null, result: unknown) {
|
||||
return NextResponse.json({ jsonrpc: "2.0", id, result });
|
||||
}
|
||||
|
||||
// ============ Route Handler ============
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
if (!authenticate(req)) {
|
||||
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
|
||||
}
|
||||
|
||||
// Parse JSON-RPC body
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonRpcError(null, -32700, "Parse error: invalid JSON");
|
||||
}
|
||||
|
||||
const { jsonrpc, id, method, params } = body;
|
||||
if (jsonrpc !== "2.0" || !method) {
|
||||
return jsonRpcError(id || null, -32600, "Invalid request: missing jsonrpc or method");
|
||||
}
|
||||
|
||||
const tm = getTaskManager();
|
||||
|
||||
switch (method) {
|
||||
// ── message/send ──────────────────────────────────────
|
||||
case "message/send": {
|
||||
const skill = params?.skill || "smart-routing";
|
||||
const messages = params?.messages || params?.message?.parts;
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return jsonRpcError(id, -32602, "Invalid params: messages array required");
|
||||
}
|
||||
|
||||
const handler = SKILL_HANDLERS[skill];
|
||||
if (!handler) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
try {
|
||||
tm.updateTask(task.id, "working");
|
||||
const result = await handler(task);
|
||||
tm.updateTask(task.id, "completed", result.artifacts);
|
||||
|
||||
// Log routing decision
|
||||
if (skill === "smart-routing" && result.metadata) {
|
||||
logRoutingDecision({
|
||||
taskType: (params?.metadata?.role as string) || "general",
|
||||
comboId: (params?.metadata?.combo as string) || "default",
|
||||
providerSelected:
|
||||
result.metadata?.routing_explanation?.match(/"([^"]+)"/)?.[1] || "unknown",
|
||||
modelUsed: (params?.metadata?.model as string) || "auto",
|
||||
score: 1,
|
||||
factors: [],
|
||||
fallbacksTriggered: [],
|
||||
success: true,
|
||||
latencyMs: 0,
|
||||
cost: result.metadata?.cost_envelope?.actual || 0,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonRpcResult(id, {
|
||||
task: { id: task.id, state: "completed" },
|
||||
artifacts: result.artifacts,
|
||||
metadata: result.metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
tm.updateTask(task.id, "failed", [{ type: "error", content: msg }], msg);
|
||||
return jsonRpcError(id, -32603, `Skill execution failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── message/stream ────────────────────────────────────
|
||||
case "message/stream": {
|
||||
const skill = params?.skill || "smart-routing";
|
||||
const messages = params?.messages || params?.message?.parts;
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return jsonRpcError(id, -32602, "Invalid params: messages array required");
|
||||
}
|
||||
|
||||
const handler = SKILL_HANDLERS[skill];
|
||||
if (!handler) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
tm.updateTask(task.id, "working");
|
||||
|
||||
const stream = createA2AStream(
|
||||
task,
|
||||
async (t) => {
|
||||
const result = await handler(t);
|
||||
tm.updateTask(t.id, "completed", result.artifacts);
|
||||
return result;
|
||||
},
|
||||
req.signal
|
||||
);
|
||||
|
||||
return new Response(stream, { headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
// ── tasks/get ─────────────────────────────────────────
|
||||
case "tasks/get": {
|
||||
const taskId = params?.taskId || params?.id;
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
const task = tm.getTask(taskId);
|
||||
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
|
||||
|
||||
return jsonRpcResult(id, { task });
|
||||
}
|
||||
|
||||
// ── tasks/cancel ──────────────────────────────────────
|
||||
case "tasks/cancel": {
|
||||
const taskId = params?.taskId || params?.id;
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
try {
|
||||
const task = tm.cancelTask(taskId);
|
||||
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return jsonRpcError(id, -32603, msg);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return jsonRpcError(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent Card discovery via OPTIONS
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
Allow: "POST, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMitmAlias, setMitmAliasAll } from "@/models";
|
||||
import {
|
||||
cliMitmAliasUpdateSchema,
|
||||
isValidationFailure,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
// GET - Get MITM aliases for a tool
|
||||
export async function GET(request) {
|
||||
@@ -18,12 +23,27 @@ export async function GET(request) {
|
||||
|
||||
// PUT - Save MITM aliases for a specific tool
|
||||
export async function PUT(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const { tool, mappings } = await request.json();
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!tool || !mappings || typeof mappings !== "object") {
|
||||
return NextResponse.json({ error: "tool and mappings required" }, { status: 400 });
|
||||
try {
|
||||
const validation = validateBody(cliMitmAliasUpdateSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { tool, mappings } = validation.data;
|
||||
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [alias, model] of Object.entries(mappings)) {
|
||||
|
||||
@@ -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() || "";
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
67
src/app/api/cli-tools/openclaw/auto-order/route.ts
Normal file
67
src/app/api/cli-tools/openclaw/auto-order/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* OpenClaw Integration — Dynamic provider.order based on Auto-Combo scores.
|
||||
*
|
||||
* GET /api/cli-tools/openclaw/auto-order
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch current health and combos to determine best provider ordering
|
||||
const [healthRes, combosRes] = await Promise.allSettled([
|
||||
fetch(`${OMNIROUTE_BASE_URL}/api/monitoring/health`, { signal: AbortSignal.timeout(5000) }),
|
||||
fetch(`${OMNIROUTE_BASE_URL}/api/combos`, { signal: AbortSignal.timeout(5000) }),
|
||||
]);
|
||||
|
||||
const health = healthRes.status === "fulfilled" ? await healthRes.value.json() : {};
|
||||
const combos = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
|
||||
|
||||
// Build provider scores from circuit breaker state
|
||||
const breakers: any[] = health?.circuitBreakers || [];
|
||||
const providerScores = new Map<string, number>();
|
||||
|
||||
// Start all providers with base score
|
||||
const allProviders = new Set<string>();
|
||||
if (Array.isArray(combos)) {
|
||||
for (const combo of combos) {
|
||||
for (const model of combo.models || combo.data?.models || []) {
|
||||
allProviders.add(model.provider);
|
||||
providerScores.set(model.provider, (providerScores.get(model.provider) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust by circuit breaker state
|
||||
for (const cb of breakers) {
|
||||
const current = providerScores.get(cb.provider) || 0;
|
||||
if (cb.state === "OPEN") providerScores.set(cb.provider, current * 0.1);
|
||||
else if (cb.state === "HALF_OPEN") providerScores.set(cb.provider, current * 0.5);
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
const ordered = [...providerScores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([provider]) => provider);
|
||||
|
||||
return NextResponse.json({
|
||||
provider: {
|
||||
order: ordered,
|
||||
allow_fallbacks: true,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
source: "omniroute-auto-combo",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
provider: {
|
||||
order: ["anthropic", "google", "openai"],
|
||||
allow_fallbacks: true,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
source: "omniroute-fallback",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
76
src/app/api/combos/auto/route.ts
Normal file
76
src/app/api/combos/auto/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Auto-Combo REST API — `/api/combos/auto`
|
||||
*
|
||||
* POST — Create auto-combo
|
||||
* GET — List all auto-combos
|
||||
*
|
||||
* Note: Auto-combo state is managed in-memory by the engine module.
|
||||
* The open-sse/services/autoCombo module is outside Next.js src/,
|
||||
* so we use a lightweight in-memory store here that mirrors the engine API.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// ── In-memory auto-combo store (mirrors open-sse/services/autoCombo/engine.ts) ──
|
||||
|
||||
interface ScoringWeights {
|
||||
quota: number;
|
||||
health: number;
|
||||
costInv: number;
|
||||
latencyInv: number;
|
||||
taskFit: number;
|
||||
stability: number;
|
||||
}
|
||||
|
||||
const DEFAULT_WEIGHTS: ScoringWeights = {
|
||||
quota: 0.2,
|
||||
health: 0.25,
|
||||
costInv: 0.2,
|
||||
latencyInv: 0.15,
|
||||
taskFit: 0.1,
|
||||
stability: 0.1,
|
||||
};
|
||||
|
||||
interface AutoComboConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "auto";
|
||||
candidatePool: string[];
|
||||
weights: ScoringWeights;
|
||||
modePack?: string;
|
||||
budgetCap?: number;
|
||||
explorationRate: number;
|
||||
}
|
||||
|
||||
const autoCombos = new Map<string, AutoComboConfig>();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { id, name, candidatePool, weights, modePack, budgetCap, explorationRate } = body;
|
||||
|
||||
if (!id || !name) {
|
||||
return NextResponse.json({ error: "id and name required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config: AutoComboConfig = {
|
||||
id,
|
||||
name,
|
||||
type: "auto",
|
||||
candidatePool: candidatePool || [],
|
||||
weights: weights || DEFAULT_WEIGHTS,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate: explorationRate ?? 0.05,
|
||||
};
|
||||
autoCombos.set(id, config);
|
||||
|
||||
return NextResponse.json(config, { status: 201 });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ combos: [...autoCombos.values()] });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listSuites, runSuite } from "@/lib/evals/evalRunner";
|
||||
import { evalRunSuiteSchema, isValidationFailure, validateBody } from "@/shared/validation/schemas";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -11,14 +12,27 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const { suiteId, outputs } = await request.json();
|
||||
if (!suiteId || !outputs) {
|
||||
return NextResponse.json(
|
||||
{ error: "suiteId and outputs (Record<caseId, actualOutput>) are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(evalRunSuiteSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { suiteId, outputs } = validation.data;
|
||||
const result = runSuite(suiteId, outputs);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function GET() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({ status: "error", error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ status: "error", error: "Health check failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,6 @@ export async function DELETE() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] DELETE /api/monitoring/health error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ error: "Failed to reset circuit breakers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
removeCustomModel,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
isValidationFailure,
|
||||
providerModelMutationSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
/**
|
||||
* GET /api/provider-models?provider=<id>
|
||||
@@ -39,6 +44,16 @@ export async function GET(request) {
|
||||
* Body: { provider, modelId, modelName? }
|
||||
*/
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
@@ -48,21 +63,18 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { provider, modelId, modelName, source } = body;
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return Response.json(
|
||||
{ error: { message: "provider and modelId are required", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
const validation = validateBody(providerModelMutationSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { provider, modelId, modelName, source } = validation.data;
|
||||
|
||||
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
console.error("Error adding provider model:", error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: "Failed to add provider model", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -100,8 +112,9 @@ export async function DELETE(request) {
|
||||
const removed = await removeCustomModel(provider, modelId);
|
||||
return Response.json({ removed });
|
||||
} catch (error) {
|
||||
console.error("Error removing provider model:", error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: "Failed to remove provider model", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import {
|
||||
isValidationFailure,
|
||||
updateResilienceSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
/**
|
||||
* GET /api/resilience — Get current resilience configuration and status
|
||||
@@ -36,59 +41,27 @@ export async function GET() {
|
||||
* PATCH /api/resilience — Update provider resilience profiles and/or rate limit defaults
|
||||
*/
|
||||
export async function PATCH(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { profiles, defaults } = body;
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profiles && !defaults) {
|
||||
return NextResponse.json({ error: "Must provide profiles or defaults" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate profiles if provided
|
||||
if (profiles) {
|
||||
if (typeof profiles !== "object") {
|
||||
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
|
||||
}
|
||||
for (const [key, profile] of Object.entries(profiles)) {
|
||||
if (!["oauth", "apikey"].includes(key)) {
|
||||
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
|
||||
}
|
||||
const required = [
|
||||
"transientCooldown",
|
||||
"rateLimitCooldown",
|
||||
"maxBackoffLevel",
|
||||
"circuitBreakerThreshold",
|
||||
"circuitBreakerReset",
|
||||
];
|
||||
for (const field of required) {
|
||||
if (typeof profile[field] !== "number" || profile[field] < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate defaults if provided
|
||||
if (defaults) {
|
||||
if (typeof defaults !== "object") {
|
||||
return NextResponse.json({ error: "Invalid defaults payload" }, { status: 400 });
|
||||
}
|
||||
const validKeys = ["requestsPerMinute", "minTimeBetweenRequests", "concurrentRequests"];
|
||||
for (const key of validKeys) {
|
||||
if (
|
||||
defaults[key] !== undefined &&
|
||||
(typeof defaults[key] !== "number" || defaults[key] < 1)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid defaults.${key}: must be a positive number` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const validation = validateBody(updateResilienceSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { profiles, defaults } = validation.data;
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
if (profiles) updates.providerProfiles = profiles;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import {
|
||||
isValidationFailure,
|
||||
updateComboDefaultsSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
/**
|
||||
* GET /api/settings/combo-defaults
|
||||
@@ -33,8 +38,28 @@ export async function GET() {
|
||||
* Body: { comboDefaults?: {...}, providerOverrides?: {...} }
|
||||
*/
|
||||
export async function PATCH(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const body = await request.json();
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateComboDefaultsSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const body = validation.data;
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (body.comboDefaults) {
|
||||
@@ -44,10 +69,6 @@ export async function PATCH(request) {
|
||||
updates.providerOverrides = body.providerOverrides;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings: any = await updateSettings(updates);
|
||||
return NextResponse.json({
|
||||
comboDefaults: settings.comboDefaults || {},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -6,8 +6,18 @@ import {
|
||||
resolveProxyForConnection,
|
||||
} from "../../../../lib/localDb";
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import {
|
||||
isValidationFailure,
|
||||
updateProxyConfigSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
import type { z } from "zod";
|
||||
|
||||
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
|
||||
type UpdateProxyConfigInput = z.infer<typeof updateProxyConfigSchema>;
|
||||
type ProxyConfigInput = NonNullable<UpdateProxyConfigInput["proxy"]>;
|
||||
type ProxyMapInput = Record<string, ProxyConfigInput | null>;
|
||||
type ApiRouteError = Error & { status?: number; type?: string };
|
||||
|
||||
function isSocks5Enabled() {
|
||||
return process.env.ENABLE_SOCKS5_PROXY === "true";
|
||||
@@ -24,20 +34,30 @@ function supportedTypesMessage() {
|
||||
return isSocks5Enabled() ? "http, https, or socks5" : "http or https";
|
||||
}
|
||||
|
||||
function createInvalidProxyError(message: string) {
|
||||
const error: any = new Error(message);
|
||||
function createInvalidProxyError(message: string): ApiRouteError {
|
||||
const error = new Error(message) as ApiRouteError;
|
||||
error.status = 400;
|
||||
error.type = "invalid_request";
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeAndValidateProxy(proxy, pathLabel) {
|
||||
function toApiRouteError(error: unknown): ApiRouteError {
|
||||
if (error instanceof Error) {
|
||||
return error as ApiRouteError;
|
||||
}
|
||||
return new Error("Unexpected error") as ApiRouteError;
|
||||
}
|
||||
|
||||
function normalizeAndValidateProxy(
|
||||
proxy: ProxyConfigInput | null | undefined,
|
||||
pathLabel: string
|
||||
): ProxyConfigInput | null | undefined {
|
||||
if (proxy === null || proxy === undefined) return proxy;
|
||||
if (typeof proxy !== "object" || Array.isArray(proxy)) {
|
||||
throw createInvalidProxyError(`${pathLabel} must be an object`);
|
||||
}
|
||||
|
||||
const type = String(proxy.type || "http").toLowerCase();
|
||||
const type = String(proxy.type || "http").toLowerCase() as NonNullable<ProxyConfigInput["type"]>;
|
||||
if (type === "socks5" && !isSocks5Enabled()) {
|
||||
throw createInvalidProxyError(
|
||||
"SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
|
||||
@@ -50,23 +70,27 @@ function normalizeAndValidateProxy(proxy, pathLabel) {
|
||||
throw createInvalidProxyError(`${pathLabel}.type must be ${supportedTypesMessage()}`);
|
||||
}
|
||||
|
||||
return { ...proxy, type };
|
||||
return { ...proxy, type } as ProxyConfigInput;
|
||||
}
|
||||
|
||||
function normalizeAndValidateProxyMap(proxyMap, mapName) {
|
||||
function normalizeAndValidateProxyMap(
|
||||
proxyMap: ProxyMapInput | undefined,
|
||||
mapName: string
|
||||
): ProxyMapInput | undefined {
|
||||
if (proxyMap === undefined) return undefined;
|
||||
if (proxyMap === null || typeof proxyMap !== "object" || Array.isArray(proxyMap)) {
|
||||
throw createInvalidProxyError(`${mapName} must be an object`);
|
||||
}
|
||||
|
||||
const normalizedMap = { ...proxyMap };
|
||||
for (const [id, proxy] of Object.entries(proxyMap)) {
|
||||
normalizedMap[id] = normalizeAndValidateProxy(proxy, `${mapName}.${id}`);
|
||||
const normalizedMap: ProxyMapInput = { ...proxyMap };
|
||||
for (const [id, proxy] of Object.entries(proxyMap) as Array<[string, ProxyConfigInput | null]>) {
|
||||
const normalizedProxy = normalizeAndValidateProxy(proxy, `${mapName}.${id}`);
|
||||
normalizedMap[id] = normalizedProxy ?? null;
|
||||
}
|
||||
return normalizedMap;
|
||||
}
|
||||
|
||||
function normalizeProxyPayload(body) {
|
||||
function normalizeProxyPayload(body: UpdateProxyConfigInput): UpdateProxyConfigInput {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw createInvalidProxyError("Request body must be an object");
|
||||
}
|
||||
@@ -91,7 +115,7 @@ function normalizeProxyPayload(body) {
|
||||
* Optional query params: ?level=global|provider|combo|key&id=xxx
|
||||
* Or: ?resolve=connectionId to resolve effective proxy
|
||||
*/
|
||||
export async function GET(request) {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const level = searchParams.get("level");
|
||||
@@ -114,8 +138,9 @@ export async function GET(request) {
|
||||
const config = await getProxyConfig();
|
||||
return Response.json(config);
|
||||
} catch (error) {
|
||||
const routeError = toApiRouteError(error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: routeError.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -125,17 +150,41 @@ export async function GET(request) {
|
||||
* PUT /api/settings/proxy — update proxy configuration
|
||||
* Body: { level, id?, proxy } or legacy { global?, providers? }
|
||||
*/
|
||||
export async function PUT(request) {
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
const body = await request.json();
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateProxyConfigSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = validation.data;
|
||||
const normalizedBody = normalizeProxyPayload(body);
|
||||
const updated = await setProxyConfig(normalizedBody);
|
||||
clearDispatcherCache();
|
||||
return Response.json(updated);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 500;
|
||||
const type = error?.type || (status === 400 ? "invalid_request" : "server_error");
|
||||
return Response.json({ error: { message: error.message, type } }, { status });
|
||||
const routeError = toApiRouteError(error);
|
||||
const status = Number(routeError.status) || 500;
|
||||
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
|
||||
return Response.json({ error: { message: routeError.message, type } }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +192,7 @@ export async function PUT(request) {
|
||||
* DELETE /api/settings/proxy — remove proxy at a level
|
||||
* Query: ?level=provider&id=xxx
|
||||
*/
|
||||
export async function DELETE(request) {
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const level = searchParams.get("level");
|
||||
@@ -160,8 +209,9 @@ export async function DELETE(request) {
|
||||
clearDispatcherCache();
|
||||
return Response.json(updated);
|
||||
} catch (error) {
|
||||
const routeError = toApiRouteError(error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: routeError.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import bcrypt from "bcryptjs";
|
||||
import {
|
||||
isValidationFailure,
|
||||
updateRequireLoginSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -17,8 +22,27 @@ export async function GET() {
|
||||
* Used by the onboarding wizard security step.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const body = await request.json();
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateRequireLoginSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const body = validation.data;
|
||||
const { requireLogin, password } = body;
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
@@ -27,18 +51,9 @@ export async function POST(request: Request) {
|
||||
updates.requireLogin = requireLogin;
|
||||
}
|
||||
|
||||
if (password && typeof password === "string" && password.length >= 4) {
|
||||
if (password) {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
updates.password = hashedPassword;
|
||||
} else if (password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 4 characters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "No valid fields to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateSettings(updates);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,54 @@ import {
|
||||
import { translateRequest } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { FORMATS } from "@omniroute/open-sse/translator/formats.ts";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import {
|
||||
isValidationFailure,
|
||||
translatorTranslateSchema,
|
||||
validateBody,
|
||||
} from "@/shared/validation/schemas";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asJsonRecord(value: unknown): JsonRecord {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as JsonRecord;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getActualBody(body: JsonRecord): JsonRecord {
|
||||
const nested = asJsonRecord(body.body);
|
||||
return Object.keys(nested).length > 0 ? nested : body;
|
||||
}
|
||||
|
||||
function getModelId(value: JsonRecord): string {
|
||||
const model = value.model;
|
||||
return typeof model === "string" && model.trim().length > 0 ? model : "test-model";
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
const reqData = await request.json();
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(translatorTranslateSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ success: false, error: validation.error }, { status: 400 });
|
||||
}
|
||||
const reqData = validation.data;
|
||||
const {
|
||||
step,
|
||||
provider,
|
||||
@@ -19,18 +63,13 @@ export async function POST(request) {
|
||||
sourceFormat: reqSourceFormat,
|
||||
targetFormat: reqTargetFormat,
|
||||
} = reqData;
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ success: false, error: "Body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
// Direct translation mode (Playground): sourceFormat → targetFormat in one shot
|
||||
if (step === "direct") {
|
||||
const src = reqSourceFormat || detectFormat(body);
|
||||
const tgt = reqTargetFormat || (provider ? getTargetFormat(provider) : "openai");
|
||||
const model = body.model || "test-model";
|
||||
const model = getModelId(asJsonRecord(body));
|
||||
const translated = translateRequest(src, tgt, model, body, true, null, provider);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -40,18 +79,11 @@ export async function POST(request) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!step || !provider) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Step and provider are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case 1: {
|
||||
// Step 1: Client → Source (detect format)
|
||||
// Return format: { timestamp, endpoint, headers, body }
|
||||
const actualBody = body.body || body;
|
||||
const actualBody = getActualBody(asJsonRecord(body));
|
||||
const sourceFormat = detectFormat(actualBody);
|
||||
|
||||
result = {
|
||||
@@ -67,10 +99,10 @@ export async function POST(request) {
|
||||
case 2: {
|
||||
// Step 2: Source → OpenAI
|
||||
// Return format: { timestamp, headers: {}, body }
|
||||
const actualBody = body.body || body;
|
||||
const actualBody = getActualBody(asJsonRecord(body));
|
||||
const sourceFormat = detectFormat(actualBody);
|
||||
const targetFormat = FORMATS.OPENAI;
|
||||
const model = actualBody.model || "test-model";
|
||||
const model = getModelId(actualBody);
|
||||
const translated = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
@@ -92,10 +124,10 @@ export async function POST(request) {
|
||||
case 3: {
|
||||
// Step 3: OpenAI → Target
|
||||
// Return format: { timestamp, body }
|
||||
const actualBody = body.body || body;
|
||||
const actualBody = getActualBody(asJsonRecord(body));
|
||||
const sourceFormat = FORMATS.OPENAI;
|
||||
const targetFormat = getTargetFormat(provider);
|
||||
const model = actualBody.model || "test-model";
|
||||
const model = getModelId(actualBody);
|
||||
const translated = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
@@ -116,8 +148,8 @@ export async function POST(request) {
|
||||
case 4: {
|
||||
// Step 4: Build final request with real URL and headers
|
||||
// Return format: { timestamp, url, headers, body }
|
||||
const actualBody = body.body || body;
|
||||
const model = actualBody.model || "test-model";
|
||||
const actualBody = getActualBody(asJsonRecord(body));
|
||||
const model = getModelId(actualBody);
|
||||
|
||||
// Get provider credentials
|
||||
const connections = await getProviderConnections({ provider });
|
||||
@@ -165,6 +197,9 @@ export async function POST(request) {
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error("Error translating:", error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Failed to translate request" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
172
src/app/api/usage/quota/route.ts
Normal file
172
src/app/api/usage/quota/route.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import {
|
||||
getLearnedLimits,
|
||||
getRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.ts";
|
||||
import {
|
||||
normalizeQuotaResponse,
|
||||
sanitizeQuotaProvider,
|
||||
type QuotaProviderEntry,
|
||||
type QuotaTokenStatus,
|
||||
} from "@/shared/contracts/quota";
|
||||
|
||||
type ProviderConnectionRecord = Record<string, unknown>;
|
||||
|
||||
function toDateMs(value: unknown): number | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function deriveTokenStatus(connection: ProviderConnectionRecord): QuotaTokenStatus {
|
||||
const testStatus =
|
||||
typeof connection.testStatus === "string" ? connection.testStatus.toLowerCase() : "";
|
||||
|
||||
if (testStatus === "expired") return "expired";
|
||||
if (testStatus === "refreshing") return "refreshing";
|
||||
|
||||
const expiresAtMs = toDateMs(connection.tokenExpiresAt);
|
||||
if (expiresAtMs !== null) {
|
||||
const msRemaining = expiresAtMs - Date.now();
|
||||
if (msRemaining <= 0) return "expired";
|
||||
if (msRemaining <= 15 * 60 * 1000) return "expiring";
|
||||
}
|
||||
|
||||
const lastErrorType =
|
||||
typeof connection.lastErrorType === "string" ? connection.lastErrorType.toLowerCase() : "";
|
||||
if (lastErrorType.includes("refresh")) return "expiring";
|
||||
|
||||
return "valid";
|
||||
}
|
||||
|
||||
function buildQuotaEntry(
|
||||
connection: ProviderConnectionRecord,
|
||||
learnedLimit: Record<string, unknown> | null,
|
||||
rateStatus: Record<string, unknown>
|
||||
): QuotaProviderEntry {
|
||||
const provider =
|
||||
typeof connection.provider === "string" && connection.provider.trim()
|
||||
? connection.provider
|
||||
: "unknown";
|
||||
const connectionId =
|
||||
typeof connection.id === "string" && connection.id.trim() ? connection.id : "unknown";
|
||||
const name =
|
||||
(typeof connection.name === "string" && connection.name.trim()) ||
|
||||
(typeof connection.email === "string" && connection.email.trim()) ||
|
||||
provider;
|
||||
|
||||
const resetAt =
|
||||
typeof connection.rateLimitedUntil === "string" && connection.rateLimitedUntil.trim()
|
||||
? connection.rateLimitedUntil
|
||||
: null;
|
||||
|
||||
let quotaTotal: number | null = null;
|
||||
let quotaUsed = 0;
|
||||
let percentRemaining = 100;
|
||||
|
||||
const learnedLimitValue =
|
||||
learnedLimit && typeof learnedLimit.limit === "number" && Number.isFinite(learnedLimit.limit)
|
||||
? learnedLimit.limit
|
||||
: null;
|
||||
const learnedRemainingValue =
|
||||
learnedLimit &&
|
||||
typeof learnedLimit.remaining === "number" &&
|
||||
Number.isFinite(learnedLimit.remaining)
|
||||
? learnedLimit.remaining
|
||||
: null;
|
||||
|
||||
if (learnedLimitValue !== null && learnedLimitValue > 0) {
|
||||
quotaTotal = learnedLimitValue;
|
||||
const remaining =
|
||||
learnedRemainingValue !== null
|
||||
? Math.min(Math.max(learnedRemainingValue, 0), learnedLimitValue)
|
||||
: learnedLimitValue;
|
||||
quotaUsed = learnedLimitValue - remaining;
|
||||
percentRemaining = (remaining / learnedLimitValue) * 100;
|
||||
} else {
|
||||
const resetAtMs = toDateMs(resetAt);
|
||||
if (resetAtMs !== null && resetAtMs > Date.now()) {
|
||||
quotaTotal = 100;
|
||||
quotaUsed = 100;
|
||||
percentRemaining = 0;
|
||||
} else {
|
||||
// Fallback synthetic signal from queue pressure when limit headers are unavailable.
|
||||
const queued = typeof rateStatus.queued === "number" ? rateStatus.queued : 0;
|
||||
const running = typeof rateStatus.running === "number" ? rateStatus.running : 0;
|
||||
const executing = typeof rateStatus.executing === "number" ? rateStatus.executing : 0;
|
||||
|
||||
const syntheticUsage = Math.min(95, queued * 10 + running * 5 + executing * 3);
|
||||
if (syntheticUsage > 0) {
|
||||
quotaTotal = 100;
|
||||
quotaUsed = syntheticUsage;
|
||||
percentRemaining = 100 - syntheticUsage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeQuotaProvider({
|
||||
name,
|
||||
provider,
|
||||
connectionId,
|
||||
quotaUsed,
|
||||
quotaTotal,
|
||||
percentRemaining,
|
||||
resetAt,
|
||||
tokenStatus: deriveTokenStatus(connection),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/usage/quota
|
||||
*
|
||||
* Query params:
|
||||
* - provider (optional): filter by provider slug
|
||||
* - connectionId (optional): filter by provider connection id
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerFilter = searchParams.get("provider");
|
||||
const connectionIdFilter = searchParams.get("connectionId");
|
||||
|
||||
const connectionsRaw = await getProviderConnections({ isActive: true });
|
||||
let connections = Array.isArray(connectionsRaw) ? connectionsRaw : [];
|
||||
|
||||
if (providerFilter) {
|
||||
connections = connections.filter((conn) => conn.provider === providerFilter);
|
||||
}
|
||||
if (connectionIdFilter) {
|
||||
connections = connections.filter((conn) => conn.id === connectionIdFilter);
|
||||
}
|
||||
|
||||
const learnedLimits = getLearnedLimits();
|
||||
const providers = connections.map((conn) => {
|
||||
const learnedLimit = learnedLimits?.[`${conn.provider}:${conn.id}`] || null;
|
||||
const rateStatus = getRateLimitStatus(conn.provider, conn.id);
|
||||
return buildQuotaEntry(conn, learnedLimit, rateStatus);
|
||||
});
|
||||
|
||||
const response = normalizeQuotaResponse(
|
||||
{
|
||||
providers,
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
filters: {
|
||||
provider: providerFilter,
|
||||
connectionId: connectionIdFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: providerFilter,
|
||||
connectionId: connectionIdFilter,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/usage/quota error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch quota data" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
406
src/app/api/v1/models/catalog.ts
Normal file
406
src/app/api/v1/models/catalog.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import {
|
||||
getProviderConnections,
|
||||
getCombos,
|
||||
getAllCustomModels,
|
||||
getSettings,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
|
||||
|
||||
const FALLBACK_ALIAS_TO_PROVIDER = {
|
||||
ag: "antigravity",
|
||||
cc: "claude",
|
||||
cl: "cline",
|
||||
cu: "cursor",
|
||||
cx: "codex",
|
||||
gc: "gemini-cli",
|
||||
gh: "github",
|
||||
if: "iflow",
|
||||
kc: "kilocode",
|
||||
kmc: "kimi-coding",
|
||||
kr: "kiro",
|
||||
qw: "qwen",
|
||||
};
|
||||
|
||||
function buildAliasMaps() {
|
||||
const aliasToProviderId: Record<string, string> = {};
|
||||
const providerIdToAlias: Record<string, string> = {};
|
||||
|
||||
// Canonical source for ID/alias pairs used across dashboard/provider config.
|
||||
for (const provider of Object.values(AI_PROVIDERS)) {
|
||||
const providerId = provider?.id;
|
||||
const alias = provider?.alias || providerId;
|
||||
if (!providerId) continue;
|
||||
aliasToProviderId[providerId] = providerId;
|
||||
aliasToProviderId[alias] = providerId;
|
||||
if (!providerIdToAlias[providerId]) {
|
||||
providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [left, right] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
|
||||
// Handle both possible directions:
|
||||
// - providerId -> alias
|
||||
// - alias -> providerId
|
||||
if (PROVIDER_MODELS[left]) {
|
||||
aliasToProviderId[left] = aliasToProviderId[left] || right;
|
||||
continue;
|
||||
}
|
||||
if (PROVIDER_MODELS[right]) {
|
||||
aliasToProviderId[right] = aliasToProviderId[right] || left;
|
||||
continue;
|
||||
}
|
||||
aliasToProviderId[right] = aliasToProviderId[right] || left;
|
||||
}
|
||||
|
||||
for (const alias of Object.keys(PROVIDER_MODELS)) {
|
||||
if (!aliasToProviderId[alias]) {
|
||||
aliasToProviderId[alias] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [alias, providerId] of Object.entries(aliasToProviderId)) {
|
||||
if (!providerIdToAlias[providerId]) {
|
||||
providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety net for environments where alias maps are partially loaded during
|
||||
// module initialization/circular imports.
|
||||
for (const [alias, providerId] of Object.entries(FALLBACK_ALIAS_TO_PROVIDER)) {
|
||||
if (!aliasToProviderId[alias]) aliasToProviderId[alias] = providerId;
|
||||
if (!aliasToProviderId[providerId]) aliasToProviderId[providerId] = providerId;
|
||||
if (!providerIdToAlias[providerId]) providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
|
||||
return { aliasToProviderId, providerIdToAlias };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build unified OpenAI-compatible model catalog response.
|
||||
* Reused by `/api/v1/models` and `/api/v1` to avoid semantic drift (T09).
|
||||
*/
|
||||
export async function getUnifiedModelsResponse(
|
||||
request: Request,
|
||||
corsHeaders: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// Issue #100: Optionally require authentication for /models (security hardening)
|
||||
// When enabled, unauthenticated requests get 401 with proper error response.
|
||||
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
|
||||
let settings: Record<string, any> = {};
|
||||
try {
|
||||
settings = await getSettings();
|
||||
} catch {}
|
||||
if (settings.requireAuthForModels === true) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Authentication required",
|
||||
type: "invalid_request_error",
|
||||
code: "invalid_api_key",
|
||||
},
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
|
||||
|
||||
// Issue #96: Allow blocking specific providers from the models list
|
||||
const blockedProviders: Set<string> = new Set(
|
||||
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
|
||||
);
|
||||
|
||||
// Get active provider connections
|
||||
let connections = [];
|
||||
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
totalConnectionCount = connections.length;
|
||||
// Filter to only active connections
|
||||
connections = connections.filter((c) => c.isActive !== false);
|
||||
} catch (e) {
|
||||
// If database not available, show no provider models (safe default)
|
||||
console.log("Could not fetch providers, showing only combos/custom models");
|
||||
}
|
||||
|
||||
// Get provider nodes (for compatible providers with custom prefixes)
|
||||
let providerNodes = [];
|
||||
try {
|
||||
providerNodes = await getProviderNodes();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch provider nodes");
|
||||
}
|
||||
|
||||
// Build map of provider node ID to prefix and type for compatible providers
|
||||
const providerIdToPrefix: Record<string, string> = {};
|
||||
const nodeIdToProviderType: Record<string, string> = {};
|
||||
for (const node of providerNodes) {
|
||||
if (node.prefix) {
|
||||
providerIdToPrefix[node.id] = node.prefix;
|
||||
}
|
||||
if (node.type) {
|
||||
nodeIdToProviderType[node.id] = node.type;
|
||||
}
|
||||
}
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
try {
|
||||
combos = await getCombos();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch combos");
|
||||
}
|
||||
|
||||
// Build set of active provider aliases
|
||||
const activeAliases = new Set();
|
||||
for (const conn of connections) {
|
||||
const alias = providerIdToAlias[conn.provider] || conn.provider;
|
||||
activeAliases.add(alias);
|
||||
activeAliases.add(conn.provider);
|
||||
}
|
||||
|
||||
// Collect models from active providers (or all if none active)
|
||||
const models = [];
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Add combos first (they appear at the top) — only active ones
|
||||
for (const combo of combos) {
|
||||
if (combo.isActive === false) continue;
|
||||
models.push({
|
||||
id: combo.name,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
permission: [],
|
||||
root: combo.name,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Add provider models (chat)
|
||||
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
const providerId = aliasToProviderId[alias] || alias;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
|
||||
|
||||
// Skip blocked providers (Issue #96)
|
||||
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
|
||||
|
||||
// Only include models from providers with active connections
|
||||
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const model of providerModels) {
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: null,
|
||||
});
|
||||
|
||||
// Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model).
|
||||
// This improves compatibility for clients that expect full provider names.
|
||||
if (canonicalProviderId !== alias) {
|
||||
models.push({
|
||||
id: `${canonicalProviderId}/${model.id}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: aliasId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: check if a provider is active (by provider id or alias)
|
||||
const isProviderActive = (provider: string) => {
|
||||
if (activeAliases.size === 0) return false; // No active connections = show nothing
|
||||
const alias = providerIdToAlias[provider] || provider;
|
||||
return activeAliases.has(alias) || activeAliases.has(provider);
|
||||
};
|
||||
|
||||
// Add embedding models (filtered by active providers)
|
||||
for (const embModel of getAllEmbeddingModels()) {
|
||||
if (!isProviderActive(embModel.provider)) continue;
|
||||
models.push({
|
||||
id: embModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: embModel.provider,
|
||||
type: "embedding",
|
||||
dimensions: embModel.dimensions,
|
||||
});
|
||||
}
|
||||
|
||||
// Add image models (filtered by active providers)
|
||||
for (const imgModel of getAllImageModels()) {
|
||||
if (!isProviderActive(imgModel.provider)) continue;
|
||||
models.push({
|
||||
id: imgModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: imgModel.provider,
|
||||
type: "image",
|
||||
supported_sizes: imgModel.supportedSizes,
|
||||
});
|
||||
}
|
||||
|
||||
// Add rerank models (filtered by active providers)
|
||||
for (const rerankModel of getAllRerankModels()) {
|
||||
if (!isProviderActive(rerankModel.provider)) continue;
|
||||
models.push({
|
||||
id: rerankModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: rerankModel.provider,
|
||||
type: "rerank",
|
||||
});
|
||||
}
|
||||
|
||||
// Add audio models (filtered by active providers)
|
||||
for (const audioModel of getAllAudioModels()) {
|
||||
if (!isProviderActive(audioModel.provider)) continue;
|
||||
models.push({
|
||||
id: audioModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: audioModel.provider,
|
||||
type: "audio",
|
||||
subtype: audioModel.subtype,
|
||||
});
|
||||
}
|
||||
|
||||
// Add moderation models (filtered by active providers)
|
||||
for (const modModel of getAllModerationModels()) {
|
||||
if (!isProviderActive(modModel.provider)) continue;
|
||||
models.push({
|
||||
id: modModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: modModel.provider,
|
||||
type: "moderation",
|
||||
});
|
||||
}
|
||||
|
||||
// Add video models (local providers always listed, cloud filtered by active)
|
||||
for (const videoModel of getAllVideoModels()) {
|
||||
const vConfig = getVideoProvider(videoModel.provider);
|
||||
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
|
||||
models.push({
|
||||
id: videoModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: videoModel.provider,
|
||||
type: "video",
|
||||
});
|
||||
}
|
||||
|
||||
// Add music models (local providers always listed, cloud filtered by active)
|
||||
for (const musicModel of getAllMusicModels()) {
|
||||
const mConfig = getMusicProvider(musicModel.provider);
|
||||
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
|
||||
models.push({
|
||||
id: musicModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: musicModel.provider,
|
||||
type: "music",
|
||||
});
|
||||
}
|
||||
|
||||
// Add custom models (user-defined)
|
||||
try {
|
||||
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
|
||||
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
|
||||
// For compatible providers, use the prefix from provider nodes
|
||||
const prefix = providerIdToPrefix[providerId];
|
||||
const alias = prefix || providerIdToAlias[providerId] || providerId;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
|
||||
|
||||
// Only include if provider is active — check alias, canonical ID, raw providerId,
|
||||
// or the parent provider type (for compatible providers whose node ID is a UUID)
|
||||
const parentProviderType = nodeIdToProviderType[providerId];
|
||||
if (
|
||||
!activeAliases.has(alias) &&
|
||||
!activeAliases.has(canonicalProviderId) &&
|
||||
!activeAliases.has(providerId) &&
|
||||
!(parentProviderType && activeAliases.has(parentProviderType))
|
||||
)
|
||||
continue;
|
||||
|
||||
for (const model of providerCustomModels) {
|
||||
// Skip if already added as built-in
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
if (models.some((m) => m.id === aliasId)) continue;
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: null,
|
||||
custom: true,
|
||||
});
|
||||
|
||||
// Only add provider-prefixed version if different from alias
|
||||
if (canonicalProviderId !== alias && !prefix) {
|
||||
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
|
||||
if (models.some((m) => m.id === providerPrefixedId)) continue;
|
||||
models.push({
|
||||
id: providerPrefixedId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: aliasId,
|
||||
custom: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not fetch custom models");
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
object: "list",
|
||||
data: models,
|
||||
},
|
||||
{
|
||||
headers: corsHeaders,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
return Response.json(
|
||||
{ error: { message: (error as any).message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,5 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import {
|
||||
getProviderConnections,
|
||||
getCombos,
|
||||
getAllCustomModels,
|
||||
getSettings,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
|
||||
|
||||
const FALLBACK_ALIAS_TO_PROVIDER = {
|
||||
ag: "antigravity",
|
||||
cc: "claude",
|
||||
cl: "cline",
|
||||
cu: "cursor",
|
||||
cx: "codex",
|
||||
gc: "gemini-cli",
|
||||
gh: "github",
|
||||
if: "iflow",
|
||||
kc: "kilocode",
|
||||
kmc: "kimi-coding",
|
||||
kr: "kiro",
|
||||
qw: "qwen",
|
||||
};
|
||||
|
||||
function buildAliasMaps() {
|
||||
const aliasToProviderId: Record<string, string> = {};
|
||||
const providerIdToAlias: Record<string, string> = {};
|
||||
|
||||
// Canonical source for ID/alias pairs used across dashboard/provider config.
|
||||
for (const provider of Object.values(AI_PROVIDERS)) {
|
||||
const providerId = provider?.id;
|
||||
const alias = provider?.alias || providerId;
|
||||
if (!providerId) continue;
|
||||
aliasToProviderId[providerId] = providerId;
|
||||
aliasToProviderId[alias] = providerId;
|
||||
if (!providerIdToAlias[providerId]) {
|
||||
providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [left, right] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
|
||||
// Handle both possible directions:
|
||||
// - providerId -> alias
|
||||
// - alias -> providerId
|
||||
if (PROVIDER_MODELS[left]) {
|
||||
aliasToProviderId[left] = aliasToProviderId[left] || right;
|
||||
continue;
|
||||
}
|
||||
if (PROVIDER_MODELS[right]) {
|
||||
aliasToProviderId[right] = aliasToProviderId[right] || left;
|
||||
continue;
|
||||
}
|
||||
aliasToProviderId[right] = aliasToProviderId[right] || left;
|
||||
}
|
||||
|
||||
for (const alias of Object.keys(PROVIDER_MODELS)) {
|
||||
if (!aliasToProviderId[alias]) {
|
||||
aliasToProviderId[alias] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [alias, providerId] of Object.entries(aliasToProviderId)) {
|
||||
if (!providerIdToAlias[providerId]) {
|
||||
providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety net for environments where alias maps are partially loaded during
|
||||
// module initialization/circular imports.
|
||||
for (const [alias, providerId] of Object.entries(FALLBACK_ALIAS_TO_PROVIDER)) {
|
||||
if (!aliasToProviderId[alias]) aliasToProviderId[alias] = providerId;
|
||||
if (!aliasToProviderId[providerId]) aliasToProviderId[providerId] = providerId;
|
||||
if (!providerIdToAlias[providerId]) providerIdToAlias[providerId] = alias;
|
||||
}
|
||||
|
||||
return { aliasToProviderId, providerIdToAlias };
|
||||
}
|
||||
import { getUnifiedModelsResponse } from "./catalog";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -101,316 +16,7 @@ export async function OPTIONS() {
|
||||
|
||||
/**
|
||||
* GET /v1/models - OpenAI compatible models list
|
||||
* Returns models from all active providers, combos, embeddings, and image models in OpenAI format
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Issue #100: Optionally require authentication for /models (security hardening)
|
||||
// When enabled, unauthenticated requests get 401 with proper error response.
|
||||
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
|
||||
let settings: Record<string, any> = {};
|
||||
try {
|
||||
settings = await getSettings();
|
||||
} catch {}
|
||||
if (settings.requireAuthForModels === true) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Authentication required",
|
||||
type: "invalid_request_error",
|
||||
code: "invalid_api_key",
|
||||
},
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
|
||||
|
||||
// Issue #96: Allow blocking specific providers from the models list
|
||||
const blockedProviders: Set<string> = new Set(
|
||||
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
|
||||
);
|
||||
|
||||
// Get active provider connections
|
||||
let connections = [];
|
||||
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
totalConnectionCount = connections.length;
|
||||
// Filter to only active connections
|
||||
connections = connections.filter((c) => c.isActive !== false);
|
||||
} catch (e) {
|
||||
// If database not available, show no provider models (safe default)
|
||||
console.log("Could not fetch providers, showing only combos/custom models");
|
||||
}
|
||||
|
||||
// Get provider nodes (for compatible providers with custom prefixes)
|
||||
let providerNodes = [];
|
||||
try {
|
||||
providerNodes = await getProviderNodes();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch provider nodes");
|
||||
}
|
||||
|
||||
// Build map of provider node ID to prefix and type for compatible providers
|
||||
const providerIdToPrefix: Record<string, string> = {};
|
||||
const nodeIdToProviderType: Record<string, string> = {};
|
||||
for (const node of providerNodes) {
|
||||
if (node.prefix) {
|
||||
providerIdToPrefix[node.id] = node.prefix;
|
||||
}
|
||||
if (node.type) {
|
||||
nodeIdToProviderType[node.id] = node.type;
|
||||
}
|
||||
}
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
try {
|
||||
combos = await getCombos();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch combos");
|
||||
}
|
||||
|
||||
// Build set of active provider aliases
|
||||
const activeAliases = new Set();
|
||||
for (const conn of connections) {
|
||||
const alias = providerIdToAlias[conn.provider] || conn.provider;
|
||||
activeAliases.add(alias);
|
||||
activeAliases.add(conn.provider);
|
||||
}
|
||||
|
||||
// Collect models from active providers (or all if none active)
|
||||
const models = [];
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Add combos first (they appear at the top) — only active ones
|
||||
for (const combo of combos) {
|
||||
if (combo.isActive === false) continue;
|
||||
models.push({
|
||||
id: combo.name,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
permission: [],
|
||||
root: combo.name,
|
||||
parent: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Add provider models (chat)
|
||||
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
const providerId = aliasToProviderId[alias] || alias;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
|
||||
|
||||
// Skip blocked providers (Issue #96)
|
||||
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;
|
||||
|
||||
// Only include models from providers with active connections
|
||||
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const model of providerModels) {
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: null,
|
||||
});
|
||||
|
||||
// Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model).
|
||||
// This improves compatibility for clients that expect full provider names.
|
||||
if (canonicalProviderId !== alias) {
|
||||
models.push({
|
||||
id: `${canonicalProviderId}/${model.id}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: aliasId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: check if a provider is active (by provider id or alias)
|
||||
const isProviderActive = (provider: string) => {
|
||||
if (activeAliases.size === 0) return false; // No active connections = show nothing
|
||||
const alias = providerIdToAlias[provider] || provider;
|
||||
return activeAliases.has(alias) || activeAliases.has(provider);
|
||||
};
|
||||
|
||||
// Add embedding models (filtered by active providers)
|
||||
for (const embModel of getAllEmbeddingModels()) {
|
||||
if (!isProviderActive(embModel.provider)) continue;
|
||||
models.push({
|
||||
id: embModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: embModel.provider,
|
||||
type: "embedding",
|
||||
dimensions: embModel.dimensions,
|
||||
});
|
||||
}
|
||||
|
||||
// Add image models (filtered by active providers)
|
||||
for (const imgModel of getAllImageModels()) {
|
||||
if (!isProviderActive(imgModel.provider)) continue;
|
||||
models.push({
|
||||
id: imgModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: imgModel.provider,
|
||||
type: "image",
|
||||
supported_sizes: imgModel.supportedSizes,
|
||||
});
|
||||
}
|
||||
|
||||
// Add rerank models (filtered by active providers)
|
||||
for (const rerankModel of getAllRerankModels()) {
|
||||
if (!isProviderActive(rerankModel.provider)) continue;
|
||||
models.push({
|
||||
id: rerankModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: rerankModel.provider,
|
||||
type: "rerank",
|
||||
});
|
||||
}
|
||||
|
||||
// Add audio models (filtered by active providers)
|
||||
for (const audioModel of getAllAudioModels()) {
|
||||
if (!isProviderActive(audioModel.provider)) continue;
|
||||
models.push({
|
||||
id: audioModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: audioModel.provider,
|
||||
type: "audio",
|
||||
subtype: audioModel.subtype,
|
||||
});
|
||||
}
|
||||
|
||||
// Add moderation models (filtered by active providers)
|
||||
for (const modModel of getAllModerationModels()) {
|
||||
if (!isProviderActive(modModel.provider)) continue;
|
||||
models.push({
|
||||
id: modModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: modModel.provider,
|
||||
type: "moderation",
|
||||
});
|
||||
}
|
||||
|
||||
// Add video models (local providers always listed, cloud filtered by active)
|
||||
for (const videoModel of getAllVideoModels()) {
|
||||
const vConfig = getVideoProvider(videoModel.provider);
|
||||
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
|
||||
models.push({
|
||||
id: videoModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: videoModel.provider,
|
||||
type: "video",
|
||||
});
|
||||
}
|
||||
|
||||
// Add music models (local providers always listed, cloud filtered by active)
|
||||
for (const musicModel of getAllMusicModels()) {
|
||||
const mConfig = getMusicProvider(musicModel.provider);
|
||||
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
|
||||
models.push({
|
||||
id: musicModel.id,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: musicModel.provider,
|
||||
type: "music",
|
||||
});
|
||||
}
|
||||
|
||||
// Add custom models (user-defined)
|
||||
try {
|
||||
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
|
||||
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
|
||||
// For compatible providers, use the prefix from provider nodes
|
||||
const prefix = providerIdToPrefix[providerId];
|
||||
const alias = prefix || providerIdToAlias[providerId] || providerId;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
|
||||
|
||||
// Only include if provider is active — check alias, canonical ID, raw providerId,
|
||||
// or the parent provider type (for compatible providers whose node ID is a UUID)
|
||||
const parentProviderType = nodeIdToProviderType[providerId];
|
||||
if (
|
||||
!activeAliases.has(alias) &&
|
||||
!activeAliases.has(canonicalProviderId) &&
|
||||
!activeAliases.has(providerId) &&
|
||||
!(parentProviderType && activeAliases.has(parentProviderType))
|
||||
)
|
||||
continue;
|
||||
|
||||
for (const model of providerCustomModels) {
|
||||
// Skip if already added as built-in
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
if (models.some((m) => m.id === aliasId)) continue;
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: null,
|
||||
custom: true,
|
||||
});
|
||||
|
||||
// Only add provider-prefixed version if different from alias
|
||||
if (canonicalProviderId !== alias && !prefix) {
|
||||
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
|
||||
if (models.some((m) => m.id === providerPrefixedId)) continue;
|
||||
models.push({
|
||||
id: providerPrefixedId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: model.id,
|
||||
parent: aliasId,
|
||||
custom: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not fetch custom models");
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
object: "list",
|
||||
data: models,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
return Response.json(
|
||||
{ error: { message: (error as any).message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return getUnifiedModelsResponse(request);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user