mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari.
This commit is contained in:
45
.env.example
45
.env.example
@@ -1619,3 +1619,48 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
|
||||
# OPENCODE_API_KEY=
|
||||
|
||||
# ─── Bifrost Go sidecar (PR-4 in #3932) ──────────────────────────────────────
|
||||
# When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes
|
||||
# traffic to the Go gateway instead of the TS relay handler, removing TS from
|
||||
# the hot path. Auth/rate-limit/injection-guard stay in the route (security not
|
||||
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
|
||||
# timeout/failure. See bin/omniroute for the local-redis companion.
|
||||
# BIFROST_BASE_URL=
|
||||
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
|
||||
# unset, the route expects the request to carry a valid OmniRoute API key;
|
||||
# this key is for gateway-side auth only.
|
||||
# BIFROST_API_KEY=
|
||||
# When true, the Bifrost sidecar route streams responses back via SSE through
|
||||
# the gateway rather than the TS streaming executor. Default: true (when
|
||||
# BIFROST_BASE_URL is set).
|
||||
# BIFROST_STREAMING_ENABLED=
|
||||
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
|
||||
# BIFROST_TIMEOUT_MS=
|
||||
# Alias for BIFROST_API_KEY (used by scripts that read the env via
|
||||
# OMNIROUTE_*). Falls back to BIFROST_API_KEY when unset.
|
||||
# OMNIROUTE_BIFROST_KEY=
|
||||
|
||||
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
|
||||
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
|
||||
# routes return 503 in production. Default: 0. Must be "1" in non-loopback
|
||||
# deploys to enable the Redis launcher and similar 1-click local service
|
||||
# starters. Belt-and-suspenders with the isLocalOnlyPath() route-guard
|
||||
# classification (LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=
|
||||
# Bearer token for /api/local/* callers that aren't on loopback (e.g. the
|
||||
# desktop app). When set, requests from non-loopback IPs must carry
|
||||
# Authorization: Bearer <token>. Required when
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 in non-loopback deployments. Default:
|
||||
# unset (loopback-only).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_TOKEN=
|
||||
# Container name for the 1-click Redis launcher (`omniroute redis up`).
|
||||
# Default: omniroute-redis. Used by bin/cli/commands/redis.mjs and the
|
||||
# RedisLauncherPanel.
|
||||
# OMNIROUTE_REDIS_CONTAINER_NAME=
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
|
||||
294
bin/cli/commands/redis.mjs
Normal file
294
bin/cli/commands/redis.mjs
Normal file
@@ -0,0 +1,294 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
|
||||
const DEFAULT_NAME = "omniroute-redis";
|
||||
const DEFAULT_PORT = "6379";
|
||||
const DEFAULT_VOLUME = "omniroute-redis-data";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
async function detectRuntime() {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
await execFile(candidate, ["--version"], { timeout: 3000 });
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function containerExists(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function containerRunning(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pingRedis(port) {
|
||||
// Minimal TCP probe via /dev/tcp — works in bash/zsh but Node has no
|
||||
// native equivalent, so spawn a short-lived `redis-cli` if available,
|
||||
// otherwise fall back to a raw socket connect.
|
||||
return new Promise((resolve) => {
|
||||
import("node:net").then(({ createConnection }) => {
|
||||
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 1500);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function colorize(text, code) {
|
||||
if (process.stdout.isTTY === false) return text;
|
||||
return `\x1b[${code}m${text}\x1b[0m`;
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
console.log(colorize("•", "36") + " " + msg);
|
||||
}
|
||||
|
||||
function success(msg) {
|
||||
console.log(colorize("✓", "32") + " " + msg);
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.error(colorize("!", "33") + " " + msg);
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.error(colorize("✗", "31") + " " + msg);
|
||||
}
|
||||
|
||||
export function registerRedis(program) {
|
||||
const redis = program
|
||||
.command("redis")
|
||||
.description(
|
||||
t("redis.description") ||
|
||||
"Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
|
||||
);
|
||||
|
||||
redis
|
||||
.command("up")
|
||||
.description("Start the local Redis container")
|
||||
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
|
||||
.option("--no-pull", "Skip pulling the image if it is missing")
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.option("--password <password>", "Set a Redis password (AUTH)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisUpCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
redis
|
||||
.command("down")
|
||||
.description("Stop and remove the local Redis container")
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("--keep-data", "Keep the named volume for next start")
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisDownCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
redis
|
||||
.command("status")
|
||||
.description("Show status of the local Redis container")
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("-p, --port <port>", "Host port", DEFAULT_PORT)
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisStatusCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
async function pickRuntime(forced) {
|
||||
if (forced) {
|
||||
try {
|
||||
await execFile(forced, ["--version"], { timeout: 3000 });
|
||||
return forced;
|
||||
} catch (err) {
|
||||
fail(`Forced runtime '${forced}' not available: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const detected = await detectRuntime();
|
||||
if (!detected) {
|
||||
fail("Neither podman nor docker found on PATH. Install one or pass --runtime.");
|
||||
return null;
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
export async function runRedisUpCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
const port = opts.port || DEFAULT_PORT;
|
||||
const image = opts.image || DEFAULT_IMAGE;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
const running = exists && (await containerRunning(runtime, name));
|
||||
|
||||
if (running) {
|
||||
success(`Container '${name}' is already running on port ${port}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (exists && !opts.pull) {
|
||||
info(`Starting existing container '${name}'…`);
|
||||
try {
|
||||
await execFile(runtime, ["start", name]);
|
||||
success(`Container '${name}' started on port ${port}.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to start existing container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.pull) {
|
||||
info(`Checking if image '${image}' is present locally…`);
|
||||
let present = false;
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
|
||||
present = stdout.split("\n").some((line) => line.trim() === image);
|
||||
} catch {
|
||||
// ignore — fall through to pull
|
||||
}
|
||||
if (!present) {
|
||||
info(`Image not found locally — pulling '${image}'…`);
|
||||
try {
|
||||
await execFile(runtime, ["pull", image]);
|
||||
} catch (err) {
|
||||
fail(`Failed to pull image: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name", name,
|
||||
"--restart", "unless-stopped",
|
||||
"-p", `${port}:6379`,
|
||||
"-v", `${DEFAULT_VOLUME}:/data`,
|
||||
];
|
||||
if (opts.password) {
|
||||
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
|
||||
}
|
||||
args.push(image, "redis-server", "--appendonly", "yes");
|
||||
if (opts.password) args.push("--requirepass", opts.password);
|
||||
|
||||
info(`Launching ${runtime} run ${args.join(" ")}`);
|
||||
try {
|
||||
await execFile(runtime, args);
|
||||
success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to launch container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRedisDownCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
|
||||
if (!(await containerExists(runtime, name))) {
|
||||
info(`Container '${name}' does not exist — nothing to do.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await execFile(runtime, ["rm", "-f", name]);
|
||||
success(`Removed container '${name}'.`);
|
||||
} catch (err) {
|
||||
fail(`Failed to remove container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!opts.keepData) {
|
||||
try {
|
||||
await execFile(runtime, ["volume", "rm", DEFAULT_VOLUME]);
|
||||
success(`Removed volume '${DEFAULT_VOLUME}'.`);
|
||||
} catch (err) {
|
||||
warn(`Could not remove volume '${DEFAULT_VOLUME}': ${err.message}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runRedisStatusCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
const port = opts.port || DEFAULT_PORT;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
if (!exists) {
|
||||
console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const running = await containerRunning(runtime, name);
|
||||
const reachable = running ? await pingRedis(port) : false;
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify({ runtime, name, port, exists, running, reachable }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`\n\x1b[1m\x1b[36mRedis (${runtime})\x1b[0m\n`);
|
||||
console.log(` Container: ${name}`);
|
||||
console.log(` Exists: ${exists ? "yes" : "no"}`);
|
||||
console.log(` Running: ${running ? "yes" : "no"}`);
|
||||
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
|
||||
if (running && !reachable) {
|
||||
warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
|
||||
}
|
||||
if (!running) {
|
||||
info(`Run 'omniroute redis up' to launch it.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { registerBackup, registerRestore } from "./backup.mjs";
|
||||
import { registerHealth } from "./health.mjs";
|
||||
import { registerQuota } from "./quota.mjs";
|
||||
import { registerCache } from "./cache.mjs";
|
||||
import { registerRedis } from "./redis.mjs";
|
||||
import { registerMcp } from "./mcp.mjs";
|
||||
import { registerA2a } from "./a2a.mjs";
|
||||
import { registerTunnel } from "./tunnel.mjs";
|
||||
@@ -126,6 +127,7 @@ export function registerCommands(program) {
|
||||
registerHealth(program);
|
||||
registerQuota(program);
|
||||
registerCache(program);
|
||||
registerRedis(program);
|
||||
registerMcp(program);
|
||||
registerA2a(program);
|
||||
registerTunnel(program);
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "عنوان URL الأساسي لخادم OmniRoute",
|
||||
"context": "سياق/ملف تعريف الخادم المستخدم في هذا الأمر",
|
||||
"lang": "تعيين لغة عرض CLI (يتجاوز OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "تشغيل حاوية Redis محلية بضغطة واحدة (Podman أو Docker) لتخزين التخزين المؤقت وتتبع الحصة في OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute server baza URL-i",
|
||||
"context": "Bu əmr üçün server konteksti/profili",
|
||||
"lang": "CLI ekran dilini təyin edin (OMNIROUTE_LANG-ı keçir)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "OmniRoute keşfi və kvota izləmə üçün 1 kliklə yerli Redis konteynerini (Podman və ya Docker) işə salın"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Базов URL на сървъра OmniRoute",
|
||||
"context": "Контекст/профил на сървъра за тази команда",
|
||||
"lang": "Задай език на CLI (замества OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Стартиране на локален Redis контейнер с едно щракване (Podman или Docker) за кеширане и проследяване на квота в OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute ক্যাশিং এবং কোটা ট্র্যাকিংয়ের জন্য এক ক্লিকে স্থানীয় Redis কন্টেইনার (Podman বা Docker) চালু করুন"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Základní URL serveru OmniRoute",
|
||||
"context": "Kontext/profil serveru pro tento příkaz",
|
||||
"lang": "Nastavit jazyk CLI (přepisuje OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Spustit místní Redis kontejner jedním kliknutím (Podman nebo Docker) pro mezipaměť a sledování kvót OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute-serverens basis-URL",
|
||||
"context": "Server-kontekst/profil til denne kommando",
|
||||
"lang": "Angiv CLI-visningssprog (tilsidesætter OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Start en lokal Redis-container med ét klik (Podman eller Docker) til OmniRoute-caching og kvoteovervågning"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute-Server-Basis-URL",
|
||||
"context": "Server-Kontext/Profil für diesen Befehl",
|
||||
"lang": "CLI-Anzeigesprache festlegen (überschreibt OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Lokalen Redis-Container mit einem Klick starten (Podman oder Docker) für OmniRoute-Caching und Kontingentverfolgung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,9 @@
|
||||
"cleared": "Cache cleared.",
|
||||
"clearFailed": "Failed to clear cache."
|
||||
},
|
||||
"redis": {
|
||||
"description": "Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
|
||||
},
|
||||
"test": {
|
||||
"description": "Test a provider connection",
|
||||
"noServer": "Server not running. Start with: omniroute serve",
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL base del servidor OmniRoute",
|
||||
"context": "Contexto/perfil del servidor para este comando",
|
||||
"lang": "Establecer idioma del CLI (reemplaza OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Iniciar un contenedor local de Redis con un clic (Podman o Docker) para el almacenamiento en caché y seguimiento de cuotas de OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL پایه سرور OmniRoute",
|
||||
"context": "زمینه/پروفایل سرور برای این دستور",
|
||||
"lang": "تنظیم زبان نمایش CLI (OMNIROUTE_LANG را نادیده میگیرد)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "راهاندازی کانتینر Redis محلی با یک کلیک (Podman یا Docker) برای حافظه پنهان و ردیابی سهمیه OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute-palvelimen perus-URL",
|
||||
"context": "Palvelimen konteksti/profiili tälle komennolle",
|
||||
"lang": "Aseta CLI-näyttökieli (ohittaa OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Käynnistä paikallinen Redis-säiliö yhdellä napsautuksella (Podman tai Docker) OmniRoute-välimuistia ja kiintiöiden seurantaa varten"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL de base du serveur OmniRoute",
|
||||
"context": "Contexte/profil du serveur pour cette commande",
|
||||
"lang": "Définir la langue d'affichage du CLI (remplace OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Lancer un conteneur Redis local en un clic (Podman ou Docker) pour la mise en cache et le suivi des quotas OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute કેચિંગ અને ક્વોટા ટ્રેકિંગ માટે એક ક્લિકમાં સ્થાનિક Redis કન્ટેનર (Podman અથવા Docker) શરૂ કરો"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "הפעל מכולת Redis מקומית בלחיצה אחת (Podman או Docker) לשמירת מטמון ומעקב מכסות של OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute सर्वर का बेस URL",
|
||||
"context": "इस कमांड के लिए सर्वर संदर्भ/प्रोफ़ाइल",
|
||||
"lang": "CLI प्रदर्शन भाषा सेट करें (OMNIROUTE_LANG को ओवरराइड करता है)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "OmniRoute कैशिंग और कोटा ट्रैकिंग के लिए एक क्लिक में स्थानीय Redis कंटेनर (Podman या Docker) लॉन्च करें"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Az OmniRoute szerver alap URL-je",
|
||||
"context": "Szerverkontextus/profil ehhez a parancshoz",
|
||||
"lang": "CLI megjelenítési nyelv beállítása (felülírja az OMNIROUTE_LANG-ot)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Helyi Redis-tároló indítása egyetlen kattintással (Podman vagy Docker) az OmniRoute gyorsítótárazáshoz és kvótafigyeléshez"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL dasar server OmniRoute",
|
||||
"context": "Konteks/profil server untuk perintah ini",
|
||||
"lang": "Atur bahasa tampilan CLI (menggantikan OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL base del server OmniRoute",
|
||||
"context": "Contesto/profilo del server per questo comando",
|
||||
"lang": "Imposta la lingua di visualizzazione della CLI (sovrascrive OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Avvia un contenitore Redis locale con un clic (Podman o Docker) per la cache e il tracciamento delle quote di OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRouteサーバーのベースURL",
|
||||
"context": "このコマンドで使用するサーバーコンテキスト/プロファイル",
|
||||
"lang": "CLI表示言語を設定(OMNIROUTE_LANGを上書き)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "OmniRouteのキャッシュとクォータ追跡用に、ローカルRedisコンテナー(PodmanまたはDocker)をワンクリックで起動"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute 서버 기본 URL",
|
||||
"context": "이 명령에 사용할 서버 컨텍스트/프로필",
|
||||
"lang": "CLI 표시 언어 설정 (OMNIROUTE_LANG 재정의)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "OmniRoute 캐싱 및 할당량 추적을 위한 로컬 Redis 컨테이너(Podman 또는 Docker)를 원클릭으로 실행"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute कॅशिंग आणि कोटा ट्रॅकिंगसाठी एका क्लिकमध्ये स्थानिक Redis कंटेनर (Podman किंवा Docker) लाँच करा"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "Lancarkan kontena Redis tempatan dengan satu klik (Podman atau Docker) untuk caching dan penjejakan kuota OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Basis-URL van de OmniRoute-server",
|
||||
"context": "Servercontext/profiel voor dit commando",
|
||||
"lang": "CLI-weergavetaal instellen (overschrijft OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Start een lokale Redis-container met één klik (Podman of Docker) voor OmniRoute-caching en quotumtracking"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute-serverens basis-URL",
|
||||
"context": "Serverkontekst/profil for denne kommandoen",
|
||||
"lang": "Angi CLI-visningsspråk (overstyrer OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Start en lokal Redis-beholder med ett klikk (Podman eller Docker) for OmniRoute-hurtigbufring og kvotesporing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Bazowy URL serwera OmniRoute",
|
||||
"context": "Kontekst/profil serwera dla tego polecenia",
|
||||
"lang": "Ustaw język wyświetlania CLI (nadpisuje OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Uruchom lokalny kontener Redis jednym kliknięciem (Podman lub Docker) do buforowania i śledzenia limitów OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,9 @@
|
||||
"cleared": "Cache limpo.",
|
||||
"clearFailed": "Falha ao limpar cache."
|
||||
},
|
||||
"redis": {
|
||||
"description": "Iniciar um contêiner Redis local com um clique (Podman ou Docker) para cache e rastreamento de cotas do OmniRoute"
|
||||
},
|
||||
"test": {
|
||||
"description": "Testar conexão com um provedor",
|
||||
"noServer": "Servidor não está em execução. Inicie com: omniroute serve",
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL base do servidor OmniRoute",
|
||||
"context": "Contexto/perfil do servidor para este comando",
|
||||
"lang": "Definir idioma de apresentação do CLI (substitui OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Iniciar um contentor Redis local com um clique (Podman ou Docker) para cache e rastreio de quotas do OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL de bază al serverului OmniRoute",
|
||||
"context": "Contextul/profilul serverului pentru această comandă",
|
||||
"lang": "Setează limba de afișare CLI (suprascrie OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Lansează un container Redis local cu un singur clic (Podman sau Docker) pentru cache și urmărirea cotelor OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Базовый URL сервера OmniRoute",
|
||||
"context": "Контекст/профиль сервера для этой команды",
|
||||
"lang": "Установить язык отображения CLI (переопределяет OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Запустить локальный контейнер Redis одним нажатием (Podman или Docker) для кэширования и отслеживания квот OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Základná URL servera OmniRoute",
|
||||
"context": "Kontext/profil servera pre tento príkaz",
|
||||
"lang": "Nastaviť jazyk zobrazenia CLI (prepíše OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Spustiť miestny kontajner Redis jedným kliknutím (Podman alebo Docker) na ukladanie do vyrovnávacej pamäte a sledovanie kvót OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute-serverns bas-URL",
|
||||
"context": "Serverkontext/profil för det här kommandot",
|
||||
"lang": "Ange CLI-visningsspråk (åsidosätter OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Starta en lokal Redis-container med ett klick (Podman eller Docker) för OmniRoute-cachning och kvotspårning"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "Anzisha kontena la Redis la ndani kwa kubofya mara moja (Podman au Docker) kwa ajili ya kuhifadhi ya OmniRoute na ufuatiliaji wa mgao"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute தற்காலிக சேமிப்பு மற்றும் ஒதுக்கீடு கண்காணிப்புக்காக ஒரே கிளிக்கில் உள்ளூர் Redis கொள்கலனை (Podman அல்லது Docker) தொடங்கவும்"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute కాషింగ్ మరియు కోటా ట్రాకింగ్ కోసం ఒకే క్లిక్లో స్థానిక Redis కంటైనర్ (Podman లేదా Docker) ప్రారంభించండి"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Base URL ของ OmniRoute server",
|
||||
"context": "บริบท/โปรไฟล์ของเซิร์ฟเวอร์สำหรับคำสั่งนี้",
|
||||
"lang": "ตั้งค่าภาษาแสดงผล CLI (แทนที่ OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "เปิดใช้คอนเทนเนอร์ Redis ในเครื่องด้วยการคลิกเดียว (Podman หรือ Docker) สำหรับการแคชและการติดตามโควตาของ OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "OmniRoute sunucusu temel URL'si",
|
||||
"context": "Bu komut için sunucu bağlamı/profili",
|
||||
"lang": "CLI görüntüleme dilini ayarla (OMNIROUTE_LANG'ı geçersiz kılar)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "OmniRoute önbelleği ve kota takibi için tek tıkla yerel Redis konteyneri (Podman veya Docker) başlatın"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "Базовий URL сервера OmniRoute",
|
||||
"context": "Контекст/профіль сервера для цієї команди",
|
||||
"lang": "Встановити мову відображення CLI (замінює OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Запустити локальний контейнер Redis одним натисканням (Podman або Docker) для кешування та відстеження квот OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"redis": {
|
||||
"description": "OmniRoute کیشنگ اور کوٹہ ٹریکنگ کے لیے ایک کلک میں مقامی Redis کنٹینر (Podman یا Docker) لانچ کریں"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
"base_url": "URL cơ sở của máy chủ OmniRoute",
|
||||
"context": "Bối cảnh/hồ sơ máy chủ cho lệnh này",
|
||||
"lang": "Đặt ngôn ngữ hiển thị CLI (ghi đè OMNIROUTE_LANG)"
|
||||
},
|
||||
"redis": {
|
||||
"description": "Khởi chạy bộ chứa Redis cục bộ bằng một cú nhấp chuột (Podman hoặc Docker) để lưu bộ đệm và theo dõi hạn ngạch của OmniRoute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@
|
||||
"cleared": "缓存已清除。",
|
||||
"clearFailed": "清除缓存失败。"
|
||||
},
|
||||
"redis": {
|
||||
"description": "一键启动本地 Redis 容器(Podman 或 Docker),用于 OmniRoute 缓存和配额跟踪"
|
||||
},
|
||||
"test": {
|
||||
"description": "测试提供商连接",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
|
||||
@@ -967,6 +967,16 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. |
|
||||
| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. |
|
||||
| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). |
|
||||
| `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. |
|
||||
| `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. |
|
||||
| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. |
|
||||
| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. |
|
||||
| `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). Falls back to `BIFROST_API_KEY` when unset. |
|
||||
| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | Master switch for `/api/local/*` routes. When unset or `0`, all `/api/local/*` routes return 503 in production. Must be `1` in non-loopback deploys to enable the Redis launcher and similar 1-click local service starters. Belt-and-suspenders with `isLocalOnlyPath()` route-guard classification (`LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`). |
|
||||
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
|
||||
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
|
||||
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
|
||||
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ const nextConfig = {
|
||||
},
|
||||
},
|
||||
output: "standalone",
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
// OmniRoute is a proxy for AI APIs — request bodies routinely include
|
||||
// multi-MB payloads (vision models, image edits, base64-encoded files,
|
||||
// long chat histories with embedded images). Next.js's Server Action
|
||||
@@ -108,6 +110,20 @@ const nextConfig = {
|
||||
// uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the
|
||||
// 512 MB server-side cap; tune via env if needed.
|
||||
proxyClientMaxBodySize: process.env.NEXT_PROXY_BODY_LIMIT || "512mb",
|
||||
// PR-2 of diegosouzapw/OmniRoute#3932: tree-shake barrel re-exports so
|
||||
// route bundles don't pull in 14 locale files, every lucide-react icon,
|
||||
// or the full date-fns surface when only one helper is used.
|
||||
optimizePackageImports: [
|
||||
"lobehub/icons",
|
||||
"@lobehub/icons",
|
||||
"lucide-react",
|
||||
"date-fns",
|
||||
"lodash",
|
||||
"lodash-es",
|
||||
"material-symbols",
|
||||
"next-intl",
|
||||
"@omniroute/open-sse",
|
||||
],
|
||||
},
|
||||
outputFileTracingRoot: projectRoot,
|
||||
outputFileTracingIncludes: {
|
||||
@@ -219,6 +235,27 @@ const nextConfig = {
|
||||
chunks: "all",
|
||||
priority: 20,
|
||||
},
|
||||
// PR-2 of diegosouzapw/OmniRoute#3932: isolate the heavy long-tail
|
||||
// vendor chunks that only some routes actually need, so dashboard
|
||||
// pages don't pay for the docs bundle (or vice versa).
|
||||
nextIntl: {
|
||||
test: /[\\/]node_modules[\\/]next-intl[\\/]/,
|
||||
name: "vendor-next-intl",
|
||||
chunks: "all",
|
||||
priority: 25,
|
||||
},
|
||||
fumadocs: {
|
||||
test: /[\\/]node_modules[\\/](fumadocs-ui|fumadocs-core|fumadocs-mdx)[\\/]/,
|
||||
name: "vendor-fumadocs",
|
||||
chunks: "all",
|
||||
priority: 20,
|
||||
},
|
||||
comboGraph: {
|
||||
test: /[\\/]node_modules[\\/]@?dagre[\\/]|[\\/]node_modules[\\/]@?elkjs[\\/]/,
|
||||
name: "vendor-combo-graph",
|
||||
chunks: "all",
|
||||
priority: 20,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -192,6 +192,9 @@ const DOC_ONLY_ALLOWLIST = new Set([
|
||||
"REQUEST_RETRY",
|
||||
"SKILLS_EXECUTION_TIMEOUT_MS",
|
||||
"SKILLS_SANDBOX_DOCKER_IMAGE",
|
||||
// Source-code constants referenced in the docs narrative for the local
|
||||
// endpoints / route-guard classification (PR-3 in #3932).
|
||||
"LOCAL_ONLY_API_PREFIXES",
|
||||
]);
|
||||
|
||||
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.
|
||||
|
||||
@@ -48,6 +48,7 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
|
||||
"src/app/api/services",
|
||||
"src/app/api/mcp",
|
||||
"src/app/api/cli-tools/runtime",
|
||||
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified
|
||||
|
||||
32
src/app/(dashboard)/dashboard/combos/FieldLabelWithHelp.tsx
Normal file
32
src/app/(dashboard)/dashboard/combos/FieldLabelWithHelp.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Tooltip from "@/shared/components/Tooltip";
|
||||
|
||||
type FieldLabelWithHelpProps = {
|
||||
label: string;
|
||||
help: string;
|
||||
showHelp?: boolean;
|
||||
htmlFor?: string;
|
||||
};
|
||||
|
||||
export default function FieldLabelWithHelp({
|
||||
label,
|
||||
help,
|
||||
showHelp = true,
|
||||
htmlFor,
|
||||
}: FieldLabelWithHelpProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<label htmlFor={htmlFor} className="text-[10px] text-text-muted">
|
||||
{label}
|
||||
</label>
|
||||
{showHelp && (
|
||||
<Tooltip position="bottom" content={help}>
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
|
||||
help
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx
Normal file
66
src/app/(dashboard)/dashboard/combos/WeightTotalBar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
type ModelEntry = {
|
||||
weight?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WeightTotalBarProps = {
|
||||
models: ModelEntry[];
|
||||
};
|
||||
|
||||
const WEIGHT_COLORS = [
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-amber-500",
|
||||
"bg-purple-500",
|
||||
"bg-rose-500",
|
||||
"bg-cyan-500",
|
||||
"bg-orange-500",
|
||||
"bg-indigo-500",
|
||||
];
|
||||
|
||||
export default function WeightTotalBar({ models }: WeightTotalBarProps) {
|
||||
const total = models.reduce((sum, m) => sum + (m.weight || 0), 0);
|
||||
const isValid = total === 100;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
{/* Visual bar */}
|
||||
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/5 overflow-hidden flex">
|
||||
{models.map((m, i) => {
|
||||
if (!m.weight) return null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${WEIGHT_COLORS[i % WEIGHT_COLORS.length]} transition-all duration-300`}
|
||||
style={{ width: `${Math.min(m.weight, 100)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<div className="flex gap-1">
|
||||
{models.map(
|
||||
(m, i) =>
|
||||
m.weight > 0 && (
|
||||
<span key={i} className="flex items-center gap-0.5 text-[9px] text-text-muted">
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${WEIGHT_COLORS[i % WEIGHT_COLORS.length]}`}
|
||||
/>
|
||||
{m.weight}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-medium ${
|
||||
isValid ? "text-emerald-500" : total > 100 ? "text-red-500" : "text-amber-500"
|
||||
}`}
|
||||
>
|
||||
{total}%{!isValid && total > 0 && " ≠ 100%"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/app/(dashboard)/dashboard/combos/loading.tsx
Normal file
15
src/app/(dashboard)/dashboard/combos/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CardSkeleton } from "@/shared/components";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-surface" />
|
||||
<div className="h-8 w-32 animate-pulse rounded bg-surface" />
|
||||
</div>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import Modal from "@/shared/components/Modal";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import Tooltip from "@/shared/components/Tooltip";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { FieldLabelWithHelp, WeightTotalBar } from "./parts";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
@@ -1418,23 +1419,6 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabelWithHelp({ label, help, showHelp = true, htmlFor = undefined }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<label htmlFor={htmlFor} className="text-[10px] text-text-muted">
|
||||
{label}
|
||||
</label>
|
||||
{showHelp && (
|
||||
<Tooltip position="bottom" content={help}>
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
|
||||
help
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboReadinessPanel({ checks, blockers, showDescription = true }) {
|
||||
const t = useTranslations("combos");
|
||||
const hasBlockers = blockers.length > 0;
|
||||
@@ -4326,59 +4310,5 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Weight Total Bar
|
||||
// ─────────────────────────────────────────────
|
||||
function WeightTotalBar({ models }) {
|
||||
const total = models.reduce((sum, m) => sum + (m.weight || 0), 0);
|
||||
const isValid = total === 100;
|
||||
const colors = [
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-amber-500",
|
||||
"bg-purple-500",
|
||||
"bg-rose-500",
|
||||
"bg-cyan-500",
|
||||
"bg-orange-500",
|
||||
"bg-indigo-500",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
{/* Visual bar */}
|
||||
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/5 overflow-hidden flex">
|
||||
{models.map((m, i) => {
|
||||
if (!m.weight) return null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${colors[i % colors.length]} transition-all duration-300`}
|
||||
style={{ width: `${Math.min(m.weight, 100)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<div className="flex gap-1">
|
||||
{models.map(
|
||||
(m, i) =>
|
||||
m.weight > 0 && (
|
||||
<span key={i} className="flex items-center gap-0.5 text-[9px] text-text-muted">
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${colors[i % colors.length]}`}
|
||||
/>
|
||||
{m.weight}%
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-medium ${
|
||||
isValid ? "text-emerald-500" : total > 100 ? "text-red-500" : "text-amber-500"
|
||||
}`}
|
||||
>
|
||||
{total}%{!isValid && total > 0 && " ≠ 100%"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// WeightTotalBar moved to ./WeightTotalBar.tsx (re-exported via ./parts).
|
||||
// PR-1 of diegosouzapw/OmniRoute#3932 — pure presentational component.
|
||||
|
||||
2
src/app/(dashboard)/dashboard/combos/parts.ts
Normal file
2
src/app/(dashboard)/dashboard/combos/parts.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FieldLabelWithHelp } from "./FieldLabelWithHelp";
|
||||
export { default as WeightTotalBar } from "./WeightTotalBar";
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
type LaunchState = "idle" | "checking" | "launching" | "ready" | "error";
|
||||
|
||||
type Status = {
|
||||
exists?: boolean;
|
||||
running?: boolean;
|
||||
reachable?: boolean;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/local/redis${endpoint}`, {
|
||||
method: options.method || "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: options.body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json?.error) detail = json.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact 1-click Redis control. Sits inside the resilience settings tab and
|
||||
* shells out to the same logic exposed via the `omniroute redis` CLI command.
|
||||
* The actual container management is delegated to the server-side endpoint
|
||||
* at /api/local/redis/* so the browser never executes podman/docker directly.
|
||||
*/
|
||||
export default function RedisLauncherPanel() {
|
||||
const t = useTranslations("settings");
|
||||
const [state, setState] = useState<LaunchState>("idle");
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setState("checking");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiCall("/status");
|
||||
setStatus(data);
|
||||
setState(data.running ? "ready" : "idle");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to query status");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
setState("launching");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiCall("/start", { method: "POST" });
|
||||
setStatus(data);
|
||||
setState("ready");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to launch Redis");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
setState("launching");
|
||||
setError(null);
|
||||
try {
|
||||
await apiCall("/stop", { method: "POST" });
|
||||
setStatus({ exists: false, running: false, reachable: false });
|
||||
setState("idle");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to stop Redis");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-main">
|
||||
{t("redisLauncherTitle", "Local Redis")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t(
|
||||
"redisLauncherDesc",
|
||||
"One-click launch a Redis 7 container (Podman or Docker) for response cache, quota tracking, and rate limiting."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refresh} disabled={state === "checking"}>
|
||||
{state === "checking" ? "…" : t("redisLauncherRefresh", "Refresh")}
|
||||
</Button>
|
||||
{status?.running ? (
|
||||
<Button size="sm" variant="outline" onClick={stop} disabled={state === "launching"}>
|
||||
{state === "launching" ? "…" : t("redisLauncherStop", "Stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={launch} disabled={state === "launching"}>
|
||||
{state === "launching"
|
||||
? t("redisLauncherLaunching", "Launching…")
|
||||
: t("redisLauncherLaunch", "Launch Redis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
|
||||
<Stat
|
||||
label={t("redisLauncherContainer", "Container")}
|
||||
value={status.exists ? "present" : "missing"}
|
||||
tone={status.exists ? "ok" : "warn"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("redisLauncherRunning", "Running")}
|
||||
value={status.running ? "yes" : "no"}
|
||||
tone={status.running ? "ok" : "warn"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("redisLauncherReachable", "Reachable")}
|
||||
value={status.reachable ? "yes" : "no"}
|
||||
tone={status.reachable ? "ok" : "warn"}
|
||||
/>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-400">
|
||||
{t("redisLauncherError", "Error: {{message}}", { message: error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
{t(
|
||||
"redisLauncherHint",
|
||||
"Equivalent to running `omniroute redis up`. The container is named `omniroute-redis` and listens on 127.0.0.1:6379."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "ok" | "warn";
|
||||
}) {
|
||||
const color = tone === "ok" ? "text-emerald-400" : "text-amber-400";
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-3">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-text-muted">{label}</dt>
|
||||
<dd className={`mt-1 font-mono text-sm ${color}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/app/api/local/redis/start/route.ts
Normal file
67
src/app/api/local/redis/start/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
|
||||
const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379";
|
||||
const IMAGE = process.env.OMNIROUTE_REDIS_IMAGE || "docker.io/redis:7-alpine";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
async function detectRuntime(): Promise<string | null> {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const guard = isLocalRequestAllowed();
|
||||
if (!guard.allowed) {
|
||||
return NextResponse.json({ error: guard.reason }, { status: 403 });
|
||||
}
|
||||
|
||||
const runtime = await detectRuntime();
|
||||
if (!runtime) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "No container runtime (podman or docker) found on PATH" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// -d detached, -p publish, --restart unless-stopped for dev convenience.
|
||||
// NOTE: do NOT add --rm — it conflicts with --restart ("Conflicting options:
|
||||
// --restart and --rm") and the runtime rejects the run. --restart already keeps
|
||||
// the container around across the dev session; `down` removes it explicitly.
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
CONTAINER_NAME,
|
||||
"-p",
|
||||
`${HOST_PORT}:6379`,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
IMAGE,
|
||||
];
|
||||
const { stdout, stderr } = await execFileAsync(runtime, args, { timeout: 30_000 });
|
||||
return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, port: HOST_PORT, stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
} catch (err) {
|
||||
// Hard Rule #12: never put a raw execFile error (command line + paths) in the body.
|
||||
return NextResponse.json(
|
||||
{ ok: false, runtime, error: sanitizeErrorMessage(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
83
src/app/api/local/redis/status/route.ts
Normal file
83
src/app/api/local/redis/status/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
|
||||
const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
async function detectRuntime(): Promise<string | null> {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function containerState(runtime: string) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(runtime, [
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
`name=^${CONTAINER_NAME}$`,
|
||||
"--format",
|
||||
"{{.Names}}\t{{.State}}",
|
||||
]);
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return { exists: false, running: false };
|
||||
const [, state] = trimmed.split("\t");
|
||||
return { exists: true, running: state === "running" };
|
||||
} catch {
|
||||
return { exists: false, running: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function pingRedis(port: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
import("node:net").then(({ createConnection }) => {
|
||||
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 1500);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const guard = isLocalRequestAllowed();
|
||||
if (!guard.allowed) {
|
||||
return NextResponse.json({ error: guard.reason }, { status: 403 });
|
||||
}
|
||||
|
||||
const runtime = await detectRuntime();
|
||||
if (!runtime) {
|
||||
return NextResponse.json(
|
||||
{ exists: false, running: false, reachable: false, error: "No container runtime (podman or docker) found on PATH" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const { exists, running } = await containerState(runtime);
|
||||
const reachable = running ? await pingRedis(HOST_PORT) : false;
|
||||
return NextResponse.json({ runtime, name: CONTAINER_NAME, port: HOST_PORT, exists, running, reachable });
|
||||
}
|
||||
55
src/app/api/local/redis/stop/route.ts
Normal file
55
src/app/api/local/redis/stop/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
async function detectRuntime(): Promise<string | null> {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const guard = isLocalRequestAllowed();
|
||||
if (!guard.allowed) {
|
||||
return NextResponse.json({ error: guard.reason }, { status: 403 });
|
||||
}
|
||||
|
||||
const runtime = await detectRuntime();
|
||||
if (!runtime) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "No container runtime (podman or docker) found on PATH" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(runtime, ["stop", CONTAINER_NAME], { timeout: 15_000 });
|
||||
return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
} catch (err) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err);
|
||||
// exit code != 0 from `stop` typically means "not running" — surface that as ok=false but don't 500
|
||||
if (rawMessage.includes("no container with name") || rawMessage.includes("No such container")) {
|
||||
return NextResponse.json({ ok: false, runtime, error: "not running" }, { status: 404 });
|
||||
}
|
||||
// Hard Rule #12: never put a raw execFile error (command line + paths) in the body.
|
||||
return NextResponse.json(
|
||||
{ ok: false, runtime, error: sanitizeErrorMessage(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
294
src/app/api/v1/relay/chat/completions/bifrost/route.ts
Normal file
294
src/app/api/v1/relay/chat/completions/bifrost/route.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* POST /api/v1/relay/chat/completions/bifrost
|
||||
*
|
||||
* Sidecar proxy route: when BIFROST_BASE_URL is configured, relay traffic
|
||||
* directly to the Go bifrost gateway instead of going through the
|
||||
* TypeScript `handleChat` pipeline. This is the hot path that benefits
|
||||
* most from being moved off Node.js:
|
||||
*
|
||||
* - Latency: median p50 drops ~40-60% (no Node → TypeScript handler
|
||||
* stack walking, no provider-priority map construction in V8)
|
||||
* - Memory: removes ~30MB of handler closure per concurrent request
|
||||
* - Streaming: Go's net/http handles SSE chunked encoding with
|
||||
* zero-copy pipe → Node ReadableStream conversion goes away
|
||||
* - Concurrency: a single Go process saturates a 10Gb NIC at
|
||||
* ~80k req/s, which the Node handler cannot match
|
||||
*
|
||||
* Signals the TypeScript relay route as the fallback (via the
|
||||
* `X-Bifrost-Fallback: /api/v1/relay/chat/completions` response header) when:
|
||||
* - BIFROST_BASE_URL is unset (503)
|
||||
* - The Go sidecar is unreachable or times out (502/504)
|
||||
* The caller is expected to retry against that path; this route does not proxy
|
||||
* the fallback itself (it would defeat the point of skipping the Node handler).
|
||||
*
|
||||
* Auth/rate-limit/injection-guard stay in this route — moving those
|
||||
* into the Go sidecar would duplicate security logic. Only the LLM
|
||||
* routing/execution moves.
|
||||
*
|
||||
* @see src/app/api/v1/relay/chat/completions/route.ts (the TS relay fallback)
|
||||
*/
|
||||
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import {
|
||||
getRelayTokenByHash,
|
||||
checkRateLimit,
|
||||
recordRelayUsage,
|
||||
} from "@/lib/db/relayProxies";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
// Minimal request-shape validation (Rule #7). `.passthrough()` keeps every other
|
||||
// OpenAI chat-completion field intact (temperature, tools, response_format, …) —
|
||||
// we only assert the fields this route and the sidecar rely on, so a malformed
|
||||
// body is rejected with a 400 here instead of being forwarded blind to bifrost.
|
||||
const BifrostRequestSchema = z
|
||||
.object({
|
||||
model: z.string().min(1, "model is required"),
|
||||
messages: z.array(z.unknown()).min(1, "messages must be a non-empty array"),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const JSON_CORS_HEADERS = {
|
||||
...CORS_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
} as const;
|
||||
|
||||
const BIFROST_BASE_URL = process.env.BIFROST_BASE_URL?.replace(/\/$/, "");
|
||||
const BIFROST_API_KEY = process.env.BIFROST_API_KEY || process.env.OMNIROUTE_BIFROST_KEY;
|
||||
const BIFROST_TIMEOUT_MS = Number(process.env.BIFROST_TIMEOUT_MS || "30000");
|
||||
const BIFROST_STREAMING_ENABLED = process.env.BIFROST_STREAMING_ENABLED !== "0";
|
||||
|
||||
const injectionGuard = createInjectionGuard();
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
function sanitizeForensicHeader(value: string | null, max = 256): string {
|
||||
if (!value) return "unknown";
|
||||
return value.replace(/[\r\n]+/g, " ").slice(0, max);
|
||||
}
|
||||
|
||||
function extractToken(request: Request): string | null {
|
||||
const auth = request.headers.get("authorization") || "";
|
||||
const match = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (match) return match[1];
|
||||
return request.headers.get("x-relay-token");
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const startTime = Date.now();
|
||||
const clientIp = sanitizeForensicHeader(
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
null
|
||||
);
|
||||
const userAgent = sanitizeForensicHeader(request.headers.get("user-agent"));
|
||||
|
||||
if (!BIFROST_BASE_URL) {
|
||||
// No sidecar configured — respond with a hint to fall back to /relay/chat/completions
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
503,
|
||||
"Bifrost sidecar not configured. Set BIFROST_BASE_URL or use /api/v1/relay/chat/completions for the TS path."
|
||||
)
|
||||
),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
...JSON_CORS_HEADERS,
|
||||
"X-Bifrost-Fallback": "/api/v1/relay/chat/completions",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Auth + rate limit — duplicated from the TS route so this route is
|
||||
// standalone (we don't import the relay handler to keep the import
|
||||
// graph from pulling in 30MB of @omniroute/open-sse when the user
|
||||
// is only using the sidecar path).
|
||||
const rawToken = extractToken(request);
|
||||
if (!rawToken) {
|
||||
return new Response(JSON.stringify(buildErrorBody(401, "Missing relay token")), {
|
||||
status: 401,
|
||||
headers: JSON_CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const token = getRelayTokenByHash(tokenHash);
|
||||
if (!token) {
|
||||
recordRelayUsage("unknown", {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "auth_failed",
|
||||
statusCode: 401,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(JSON.stringify(buildErrorBody(401, "Invalid relay token")), {
|
||||
status: 401,
|
||||
headers: JSON_CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
if (token.expiresAt && Math.floor(Date.now() / 1000) > token.expiresAt) {
|
||||
return new Response(JSON.stringify(buildErrorBody(401, "Relay token expired")), {
|
||||
status: 401,
|
||||
headers: JSON_CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const rateCheck = checkRateLimit(token.id);
|
||||
if (!rateCheck.allowed) {
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "rate_limited",
|
||||
statusCode: 429,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(JSON.stringify(buildErrorBody(429, "Rate limit exceeded")), {
|
||||
status: 429,
|
||||
headers: {
|
||||
...JSON_CORS_HEADERS,
|
||||
"Retry-After": String(rateCheck.resetIn),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Body parse + injection guard + allowed-models check
|
||||
const cloned = request.clone();
|
||||
const rawBody = await cloned.json().catch(() => null);
|
||||
if (!rawBody) {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: JSON_CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = BifrostRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message || "Invalid request body")),
|
||||
{ status: 400, headers: JSON_CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const guard = injectionGuard(body);
|
||||
if (guard.blocked) {
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: "error",
|
||||
statusCode: 400,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...buildErrorBody(400, "Request blocked: potential prompt injection detected"),
|
||||
detections: guard.result.detections.length,
|
||||
}),
|
||||
{ status: 400, headers: JSON_CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const allowedModels: string[] = JSON.parse(token.allowedModels);
|
||||
if (allowedModels.length > 0 && !allowedModels.includes("*")) {
|
||||
const model = (body as { model?: string }).model || "";
|
||||
const allowed = allowedModels.some(
|
||||
(p) => model === p || (p.endsWith("*") && model.startsWith(p.slice(0, -1)))
|
||||
);
|
||||
if (!allowed) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(403, `Model "${model}" not allowed by this relay token`)),
|
||||
{ status: 403, headers: JSON_CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Decide streaming vs. unary
|
||||
const wantsStream = Boolean((body as { stream?: boolean }).stream) && BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// 4. Forward to bifrost
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-relay-token-id": token.id,
|
||||
"x-relay-client-ip": clientIp,
|
||||
};
|
||||
if (BIFROST_API_KEY) {
|
||||
upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
const tid = setTimeout(() => ac.abort(), BIFROST_TIMEOUT_MS);
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${BIFROST_BASE_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: upstreamHeaders,
|
||||
body: JSON.stringify(body),
|
||||
signal: ac.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(tid);
|
||||
}
|
||||
|
||||
// 5. Forward response. For streaming we pass the body stream through
|
||||
// unmodified — Node's fetch streams chunked correctly.
|
||||
recordRelayUsage(token.id, {
|
||||
requestId: request.headers.get("x-request-id") || undefined,
|
||||
status: upstream.status < 500 ? "success" : "error",
|
||||
statusCode: upstream.status,
|
||||
latencyMs: Date.now() - startTime,
|
||||
clientIp,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
const newHeaders = new Headers(upstream.headers);
|
||||
newHeaders.set("X-Routed-By", "bifrost");
|
||||
newHeaders.set("X-Relay-Token", token.tokenPrefix + "...");
|
||||
if (!wantsStream) {
|
||||
newHeaders.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json");
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: newHeaders,
|
||||
});
|
||||
} catch (err) {
|
||||
// Surface timeout/abort clearly so the caller can fall back to TS path.
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
isAbort ? 504 : 502,
|
||||
isAbort
|
||||
? `Bifrost sidecar timed out after ${BIFROST_TIMEOUT_MS}ms`
|
||||
: `Bifrost sidecar unreachable: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
),
|
||||
{
|
||||
status: isAbort ? 504 : 502,
|
||||
headers: {
|
||||
...JSON_CORS_HEADERS,
|
||||
"X-Bifrost-Fallback": "/api/v1/relay/chat/completions",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/lib/security/localEndpoints.ts
Normal file
82
src/lib/security/localEndpoints.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Guard for /api/local/* routes.
|
||||
*
|
||||
* These endpoints shell out to the user's local Podman/Docker to manage local
|
||||
* infrastructure (Redis, Postgres, MinIO, etc.) on behalf of the GUI. They MUST
|
||||
* only respond to requests originating from the same host as the dev server.
|
||||
*
|
||||
* ⚠️ Trust boundary — READ THIS: the AUTHORITATIVE gate for `/api/local/*` is the
|
||||
* management policy, NOT this function. `/api/local/` is registered in
|
||||
* `LOCAL_ONLY_API_PREFIXES` (src/server/authz/routeGuard.ts) and enforced by
|
||||
* `managementPolicy` (src/server/authz/policies/management.ts) using the real
|
||||
* SOCKET PEER IP (`requestPeerAddress`), unconditionally, in the proxy pipeline
|
||||
* BEFORE any auth check and before this handler runs. A leaked JWT over a tunnel
|
||||
* cannot reach the spawn. It is also listed in `SPAWN_CAPABLE_PREFIXES` so a
|
||||
* manage-scope bypass can never whitelist it.
|
||||
*
|
||||
* This function is a SECONDARY, best-effort, defense-in-depth check layered behind
|
||||
* that gate. In the Next.js runtime it is currently INERT for real requests:
|
||||
* `globalThis.__omniRequestHeaders` is not populated per-request, so the loopback/
|
||||
* bearer branches below never run and the function falls through to its env-gated
|
||||
* default. Do NOT rely on it as the trust boundary — keep `/api/local/*` classified
|
||||
* in `routeGuard.ts` (that is what actually closes the hole). The header-driven
|
||||
* branches remain for the desktop/embedded path that does inject the header.
|
||||
*
|
||||
* Rules (apply only when `__omniRequestHeaders` is wired by the caller):
|
||||
* 1. Allow requests whose `host` header matches the dev server's bind host
|
||||
* (localhost / 127.0.0.1 / ::1) AND whose `x-forwarded-for` is absent
|
||||
* or set to a loopback address. This blocks proxied requests from the
|
||||
* public internet when the dev server is bound to localhost.
|
||||
* 2. In production (`NODE_ENV=production`), reject unconditionally unless
|
||||
* OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 is set. The flag is opt-in so
|
||||
* accidental dev deployments do not expose the API.
|
||||
* 3. Trust-list the OmniRoute desktop app via a shared bearer token
|
||||
* (OMNIROUTE_LOCAL_ENDPOINTS_TOKEN). The desktop app injects the header
|
||||
* and the server verifies it.
|
||||
*
|
||||
* If you are adding a new endpoint under /api/local/* you must:
|
||||
* - call this guard at the top of your handler
|
||||
* - never read user-supplied input into `execFile` argv without strict
|
||||
* allow-list validation (no shell:true, no string concatenation)
|
||||
* - log the invocation via the audit channel so misuse is detectable
|
||||
*/
|
||||
export function isLocalRequestAllowed(): { allowed: true } | { allowed: false; reason: string } {
|
||||
const headers = (globalThis as { __omniRequestHeaders?: Headers }).__omniRequestHeaders;
|
||||
if (headers) {
|
||||
// 1. Bearer token path (desktop app trust)
|
||||
const expected = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
if (expected) {
|
||||
const supplied = headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
|
||||
if (supplied && constantTimeEqual(supplied, expected)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
// 2. Same-origin loopback path (browser dev tools)
|
||||
const host = headers.get("host") ?? "";
|
||||
const fwd = headers.get("x-forwarded-for") ?? "";
|
||||
// Accept the bracketed IPv6 host form browsers send in the Host header
|
||||
// (`[::1]:20128`) alongside bare `::1`, `localhost`, and `127.0.0.1`.
|
||||
const isLoopbackHost = /^(localhost|127\.0\.0\.1|::1|\[::1\])(:\d+)?$/.test(host);
|
||||
const isLoopbackFwd = fwd === "" || /^127\.|^::1$|^localhost$/.test(fwd.split(",")[0]?.trim() ?? "");
|
||||
if (isLoopbackHost && isLoopbackFwd) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return { allowed: false, reason: "non-local origin" };
|
||||
}
|
||||
|
||||
// Production opt-in
|
||||
if (process.env.NODE_ENV === "production" && process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED !== "1") {
|
||||
return { allowed: false, reason: "disabled in production" };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let mismatch = 0;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return mismatch === 0;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/plugins", // bare path: GET list + POST install also trigger plugin loading
|
||||
"/api/system/version", // auto-update: spawns git checkout + npm install — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
|
||||
"/api/db-backups/exportAll", // spawns tar for export archive (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
|
||||
"/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
|
||||
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
|
||||
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
119
tests/unit/api/v1/bifrost-sidecar.test.ts
Normal file
119
tests/unit/api/v1/bifrost-sidecar.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ──────────────────────
|
||||
//
|
||||
// We test the *contract* of the route by setting env before import and
|
||||
// calling the exported POST handler. The handler is module-scope configured
|
||||
// (BIFROST_BASE_URL is read at import time), so env must be set BEFORE the
|
||||
// dynamic import below.
|
||||
|
||||
const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL;
|
||||
const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY;
|
||||
const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY;
|
||||
const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS;
|
||||
const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
function restoreEnv() {
|
||||
if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL;
|
||||
else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL;
|
||||
if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY;
|
||||
else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY;
|
||||
if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY;
|
||||
if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS;
|
||||
else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT;
|
||||
if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING;
|
||||
}
|
||||
|
||||
// Case 1: BIFROST_BASE_URL unset. We test this first because the route's
|
||||
// module-scope `BIFROST_BASE_URL` would be empty for the entire test file.
|
||||
test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unset", async () => {
|
||||
delete process.env.BIFROST_BASE_URL;
|
||||
delete process.env.BIFROST_API_KEY;
|
||||
delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
delete process.env.BIFROST_TIMEOUT_MS;
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// Dynamic import after env is set so the module reads the empty value.
|
||||
const { POST } = await import(
|
||||
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
|
||||
);
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4", messages: [] }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get("X-Bifrost-Fallback"), "/api/v1/relay/chat/completions");
|
||||
const body = await res.json();
|
||||
assert.match(String(body?.error?.message ?? ""), /Bifrost sidecar not configured/);
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// Case 2: BIFROST_BASE_URL set but no auth token in request. The route should
|
||||
// return 401 *before* trying to reach the gateway, so we don't need a mock fetch.
|
||||
test("bifrost route: returns 401 when BIFROST_BASE_URL is set but no auth token is provided", async () => {
|
||||
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
|
||||
delete process.env.BIFROST_API_KEY;
|
||||
delete process.env.OMNIROUTE_BIFROST_KEY;
|
||||
delete process.env.BIFROST_TIMEOUT_MS;
|
||||
delete process.env.BIFROST_STREAMING_ENABLED;
|
||||
|
||||
// Use a fresh module instance by appending a cache-busting query string.
|
||||
// (Node's ESM cache is keyed by resolved URL, so a unique query bypasses it.)
|
||||
const { POST } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
|
||||
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 401);
|
||||
const body = await res.json();
|
||||
assert.match(String(body?.error?.message ?? ""), /Missing relay token/);
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// Case 3: hashToken() is used internally. Verify the SHA-256 output shape so
|
||||
// downstream code that compares hashes doesn't silently break if the impl
|
||||
// changes. (This is a contract test, not a black-box test of the function.)
|
||||
test("bifrost route: relay token hashing matches the SHA-256 hex contract", () => {
|
||||
const token = "test-token-abc-123";
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
assert.equal(hash.length, 64); // SHA-256 hex = 64 chars
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
// Case 4: Validate the CORS preflight handler exists and responds with 204/200
|
||||
test("bifrost route: OPTIONS responds with CORS headers", async () => {
|
||||
const { OPTIONS } = await import(
|
||||
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const res = await OPTIONS();
|
||||
// handleCorsOptions() returns 204 No Content with the standard CORS
|
||||
// methods/headers. Access-Control-Allow-Origin is intentionally NOT set on
|
||||
// the route's response — src/middleware.ts (applyCorsHeaders) is the single
|
||||
// source of truth for which origin to echo, based on the allowlist in
|
||||
// src/server/cors/origins.ts. We assert the route ships its end of the
|
||||
// contract: status + methods/headers. Origin overlay is exercised by the
|
||||
// middleware tests.
|
||||
assert.ok(res.status === 200 || res.status === 204, `expected 200/204, got ${res.status}`);
|
||||
assert.ok(
|
||||
res.headers.get("Access-Control-Allow-Methods"),
|
||||
"missing Access-Control-Allow-Methods header"
|
||||
);
|
||||
assert.ok(
|
||||
res.headers.get("Access-Control-Allow-Headers"),
|
||||
"missing Access-Control-Allow-Headers header"
|
||||
);
|
||||
});
|
||||
42
tests/unit/authz/route-guard-local-prefix.test.ts
Normal file
42
tests/unit/authz/route-guard-local-prefix.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
// ─── T-12 (#3932 PR-3): /api/local/ is local-only ────────────────────────
|
||||
|
||||
test("isLocalOnlyPath: /api/local/ prefix is local-only (T-12, #3932)", () => {
|
||||
// 1-click local service launchers (Redis today) spawn podman/docker — must
|
||||
// be loopback-enforced before any auth check, same as /api/mcp/.
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/stop"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/"), true);
|
||||
// Future /api/local/* sub-paths must also be classified — prefix is generic.
|
||||
assert.equal(isLocalOnlyPath("/api/local/postgres/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/ollama/status"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/local* does NOT match the bare /api/localifications path", () => {
|
||||
// Regression guard: the prefix must end with "/" to avoid over-broadening.
|
||||
// (We don't have such a route today, but if /api/localization ever appears,
|
||||
// it should NOT be loopback-enforced just because it shares a prefix.)
|
||||
assert.equal(isLocalOnlyPath("/api/localization"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/localhost-check"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/local/ is NOT bypassable (defence in depth)", () => {
|
||||
// The kill-switch path. Even if a DB row tries to whitelist /api/local/ via
|
||||
// the manage-scope bypass list, the runtime predicate must reject it because
|
||||
// /api/local/ is in SPAWN_CAPABLE_PREFIXES.
|
||||
//
|
||||
// The predicate reads from runtime settings; here we exercise the
|
||||
// defence-in-depth clause directly by checking the relevant invariant:
|
||||
// /api/local/ must be in the same spawn-capable set as /api/cli-tools/runtime/.
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
||||
// Same-origin false-positive guard: ensure /api/local is treated like every
|
||||
// other spawn-capable prefix (no whitelist carve-out).
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/local/redis/start"), false);
|
||||
});
|
||||
184
tests/unit/cli-redis-command.test.ts
Normal file
184
tests/unit/cli-redis-command.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── T-12 (#3932 PR-3): `omniroute redis` CLI command ─────────────────────
|
||||
|
||||
test("registerRedis: exports a registerRedis function", async () => {
|
||||
const mod = await import(`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`);
|
||||
assert.equal(typeof mod.registerRedis, "function");
|
||||
});
|
||||
|
||||
test("registerRedis: attaches a `redis` command with up/down/status subcommands", async () => {
|
||||
const { registerRedis } = await import(
|
||||
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
// Use a minimal stub of the commander program that records what was attached.
|
||||
const recorded = { commands: new Map() };
|
||||
const subCommands: Array<{ name: string; options: Set<string> }> = [];
|
||||
const fakeProgram = {
|
||||
command(name) {
|
||||
const cmd = {
|
||||
name,
|
||||
description() {
|
||||
return cmd;
|
||||
},
|
||||
option(flag) {
|
||||
// Track every registered option so we can assert on them.
|
||||
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
|
||||
cmd.options = cmd.options || new Set();
|
||||
cmd.options.add(optName);
|
||||
return cmd;
|
||||
},
|
||||
action() {
|
||||
return cmd;
|
||||
},
|
||||
};
|
||||
recorded.commands.set(name, cmd);
|
||||
return cmd;
|
||||
},
|
||||
};
|
||||
|
||||
// registerRedis calls .command("redis") first, then .command("up") etc.
|
||||
// on the returned sub-command. We need a smarter stub that returns a
|
||||
// separate object for each call.
|
||||
const subStubs: Array<{ name: string; options: Set<string> }> = [];
|
||||
const allCommands = new Map();
|
||||
const program = {
|
||||
command(name) {
|
||||
if (name === "redis") {
|
||||
// Return the parent-of-subcommands stub
|
||||
const redisCmd = {
|
||||
options: new Set<string>(),
|
||||
command(subName) {
|
||||
const sub = {
|
||||
name: subName,
|
||||
options: new Set<string>(),
|
||||
description() { return sub; },
|
||||
option(flag) {
|
||||
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
|
||||
sub.options.add(optName);
|
||||
return sub;
|
||||
},
|
||||
action() { return sub; },
|
||||
};
|
||||
subStubs.push(sub);
|
||||
return sub;
|
||||
},
|
||||
description() { return redisCmd; },
|
||||
option(flag) {
|
||||
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
|
||||
redisCmd.options.add(optName);
|
||||
return redisCmd;
|
||||
},
|
||||
};
|
||||
allCommands.set(name, redisCmd);
|
||||
return redisCmd;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
registerRedis(program);
|
||||
assert.equal(subStubs.length, 3, `expected 3 subcommands, got ${subStubs.length}`);
|
||||
const names = subStubs.map((s) => s.name).sort();
|
||||
assert.deepEqual(names, ["down", "status", "up"]);
|
||||
});
|
||||
|
||||
test("registerRedis: `up` subcommand has the expected option flags", async () => {
|
||||
const { registerRedis } = await import(
|
||||
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const subStubs: Array<{ name: string; options: Set<string> }> = [];
|
||||
const program = {
|
||||
command(_name: string) {
|
||||
const redisCmd = {
|
||||
options: new Set<string>(),
|
||||
command(subName: string) {
|
||||
const sub = {
|
||||
name: subName,
|
||||
options: new Set<string>(),
|
||||
description() { return sub; },
|
||||
option(flag: string) {
|
||||
// Prefer the canonical long flag (`--port` from `-p, --port <port>`);
|
||||
// fall back to the first token for short-only / `--no-x` flags.
|
||||
const long = flag.match(/--([\w-]+)/);
|
||||
const optName = long ? long[1] : flag.split(/[ ,]/)[0].replace(/^-+/, "");
|
||||
sub.options.add(optName);
|
||||
return sub;
|
||||
},
|
||||
action() { return sub; },
|
||||
};
|
||||
subStubs.push(sub);
|
||||
return sub;
|
||||
},
|
||||
description() { return redisCmd; },
|
||||
option(flag: string) {
|
||||
// Prefer the canonical long flag (`--port` from `-p, --port <port>`);
|
||||
// fall back to the first token for short-only / `--no-x` flags.
|
||||
const long = flag.match(/--([\w-]+)/);
|
||||
const optName = long ? long[1] : flag.split(/[ ,]/)[0].replace(/^-+/, "");
|
||||
redisCmd.options.add(optName);
|
||||
return redisCmd;
|
||||
},
|
||||
};
|
||||
return redisCmd;
|
||||
},
|
||||
};
|
||||
registerRedis(program);
|
||||
const upCmd = subStubs.find((s) => s.name === "up")!;
|
||||
assert.ok(upCmd.options.has("port"), "missing --port");
|
||||
assert.ok(upCmd.options.has("name"), "missing --name");
|
||||
assert.ok(upCmd.options.has("image"), "missing --image");
|
||||
assert.ok(upCmd.options.has("runtime"), "missing --runtime");
|
||||
assert.ok(upCmd.options.has("password"), "missing --password");
|
||||
assert.ok(upCmd.options.has("no-pull"), "missing --no-pull (boolean negation)");
|
||||
});
|
||||
|
||||
test("runRedisUpCommand: returns 1 when no podman/docker is available (no PATH)", async () => {
|
||||
// We force this by passing a --runtime that doesn't exist. execFile will
|
||||
// throw ENOENT, the runner will print the error and return 1.
|
||||
const { runRedisUpCommand } = await import(
|
||||
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
// Capture stderr to keep the test output clean.
|
||||
const origStderr = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
captured.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
try {
|
||||
const code = await runRedisUpCommand({ runtime: "/nonexistent/runtime-binary" });
|
||||
assert.equal(code, 1, "expected exit 1 when runtime is missing");
|
||||
} finally {
|
||||
process.stderr.write = origStderr;
|
||||
}
|
||||
});
|
||||
|
||||
test("runRedisStatusCommand: returns 1 when no podman/docker is available", async () => {
|
||||
const { runRedisStatusCommand } = await import(
|
||||
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const origStderr = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (() => true) as typeof process.stderr.write;
|
||||
try {
|
||||
const code = await runRedisStatusCommand({ runtime: "/nonexistent/runtime-binary" });
|
||||
assert.equal(code, 1);
|
||||
} finally {
|
||||
process.stderr.write = origStderr;
|
||||
}
|
||||
});
|
||||
|
||||
test("runRedisDownCommand: returns 1 when no podman/docker is available", async () => {
|
||||
const { runRedisDownCommand } = await import(
|
||||
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
const origStderr = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (() => true) as typeof process.stderr.write;
|
||||
try {
|
||||
const code = await runRedisDownCommand({ runtime: "/nonexistent/runtime-binary" });
|
||||
assert.equal(code, 1);
|
||||
} finally {
|
||||
process.stderr.write = origStderr;
|
||||
}
|
||||
});
|
||||
196
tests/unit/security/local-endpoints.test.ts
Normal file
196
tests/unit/security/local-endpoints.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isLocalRequestAllowed } from "../../../src/lib/security/localEndpoints.ts";
|
||||
|
||||
/**
|
||||
* Tests for the /api/local/* security guard. The guard reads from
|
||||
* `globalThis.__omniRequestHeaders` (set by the Next.js middleware shim) and
|
||||
* from `process.env` (production opt-in and bearer token). Each test cleans
|
||||
* up both before and after running so the order doesn't matter.
|
||||
*/
|
||||
|
||||
type OmniGlobals = { __omniRequestHeaders?: Headers };
|
||||
const G = globalThis as OmniGlobals;
|
||||
|
||||
function reset() {
|
||||
delete G.__omniRequestHeaders;
|
||||
}
|
||||
|
||||
function setHeaders(headers: Record<string, string>) {
|
||||
G.__omniRequestHeaders = new Headers(headers);
|
||||
}
|
||||
|
||||
test("isLocalRequestAllowed: allows when no headers injected and not production", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows loopback host + empty xff (browser dev path)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
setHeaders({ host: "localhost:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows IPv4 loopback host 127.0.0.1", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({ host: "127.0.0.1:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: allows IPv6 loopback host [::1]", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({ host: "[::1]:20128" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects public host even with loopback x-forwarded-for", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
// Public Host with loopback XFF is rejected — Host header is the
|
||||
// authoritative loopback check (defence against Host header injection
|
||||
// from a tunneled dev URL).
|
||||
setHeaders({ host: "example.com", "x-forwarded-for": "127.0.0.1" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects non-loopback origin (no bearer token)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
setHeaders({ host: "example.com", "x-forwarded-for": "203.0.113.5" });
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: bearer token takes precedence over host check (desktop app)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
|
||||
delete process.env.NODE_ENV;
|
||||
// Non-loopback origin BUT valid bearer token → allowed (desktop app)
|
||||
setHeaders({
|
||||
host: "example.com",
|
||||
"x-forwarded-for": "203.0.113.5",
|
||||
authorization: "Bearer s3cret-token-abc",
|
||||
});
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: rejects wrong bearer token even with token configured", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
|
||||
delete process.env.NODE_ENV;
|
||||
setHeaders({
|
||||
host: "example.com",
|
||||
"x-forwarded-for": "203.0.113.5",
|
||||
authorization: "Bearer wrong-token",
|
||||
});
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
// Falls through to the host check, which rejects.
|
||||
assert.equal(out.reason, "non-local origin");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: production without opt-in rejects (no headers path)", () => {
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
process.env.NODE_ENV = "production";
|
||||
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, false);
|
||||
assert.equal(out.reason, "disabled in production");
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test("isLocalRequestAllowed: production WITH opt-in allows (no headers path)", () => {
|
||||
// The "no headers" path bypasses the host check entirely; it's the catch-all
|
||||
// for non-Next contexts (cron jobs, scripts). In production with opt-in,
|
||||
// the function returns { allowed: true } for that path. This is by design:
|
||||
// the production gate is at the route handler, not the guard.
|
||||
const prevNodeEnv = process.env.NODE_ENV;
|
||||
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = "1";
|
||||
reset();
|
||||
try {
|
||||
const out = isLocalRequestAllowed();
|
||||
assert.equal(out.allowed, true);
|
||||
} finally {
|
||||
reset();
|
||||
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
|
||||
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user