mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
fix(cli): stop pre-filling the secrets the server owns (#11436)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Resolvido um conflito de merge não-relacionado em src/shared/utils/wsPath.ts (originado de um refactor já mergeado nessa branch depois do fork deste PR; o diff real deste PR — scripts/dev/sync-env.mjs + tests/unit/sync-env.test.ts — ficou intacto) e revalidado: typecheck:core limpo, 13/13 testes focados passando. Segue o precedente correto do #1622 (STORAGE_ENCRYPTION_KEY) para os dois secrets restantes que a postinstall preenchia por engano, defeituando o mecanismo de ensureSecrets(). Obrigado pela contribuição!
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ _tasks/
|
||||
.agents/**
|
||||
.claude/**
|
||||
.gemini/**
|
||||
.code-forge/**
|
||||
.config/**
|
||||
.data/**
|
||||
.logs/**
|
||||
|
||||
1
changelog.d/fixes/11388-live-ws-handshake-port.md
Normal file
1
changelog.d/fixes/11388-live-ws-handshake-port.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Live dashboard:** honour the WebSocket port reported by `/api/v1/ws?handshake=1` instead of the port compiled into the bundle, so a `LIVE_WS_PORT` override reaches prebuilt Docker/npm images and Combo Studio Live connects behind a reverse proxy ([#11331](https://github.com/diegosouzapw/OmniRoute/issues/11331)).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path.
|
||||
@@ -0,0 +1 @@
|
||||
- **test(kimi):** the Kimi background health sweep no longer draws its refresh window inside the assertion. `checkKimiWebConnectionIfNeeded` spreads the refresh over `[60, 240)` seconds before expiry so a fleet of connections does not stampede the token endpoint, and the test used a token expiring in 90 seconds and asserted that a refresh happened — which is true only when the draw lands at 90 or above, i.e. 150 of the 180 possible values. Measured: the test fails 1 run in 6 (16.7% by construction; 4 of 20 local runs), and it is what the Node 26 nightly hit and reported as a Node-compat break (#11361). The spread is now `defaultKimiRefreshJitterSec()` and the window is injectable as `jitterSecFn`, so the test decides it instead of rolling for it; production behaviour is unchanged. Cases were added for a token outside the window and for the default spread's range.
|
||||
@@ -48,6 +48,17 @@ export const GLM_SHARED_MODELS = Object.freeze([
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low"],
|
||||
},
|
||||
{
|
||||
// Explicit alias for the upstream default (max) — pins reasoning_effort so
|
||||
// the tier survives an upstream default change, and mirrors glm-5.2-max UX.
|
||||
id: "glm-5.3-max",
|
||||
name: "GLM 5.3 Max",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["max"],
|
||||
},
|
||||
{
|
||||
// GLM-5.2 has two positive effective tiers: low/medium map to high and xhigh
|
||||
// maps to max; disabling thinking remains the separate thinking toggle.
|
||||
|
||||
@@ -85,6 +85,8 @@ function parseGlmEffortTier(model: string): GlmEffortTier | null {
|
||||
return { baseModel: "glm-5.3", effort: "high", transport: "openai" };
|
||||
case "glm-5.3-low":
|
||||
return { baseModel: "glm-5.3", effort: "low", transport: "openai" };
|
||||
case "glm-5.3-max":
|
||||
return { baseModel: "glm-5.3", effort: "max", transport: "openai" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,13 +37,23 @@ function resolveRootDir(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Secrets this file may fill in when `.env.example` ships them blank.
|
||||
//
|
||||
// JWT_SECRET, API_KEY_SECRET and STORAGE_ENCRYPTION_KEY are deliberately NOT
|
||||
// here: the server owns them. It restores each one from its durable store, or
|
||||
// generates and persists it there on first use — STORAGE_ENCRYPTION_KEY in
|
||||
// bin/omniroute.mjs (guarded by bin/cli/utils/storageKeyProvision.mjs), the
|
||||
// other two in src/instrumentation-node.ts::ensureSecrets(), which persists to
|
||||
// the `secrets` namespace of the database under DATA_DIR.
|
||||
//
|
||||
// Filling any of them here defeats that: this file lives inside the installed
|
||||
// package, so `npm i -g` replaces it and postinstall writes a *different*
|
||||
// value, while ensureSecrets() — which only acts on an empty variable — never
|
||||
// gets to restore the real one. The secret then rotates silently on every
|
||||
// update, invalidating dashboard sessions (JWT_SECRET) and API-key CRCs
|
||||
// (API_KEY_SECRET). STORAGE_ENCRYPTION_KEY was pulled out first, for the same
|
||||
// reason, when it cost users their encrypted credentials (issue #1622).
|
||||
const CRYPTO_SECRETS = {
|
||||
JWT_SECRET: () => randomBytes(64).toString("hex"),
|
||||
API_KEY_SECRET: () => randomBytes(32).toString("hex"),
|
||||
// STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall.
|
||||
// Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to
|
||||
// ~/.omniroute/.env to survive across upgrades. This prevents credential loss
|
||||
// when upgrading OmniRoute (issue #1622).
|
||||
MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import {
|
||||
normalizeModelSupportedEndpoints,
|
||||
type ModelSupportedEndpoint,
|
||||
} from "@/shared/constants/modelSupportedEndpoints";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import {
|
||||
buildCompatMap,
|
||||
@@ -51,6 +55,29 @@ function targetFormatLabel(value: string, t: (key: string) => string): string {
|
||||
return key ? t(key) : value;
|
||||
}
|
||||
|
||||
const MODEL_ENDPOINT_OPTIONS: ModelSupportedEndpoint[] = [
|
||||
"chat",
|
||||
"embeddings",
|
||||
"rerank",
|
||||
"images",
|
||||
"videos",
|
||||
"audio-speech",
|
||||
"audio-transcriptions",
|
||||
];
|
||||
|
||||
function endpointLabel(endpoint: ModelSupportedEndpoint, t: (key: string) => string): string {
|
||||
const labels: Partial<Record<ModelSupportedEndpoint, string>> = {
|
||||
chat: `💬 ${t("supportedEndpointChat")}`,
|
||||
embeddings: `📐 ${t("supportedEndpointEmbeddings")}`,
|
||||
rerank: providerText(t, "rerankEndpoint", "Rerank"),
|
||||
images: `🖼️ ${t("supportedEndpointImages")}`,
|
||||
videos: "🎬 Video",
|
||||
"audio-speech": `🔊 ${t("audioSpeech")}`,
|
||||
"audio-transcriptions": `🎙️ ${t("audioTranscriptions")}`,
|
||||
};
|
||||
return labels[endpoint] || endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* #4125: parse the free-text "Context Window Override" field. Blank → no override
|
||||
* (`value: null`, not an error). A non-empty value must be a positive whole number of
|
||||
@@ -232,7 +259,7 @@ export default function CustomModelsSection({
|
||||
setEditingApiFormat(model.apiFormat || "chat-completions");
|
||||
setEditingEndpoints(
|
||||
Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length
|
||||
? model.supportedEndpoints
|
||||
? normalizeModelSupportedEndpoints(model.supportedEndpoints)
|
||||
: ["chat"]
|
||||
);
|
||||
setEditingTargetFormat(model.targetFormat || "");
|
||||
@@ -428,6 +455,7 @@ export default function CustomModelsSection({
|
||||
<option value="audio-transcriptions">{t("audioTranscriptions")}</option>
|
||||
<option value="audio-speech">{t("audioSpeech")}</option>
|
||||
<option value="images-generations">{t("imagesGenerations")}</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
@@ -454,7 +482,7 @@ export default function CustomModelsSection({
|
||||
{t("supportedEndpointsLabel")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{["chat", "embeddings", "rerank", "images", "audio"].map((ep) => (
|
||||
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
|
||||
@@ -471,15 +499,7 @@ export default function CustomModelsSection({
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ep === "chat"
|
||||
? `💬 ${t("supportedEndpointChat")}`
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
{endpointLabel(ep, t)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -594,6 +614,22 @@ export default function CustomModelsSection({
|
||||
{`🔊 ${t("audioShortLabel")}`}
|
||||
</span>
|
||||
)}
|
||||
{(model.supportedEndpoints?.includes("videos") ||
|
||||
model.supportedEndpoints?.includes("video")) && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/15 text-red-400 font-medium">
|
||||
🎬 Video
|
||||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio-speech") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
|
||||
{`🔊 ${t("audioSpeech")}`}
|
||||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio-transcriptions") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium">
|
||||
{`🎙️ ${t("audioTranscriptions")}`}
|
||||
</span>
|
||||
)}
|
||||
{anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
|
||||
@@ -639,6 +675,7 @@ export default function CustomModelsSection({
|
||||
<option value="audio-transcriptions">{t("audioTranscriptions")}</option>
|
||||
<option value="audio-speech">{t("audioSpeech")}</option>
|
||||
<option value="images-generations">{t("imagesGenerations")}</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[11rem] shrink-0 min-w-0">
|
||||
@@ -697,7 +734,7 @@ export default function CustomModelsSection({
|
||||
{t("supportedEndpointsLabel")}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
|
||||
{["chat", "embeddings", "rerank", "images", "audio"].map((ep) => (
|
||||
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
|
||||
@@ -716,15 +753,7 @@ export default function CustomModelsSection({
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ep === "chat"
|
||||
? `💬 ${t("supportedEndpointChat")}`
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
{endpointLabel(ep, t)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,21 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
import { getBaseUrl } from "@/lib/wellKnown";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
/**
|
||||
* GET /.well-known/agent-card.json
|
||||
*
|
||||
* Returns the OmniRoute Agent Card (A2A v1.0).
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const baseUrl = getBaseUrl(request);
|
||||
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI Gateway",
|
||||
@@ -31,16 +33,16 @@ export async function GET() {
|
||||
"Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " +
|
||||
"format translation, and auto-managed combos. Routes AI requests to the optimal " +
|
||||
"provider based on cost, latency, quota availability, and task requirements.",
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
version: PACKAGE_VERSION,
|
||||
supportedInterfaces: [
|
||||
{
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
protocolBinding: "JSONRPC",
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
protocolBinding: "JSONRPC",
|
||||
protocolVersion: "0.3",
|
||||
},
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
import { getBaseUrl } from "@/lib/wellKnown";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
/**
|
||||
* GET /.well-known/agent.json
|
||||
@@ -21,17 +22,18 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
* Returns the OmniRoute Agent Card that describes this gateway's
|
||||
* capabilities as an A2A agent.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when
|
||||
// the hub is unset/offline — the card stays valid without the fleet section).
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const baseUrl = getBaseUrl(request);
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI 网关",
|
||||
description:
|
||||
"智能 AI 路由网关,支持 36+ 个提供者、智能回退、配额跟踪、" +
|
||||
"格式转换和自动管理组合。根据成本、延迟、配额可用性" +
|
||||
"和任务要求将 AI 请求路由到最优提供者。",
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
version: PACKAGE_VERSION,
|
||||
capabilities: {
|
||||
streaming: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { classifyIpScope } from "@/lib/ipUtils";
|
||||
import { getCachedSettings } from "@/lib/db/settings";
|
||||
@@ -13,6 +14,7 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
||||
import { loginSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard";
|
||||
import { AUTHZ_HEADER_TRUSTED_PEER_IP } from "@/server/authz/headers";
|
||||
|
||||
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
@@ -28,7 +30,7 @@ export const authRouteInternals = {
|
||||
getCookieStore: cookies,
|
||||
};
|
||||
|
||||
export async function POST(request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
try {
|
||||
@@ -75,7 +77,10 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
|
||||
}
|
||||
const settings = await getCachedSettings();
|
||||
const clientIp = auditContext.ipAddress || null;
|
||||
const trustedPeerIp = process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
? request.headers.get(AUTHZ_HEADER_TRUSTED_PEER_IP)
|
||||
: null;
|
||||
const clientIp = trustedPeerIp || auditContext.ipAddress || null;
|
||||
const oidcDisabledPassword =
|
||||
settings.oidcEnabled === true &&
|
||||
(settings.oidcDisablePasswordLogin === true ||
|
||||
@@ -118,9 +123,7 @@ export async function POST(request) {
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: guardCheck.retryAfterSeconds
|
||||
? { "Retry-After": String(guardCheck.retryAfterSeconds) }
|
||||
: {},
|
||||
headers: { "Retry-After": String(guardCheck.retryAfterSeconds || 60) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -220,9 +223,7 @@ export async function POST(request) {
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: failureDecision.retryAfterSeconds
|
||||
? { "Retry-After": String(failureDecision.retryAfterSeconds) }
|
||||
: {},
|
||||
headers: { "Retry-After": String(failureDecision.retryAfterSeconds || 60) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
import { createModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot";
|
||||
import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync";
|
||||
import { getModelSpec } from "@/shared/constants/modelSpecs";
|
||||
import { classifyModelSupportedEndpoints } from "@/shared/constants/modelSupportedEndpoints";
|
||||
import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags";
|
||||
import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes";
|
||||
import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse";
|
||||
@@ -1152,18 +1153,15 @@ async function buildUnifiedModelsResponseCore(
|
||||
const aliasId = `${alias}/${displayModelId}`;
|
||||
const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"];
|
||||
const apiFormat = typeof sm.apiFormat === "string" ? sm.apiFormat : "chat-completions";
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("rerank")) modelType = "rerank";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
const classification = classifyModelSupportedEndpoints(endpoints);
|
||||
const modelType = classification.type;
|
||||
// Same owned_by the alias/canonical entries below will carry — computed once
|
||||
// so the effort_tiers exclusion (codex/glm/kimi) and the entries agree.
|
||||
const syncedOwnedBy = resolvePublicOwnerId(providerId, canonicalProviderId);
|
||||
const syncedFields = {
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
|
||||
...(modelType === "audio" ? { subtype: "transcription" } : {}),
|
||||
...(classification.subtype ? { subtype: classification.subtype } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(typeof sm.outputTokenLimit === "number"
|
||||
? { max_output_tokens: sm.outputTokenLimit }
|
||||
@@ -1604,11 +1602,8 @@ async function buildUnifiedModelsResponseCore(
|
||||
: ["chat"];
|
||||
const apiFormat =
|
||||
typeof model.apiFormat === "string" ? model.apiFormat : "chat-completions";
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("rerank")) modelType = "rerank";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
const classification = classifyModelSupportedEndpoints(endpoints);
|
||||
const modelType = classification.type;
|
||||
if (
|
||||
modelType &&
|
||||
hasEquivalentSpecialtyModel(canonicalProviderId, modelId, modelType, aliasId)
|
||||
@@ -1631,6 +1626,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
parent: null,
|
||||
custom: true,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(classification.subtype ? { subtype: classification.subtype } : {}),
|
||||
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { DashboardChannel, DashboardEventName } from "@/lib/events/types";
|
||||
import { deriveLiveWsPath } from "@/shared/utils/wsPath";
|
||||
import { deriveLiveWsPath, resolveLiveWsUrl, sanitizeLiveWsPort } from "@/shared/utils/wsPath";
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,14 +40,10 @@ function getDefaultWsUrl(): string {
|
||||
if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const { hostname } = window.location;
|
||||
// Bug #1 fix: Use the WS server's actual port (20132) for both loopback
|
||||
// and non-loopback clients. Previously the non-loopback branch tried to
|
||||
// upgrade the HTTP port (window.location.host) which has no upgrade
|
||||
// handler in src/proxy.ts. If the user wants the upgrade to go through
|
||||
// Next.js (same-origin), they should explicitly pass `wsUrl`.
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
|
||||
}
|
||||
// The WS server's own port, for loopback and non-loopback alike: the HTTP
|
||||
// port has no upgrade handler in src/proxy.ts. This is only the starting
|
||||
// point - the handshake below replaces the port when the server reports a
|
||||
// different one, and a caller can always pass `wsUrl` outright.
|
||||
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
|
||||
}
|
||||
|
||||
@@ -113,6 +109,7 @@ export function useLiveDashboard({
|
||||
const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined";
|
||||
const [handshakeUrl, setHandshakeUrl] = useState<string | null>(null);
|
||||
const [handshakePath, setHandshakePath] = useState<string | null>(null);
|
||||
const [handshakePort, setHandshakePort] = useState<number | null>(null);
|
||||
const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,6 +124,11 @@ export function useLiveDashboard({
|
||||
if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) {
|
||||
setHandshakePath(body.live.path);
|
||||
}
|
||||
// The live server reports the port it is actually listening on, so a
|
||||
// LIVE_WS_PORT override reaches a prebuilt image instead of being
|
||||
// overruled by the compiled-in default (#11331).
|
||||
const port = sanitizeLiveWsPort(body?.live?.port);
|
||||
if (port !== null) setHandshakePort(port);
|
||||
})
|
||||
.catch(() => {
|
||||
// Handshake unavailable — fall back to the default URL.
|
||||
@@ -139,20 +141,13 @@ export function useLiveDashboard({
|
||||
};
|
||||
}, [needsHandshake, wsUrlResolved]);
|
||||
|
||||
const effectiveWsUrl = (() => {
|
||||
if (wsUrl) return wsUrl;
|
||||
if (handshakeUrl) return handshakeUrl;
|
||||
if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) {
|
||||
try {
|
||||
const url = new URL(DEFAULT_WS_URL);
|
||||
url.pathname = handshakePath;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return DEFAULT_WS_URL;
|
||||
}
|
||||
}
|
||||
return DEFAULT_WS_URL;
|
||||
})();
|
||||
const effectiveWsUrl = resolveLiveWsUrl({
|
||||
explicit: wsUrl,
|
||||
handshakeUrl,
|
||||
handshakePort,
|
||||
handshakePath: handshakePath !== BUILD_TIME_WS_PATH ? handshakePath : null,
|
||||
defaultUrl: DEFAULT_WS_URL,
|
||||
});
|
||||
|
||||
const [events, setEvents] = useState<WsEventPayload[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@@ -190,7 +190,8 @@ export async function addCustomModel(
|
||||
| "rerank"
|
||||
| "audio-transcriptions"
|
||||
| "audio-speech"
|
||||
| "images-generations" = "chat-completions",
|
||||
| "images-generations"
|
||||
| "video" = "chat-completions",
|
||||
supportedEndpoints: string[] = ["chat"],
|
||||
// #2905: optional per-model wire format override (e.g. "claude" for an
|
||||
// opencode-go custom model). When unset, routing falls back to the provider
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
*
|
||||
* Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints:
|
||||
* - generateContent / generateAnswer → "chat"
|
||||
* - predictLongRunning → "video" (Veo video generation)
|
||||
* - predict → "images" (Imagen image generation)
|
||||
* - predictLongRunning → "videos" (Veo video generation)
|
||||
* - embedContent → "embeddings"
|
||||
* - bidiGenerateContent → "audio" (Live real-time audio)
|
||||
*
|
||||
* Model-id heuristics ensure Veo models remain in the video bucket.
|
||||
* Model-id heuristics refine the long-running bucket because Google exposes both
|
||||
* Imagen and Veo via long-running methods on the same endpoint:
|
||||
* - id contains "veo" → ensure "videos"
|
||||
* - id contains "imagen" → force "images" (never "videos")
|
||||
*
|
||||
* Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the
|
||||
* regular `generateContent` path, so they stay "chat" (image output is a chat
|
||||
@@ -21,7 +25,8 @@
|
||||
const METHOD_TO_ENDPOINT: Record<string, string> = {
|
||||
generateContent: "chat",
|
||||
embedContent: "embeddings",
|
||||
predictLongRunning: "video",
|
||||
predict: "images",
|
||||
predictLongRunning: "videos",
|
||||
bidiGenerateContent: "audio",
|
||||
generateAnswer: "chat",
|
||||
};
|
||||
@@ -63,9 +68,14 @@ export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
|
||||
const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, "");
|
||||
const lowerId = id.toLowerCase();
|
||||
|
||||
// Keep Veo models in the video bucket even when the method list is incomplete.
|
||||
// Google exposes Imagen (image) and Veo (video) via long-running methods; the
|
||||
// method alone can't always distinguish them, so refine by model id.
|
||||
if (lowerId.includes("veo")) {
|
||||
endpoints.add("video");
|
||||
endpoints.add("videos");
|
||||
}
|
||||
if (lowerId.includes("imagen")) {
|
||||
endpoints.delete("videos");
|
||||
endpoints.add("images");
|
||||
}
|
||||
|
||||
if (endpoints.size === 0) endpoints.add("chat");
|
||||
|
||||
@@ -221,7 +221,7 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[
|
||||
if (speechProvider) {
|
||||
appendModels(speechProvider.models, {
|
||||
apiFormat: "audio",
|
||||
supportedEndpoints: ["audio"],
|
||||
supportedEndpoints: ["audio-speech"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[
|
||||
if (transcriptionProvider) {
|
||||
appendModels(transcriptionProvider.models, {
|
||||
apiFormat: "audio",
|
||||
supportedEndpoints: ["audio"],
|
||||
supportedEndpoints: ["audio-transcriptions"],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ import { isKimiTokenExpiringSoon } from "@omniroute/open-sse/utils/kimiJwt.ts";
|
||||
import { exchangeKimiRefreshToken } from "@/lib/kimi/tokenRefresh";
|
||||
import { updateProviderConnection } from "@/lib/db/providers";
|
||||
|
||||
/**
|
||||
* Refresh window, spread over [60, 240) seconds before expiry so a fleet of
|
||||
* connections does not stampede the token endpoint at the same instant.
|
||||
*
|
||||
* Kept as a named export rather than inline: it is the only nondeterminism in this
|
||||
* path, and a caller that needs a decision it can predict — a test — has to be able
|
||||
* to replace it. `tests/unit/token-health-check-kimi.test.ts` used a token expiring
|
||||
* in 90 s and asserted a refresh, which is a coin the draw loses 1 in 6 times
|
||||
* (a refresh needs `jitter >= 90`, i.e. 150 of the 180 possible values). It failed
|
||||
* that way on the Node 26 nightly and was triaged as a Node-compat break.
|
||||
*/
|
||||
export function defaultKimiRefreshJitterSec(): number {
|
||||
return 60 + Math.floor(Math.random() * 180);
|
||||
}
|
||||
|
||||
export async function checkKimiWebConnectionIfNeeded(params: {
|
||||
conn: any;
|
||||
now: string;
|
||||
@@ -12,6 +27,12 @@ export async function checkKimiWebConnectionIfNeeded(params: {
|
||||
logPrefix: string;
|
||||
exchangeFn?: typeof exchangeKimiRefreshToken;
|
||||
persistFn?: typeof updateProviderConnection;
|
||||
/**
|
||||
* Seconds before expiry at which a refresh is triggered. Defaults to the random
|
||||
* spread below; injectable so a caller — a test above all — can decide the window
|
||||
* instead of drawing it.
|
||||
*/
|
||||
jitterSecFn?: () => number;
|
||||
}): Promise<boolean> {
|
||||
const { conn, log, logWarn, getConnectionLogLabel, logPrefix } = params;
|
||||
const provider = String(conn?.provider || "").toLowerCase();
|
||||
@@ -21,20 +42,23 @@ export async function checkKimiWebConnectionIfNeeded(params: {
|
||||
if (!refreshToken) return true; // Handled, but cannot refresh without refresh_token
|
||||
|
||||
const token = conn.apiKey || conn.accessToken;
|
||||
// Calculate jitter: random value between 60 and 240 seconds (1 to 4 min before expiry)
|
||||
const jitterSec = 60 + Math.floor(Math.random() * 180);
|
||||
const jitterSec = (params.jitterSecFn ?? defaultKimiRefreshJitterSec)();
|
||||
const expiringSoon = isKimiTokenExpiringSoon(token, jitterSec);
|
||||
|
||||
if (!expiringSoon) return true;
|
||||
|
||||
log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...`);
|
||||
log(
|
||||
`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...`
|
||||
);
|
||||
|
||||
const exchange = params.exchangeFn || exchangeKimiRefreshToken;
|
||||
const persist = params.persistFn || updateProviderConnection;
|
||||
|
||||
const res = await exchange(refreshToken);
|
||||
if (res.success && res.accessToken) {
|
||||
log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.`);
|
||||
log(
|
||||
`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.`
|
||||
);
|
||||
await persist(conn.id, {
|
||||
apiKey: res.accessToken,
|
||||
accessToken: res.accessToken,
|
||||
|
||||
11
src/lib/wellKnown.ts
Normal file
11
src/lib/wellKnown.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Derive the base URL for A2A agent card endpoints.
|
||||
* Prefers OMNIROUTE_BASE_URL env var for admin override; falls back to the
|
||||
* request's dynamic origin so the gateway works behind any hostname without
|
||||
* hardcoded localhost:20128 (S2 security fix).
|
||||
*/
|
||||
export function getBaseUrl(request: NextRequest): string {
|
||||
return process.env.OMNIROUTE_BASE_URL || request.nextUrl.origin;
|
||||
}
|
||||
@@ -62,6 +62,16 @@ export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";
|
||||
*/
|
||||
export const AUTHZ_HEADER_PEER_LOCALITY = "x-omniroute-peer-locality";
|
||||
|
||||
/**
|
||||
* The resolved real peer IP, stamped by the pipeline AFTER verifying the
|
||||
* token-stamped PEER_IP_HEADER. This is the trusted, non-spoofable IP that
|
||||
* route handlers (e.g. login rate-limit key) should use instead of re-deriving
|
||||
* from X-Forwarded-For / X-Real-IP. Set only when the stamp token is configured
|
||||
* and the HMAC signature validates; absent when the stamp is not in use.
|
||||
* Stripped from incoming requests like all other trusted headers.
|
||||
*/
|
||||
export const AUTHZ_HEADER_TRUSTED_PEER_IP = "x-omniroute-trusted-peer-ip";
|
||||
|
||||
/**
|
||||
* Headers the pipeline must NEVER trust on incoming requests. They are
|
||||
* stripped before route classification to prevent header-spoofing attacks.
|
||||
@@ -73,4 +83,5 @@ export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray<string> = [
|
||||
AUTHZ_HEADER_AUTH_LABEL,
|
||||
AUTHZ_HEADER_AUTH_SCOPES,
|
||||
AUTHZ_HEADER_PEER_LOCALITY,
|
||||
AUTHZ_HEADER_TRUSTED_PEER_IP,
|
||||
];
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AUTHZ_HEADER_PEER_LOCALITY,
|
||||
AUTHZ_HEADER_REQUEST_ID,
|
||||
AUTHZ_HEADER_ROUTE_CLASS,
|
||||
AUTHZ_HEADER_TRUSTED_PEER_IP,
|
||||
AUTHZ_TRUSTED_HEADERS,
|
||||
CLI_TOKEN_HEADER,
|
||||
PEER_IP_HEADER,
|
||||
@@ -332,6 +333,16 @@ export async function runAuthzPipeline(
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
);
|
||||
requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality);
|
||||
// Stamp the resolved, non-spoofable peer IP for route handlers that need
|
||||
// the real client IP (e.g. login rate-limit key). Only set when the stamp
|
||||
// token is configured and the HMAC signature validates; absent otherwise.
|
||||
const trustedPeerIp = resolveStampedPeer(
|
||||
request.headers.get(PEER_IP_HEADER),
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
);
|
||||
if (trustedPeerIp) {
|
||||
requestHeaders.set(AUTHZ_HEADER_TRUSTED_PEER_IP, trustedPeerIp);
|
||||
}
|
||||
// Local CLI-token auth is decided centrally above. Preserve that trusted
|
||||
// decision for route-level requireManagementAuth without forwarding the
|
||||
// machine token itself: custom client auth headers are stripped before the
|
||||
|
||||
@@ -70,6 +70,7 @@ const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set([
|
||||
"glm-5.3",
|
||||
"glm-5.3-high",
|
||||
"glm-5.3-low",
|
||||
"glm-5.3-max",
|
||||
"glm-5.2",
|
||||
"glm-5.2-high",
|
||||
"glm-5.2-max",
|
||||
@@ -573,6 +574,13 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
"glm-5.3-max": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 1000000,
|
||||
thinkingBudgetCap: 38912,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
|
||||
// ── Z.AI GLM-5.2 (1M context, 128K max output, effort tiers) ────
|
||||
"glm-5.2": {
|
||||
|
||||
54
src/shared/constants/modelSupportedEndpoints.ts
Normal file
54
src/shared/constants/modelSupportedEndpoints.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const MODEL_SUPPORTED_ENDPOINT_VALUES = [
|
||||
"chat",
|
||||
"embeddings",
|
||||
"rerank",
|
||||
"images",
|
||||
"videos",
|
||||
"audio-speech",
|
||||
"audio-transcriptions",
|
||||
"images-generations",
|
||||
// Persisted legacy values remain valid input and normalize on write/edit.
|
||||
"video",
|
||||
"audio",
|
||||
] as const;
|
||||
|
||||
export type ModelSupportedEndpoint = (typeof MODEL_SUPPORTED_ENDPOINT_VALUES)[number];
|
||||
|
||||
export function normalizeModelSupportedEndpoints(endpoints: readonly string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
const add = (endpoint: string) => {
|
||||
if (!normalized.includes(endpoint)) normalized.push(endpoint);
|
||||
};
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
if (endpoint === "video") {
|
||||
add("videos");
|
||||
} else if (endpoint === "audio") {
|
||||
add("audio-speech");
|
||||
add("audio-transcriptions");
|
||||
} else {
|
||||
add(endpoint);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function classifyModelSupportedEndpoints(endpoints: readonly string[]): {
|
||||
type?: "embedding" | "rerank" | "image" | "video" | "audio";
|
||||
subtype?: "speech" | "transcription";
|
||||
} {
|
||||
if (endpoints.includes("embeddings")) return { type: "embedding" };
|
||||
if (endpoints.includes("rerank")) return { type: "rerank" };
|
||||
if (endpoints.includes("images")) return { type: "image" };
|
||||
if (endpoints.includes("videos") || endpoints.includes("video")) return { type: "video" };
|
||||
|
||||
const supportsSpeech = endpoints.includes("audio-speech");
|
||||
const supportsTranscription =
|
||||
endpoints.includes("audio-transcriptions") || endpoints.includes("audio");
|
||||
if (!supportsSpeech && !supportsTranscription) return {};
|
||||
if (supportsSpeech && !supportsTranscription) return { type: "audio", subtype: "speech" };
|
||||
if (supportsTranscription && !supportsSpeech) {
|
||||
return { type: "audio", subtype: "transcription" };
|
||||
}
|
||||
return { type: "audio" };
|
||||
}
|
||||
@@ -135,6 +135,13 @@ export const GLM_PRICING = {
|
||||
reasoning: 5,
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
"glm-5.3-max": {
|
||||
input: 1.2,
|
||||
output: 5,
|
||||
cached: 0.3,
|
||||
reasoning: 5,
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
"glm-5.2": {
|
||||
input: 1.2,
|
||||
output: 5,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
|
||||
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import {
|
||||
MODEL_SUPPORTED_ENDPOINT_VALUES,
|
||||
normalizeModelSupportedEndpoints,
|
||||
} from "@/shared/constants/modelSupportedEndpoints";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import {
|
||||
@@ -238,22 +242,12 @@ export const providerModelMutationSchema = z.object({
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
"video",
|
||||
])
|
||||
.default("chat-completions"),
|
||||
supportedEndpoints: z
|
||||
.array(
|
||||
z.enum([
|
||||
"chat",
|
||||
"embeddings",
|
||||
"rerank",
|
||||
"images",
|
||||
"audio",
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
"videos",
|
||||
])
|
||||
)
|
||||
.array(z.enum(MODEL_SUPPORTED_ENDPOINT_VALUES))
|
||||
.transform(normalizeModelSupportedEndpoints)
|
||||
.default(["chat"]),
|
||||
// #2905: optional per-model wire format override for custom models (e.g. a
|
||||
// custom opencode-go model that must use the Anthropic Messages shape).
|
||||
|
||||
@@ -82,11 +82,11 @@ test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseGeminiModelsList maps Veo predictLongRunning models to the video endpoint", () => {
|
||||
test("parseGeminiModelsList maps Veo predictLongRunning models to the videos endpoint", () => {
|
||||
const models = parseGeminiModelsList(SAMPLE);
|
||||
const veo = models.find((m) => m.id === "veo-3.0-generate-001");
|
||||
assert.ok(veo, "veo-3.0-generate-001 should be present");
|
||||
assert.deepEqual(veo!.supportedEndpoints, ["video"]);
|
||||
assert.deepEqual(veo!.supportedEndpoints, ["videos"]);
|
||||
});
|
||||
|
||||
test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const metadataRegistry = await import("../../src/lib/modelMetadataRegistry.ts");
|
||||
const { shouldExposeSyncedEffortVariants, SYNCED_EFFORT_SKIP_PROVIDERS } =
|
||||
await import("../../open-sse/utils/syncedEffortVariants.ts");
|
||||
|
||||
const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low"] as const;
|
||||
const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low", "glm-5.3-max"] as const;
|
||||
|
||||
// transformForTransport returns an opaque body; surface only the fields asserted below.
|
||||
type TransformedRequest = {
|
||||
@@ -99,6 +99,7 @@ test("catalog exposes only GLM effort tiers that each provider can route", () =>
|
||||
["glm-5.3", ["low", "high", "max"]],
|
||||
["glm-5.3-high", ["high"]],
|
||||
["glm-5.3-low", ["low"]],
|
||||
["glm-5.3-max", ["max"]],
|
||||
["glm-5.2", ["high", "max"]],
|
||||
["glm-5.2-high", ["high"]],
|
||||
["glm-5.2-max", ["max"]],
|
||||
@@ -119,7 +120,6 @@ test("catalog exposes only GLM effort tiers that each provider can route", () =>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const provider of ["glm", "glm-cn", "glmt"]) {
|
||||
test(`${provider} advertises the GLM-5.3 base model and effort tiers (GLM_SHARED_MODELS)`, () => {
|
||||
const ids = modelIds(provider);
|
||||
@@ -142,7 +142,7 @@ for (const provider of ["glm", "glm-cn", "glmt"]) {
|
||||
test("zai advertises the GLM-5.3 base model only (DefaultExecutor sends ids verbatim)", () => {
|
||||
const ids = modelIds("zai");
|
||||
assert.ok(ids.includes("glm-5.3"), `zai should advertise glm-5.3; got ${ids.join(", ")}`);
|
||||
for (const alias of ["glm-5.3-high", "glm-5.3-low"]) {
|
||||
for (const alias of ["glm-5.3-high", "glm-5.3-low", "glm-5.3-max"]) {
|
||||
assert.ok(
|
||||
!ids.includes(alias),
|
||||
`zai must not list ${alias}: GlmExecutor-only alias, unknown upstream on the Anthropic endpoint`
|
||||
@@ -200,6 +200,20 @@ test("GlmExecutor resolves glm-5.3-low to reasoning_effort=low with thinking ena
|
||||
assert.equal(transformed.thinking?.type, "enabled");
|
||||
});
|
||||
|
||||
test("GlmExecutor resolves glm-5.3-max to an explicit reasoning_effort=max (pins the tier even if the upstream default changes)", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const transformed = executor.transformForTransport(
|
||||
"glm-5.3-max",
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
false,
|
||||
{ apiKey: "glm-key" },
|
||||
"openai"
|
||||
) as TransformedRequest;
|
||||
|
||||
assert.equal(transformed.model, "glm-5.3");
|
||||
assert.equal(transformed.reasoning_effort, "max");
|
||||
assert.equal(transformed.thinking?.type, "enabled");
|
||||
});
|
||||
test("GlmExecutor leaves base glm-5.3 without an injected reasoning_effort (upstream default = max)", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const transformed = executor.transformForTransport(
|
||||
|
||||
97
tests/unit/live-ws-url-11331.test.ts
Normal file
97
tests/unit/live-ws-url-11331.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
deriveLiveWsPath,
|
||||
resolveLiveWsUrl,
|
||||
sanitizeLiveWsPort,
|
||||
} from "../../src/shared/utils/wsPath.ts";
|
||||
|
||||
/**
|
||||
* The /api/v1/ws?handshake=1 response reports `live.port` — the port the live
|
||||
* server is actually listening on — but the dashboard client read only
|
||||
* `publicUrl` and `path`. An operator who moved the server with LIVE_WS_PORT
|
||||
* still got the compiled-in 20132 and a permanently disconnected Combo Studio
|
||||
* (#11331).
|
||||
*/
|
||||
const DEFAULT_URL = "wss://omniroute.example.tld:20132/live-ws";
|
||||
|
||||
describe("sanitizeLiveWsPort", () => {
|
||||
it("accepts a port in range, as a number or a string", () => {
|
||||
assert.equal(sanitizeLiveWsPort(20140), 20140);
|
||||
assert.equal(sanitizeLiveWsPort("20140"), 20140);
|
||||
});
|
||||
|
||||
it("rejects anything that is not a usable port", () => {
|
||||
for (const value of [0, -1, 65536, 1.5, "", "abc", null, undefined, {}, NaN]) {
|
||||
assert.equal(sanitizeLiveWsPort(value), null, `expected null for ${String(value)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLiveWsUrl", () => {
|
||||
it("uses the port the handshake reports instead of the compiled-in one", () => {
|
||||
const url = resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: DEFAULT_URL });
|
||||
assert.equal(new URL(url).port, "20140");
|
||||
assert.equal(new URL(url).hostname, "omniroute.example.tld");
|
||||
assert.equal(new URL(url).pathname, "/live-ws");
|
||||
});
|
||||
|
||||
it("keeps the default when the handshake reports nothing", () => {
|
||||
assert.equal(resolveLiveWsUrl({ defaultUrl: DEFAULT_URL }), DEFAULT_URL);
|
||||
});
|
||||
|
||||
it("ignores a port the handshake cannot mean", () => {
|
||||
assert.equal(resolveLiveWsUrl({ handshakePort: 0, defaultUrl: DEFAULT_URL }), DEFAULT_URL);
|
||||
assert.equal(
|
||||
resolveLiveWsUrl({ handshakePort: 70000 as number, defaultUrl: DEFAULT_URL }),
|
||||
DEFAULT_URL
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the port and the path together", () => {
|
||||
const url = new URL(
|
||||
resolveLiveWsUrl({ handshakePort: 9443, handshakePath: "/ws/live", defaultUrl: DEFAULT_URL })
|
||||
);
|
||||
assert.equal(url.port, "9443");
|
||||
assert.equal(url.pathname, "/ws/live");
|
||||
});
|
||||
|
||||
it("ignores a path that is not a path", () => {
|
||||
const url = new URL(resolveLiveWsUrl({ handshakePath: "live-ws", defaultUrl: DEFAULT_URL }));
|
||||
assert.equal(url.pathname, "/live-ws");
|
||||
});
|
||||
|
||||
it("lets a complete publicUrl win over the reported port", () => {
|
||||
assert.equal(
|
||||
resolveLiveWsUrl({
|
||||
handshakeUrl: "wss://omniroute.example.tld/live-ws",
|
||||
handshakePort: 20140,
|
||||
defaultUrl: DEFAULT_URL,
|
||||
}),
|
||||
"wss://omniroute.example.tld/live-ws"
|
||||
);
|
||||
});
|
||||
|
||||
it("lets an explicit wsUrl win over everything", () => {
|
||||
assert.equal(
|
||||
resolveLiveWsUrl({
|
||||
explicit: "wss://elsewhere.example/socket",
|
||||
handshakeUrl: "wss://omniroute.example.tld/live-ws",
|
||||
handshakePort: 20140,
|
||||
defaultUrl: DEFAULT_URL,
|
||||
}),
|
||||
"wss://elsewhere.example/socket"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default rather than throwing on an unparseable default", () => {
|
||||
assert.equal(resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: "not a url" }), "not a url");
|
||||
});
|
||||
|
||||
it("leaves deriveLiveWsPath alone", () => {
|
||||
assert.equal(deriveLiveWsPath("wss://host:20132/ws/live"), "/ws/live");
|
||||
assert.equal(deriveLiveWsPath("wss://host:20132/"), "/live-ws");
|
||||
assert.equal(deriveLiveWsPath(undefined), "/live-ws");
|
||||
});
|
||||
});
|
||||
58
tests/unit/model-supported-endpoints.test.ts
Normal file
58
tests/unit/model-supported-endpoints.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyModelSupportedEndpoints,
|
||||
MODEL_SUPPORTED_ENDPOINT_VALUES,
|
||||
normalizeModelSupportedEndpoints,
|
||||
} from "../../src/shared/constants/modelSupportedEndpoints.ts";
|
||||
|
||||
test("normalizes legacy video and audio metadata to operation-specific endpoint ids", () => {
|
||||
assert.deepEqual(normalizeModelSupportedEndpoints(["chat", "video", "audio"]), [
|
||||
"chat",
|
||||
"videos",
|
||||
"audio-speech",
|
||||
"audio-transcriptions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates canonical endpoint ids while preserving order", () => {
|
||||
assert.deepEqual(
|
||||
normalizeModelSupportedEndpoints([
|
||||
"videos",
|
||||
"video",
|
||||
"audio-speech",
|
||||
"audio",
|
||||
"audio-transcriptions",
|
||||
]),
|
||||
["videos", "audio-speech", "audio-transcriptions"]
|
||||
);
|
||||
});
|
||||
|
||||
test("exports operation-specific values accepted by model metadata", () => {
|
||||
assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("videos"));
|
||||
assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("audio-speech"));
|
||||
assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("audio-transcriptions"));
|
||||
});
|
||||
|
||||
test("preserves endpoint ids introduced by external discovery", () => {
|
||||
assert.deepEqual(normalizeModelSupportedEndpoints(["responses", "video"]), [
|
||||
"responses",
|
||||
"videos",
|
||||
]);
|
||||
});
|
||||
|
||||
test("classifies operation-specific media endpoints for the model catalog", () => {
|
||||
assert.deepEqual(classifyModelSupportedEndpoints(["videos"]), { type: "video" });
|
||||
assert.deepEqual(classifyModelSupportedEndpoints(["audio-speech"]), {
|
||||
type: "audio",
|
||||
subtype: "speech",
|
||||
});
|
||||
assert.deepEqual(classifyModelSupportedEndpoints(["audio-transcriptions"]), {
|
||||
type: "audio",
|
||||
subtype: "transcription",
|
||||
});
|
||||
assert.deepEqual(classifyModelSupportedEndpoints(["audio-speech", "audio-transcriptions"]), {
|
||||
type: "audio",
|
||||
});
|
||||
});
|
||||
16
tests/unit/provider-model-endpoint-schema.test.ts
Normal file
16
tests/unit/provider-model-endpoint-schema.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { providerModelMutationSchema } from "../../src/shared/validation/schemas/provider.ts";
|
||||
|
||||
test("provider model mutations accept video and persist canonical operation endpoints", () => {
|
||||
const parsed = providerModelMutationSchema.parse({
|
||||
provider: "example",
|
||||
modelId: "media-model",
|
||||
apiFormat: "video",
|
||||
supportedEndpoints: ["video", "audio"],
|
||||
});
|
||||
|
||||
assert.equal(parsed.apiFormat, "video");
|
||||
assert.deepEqual(parsed.supportedEndpoints, ["videos", "audio-speech", "audio-transcriptions"]);
|
||||
});
|
||||
353
tests/unit/security-s1-s2-s4.test.ts
Normal file
353
tests/unit/security-s1-s2-s4.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Security compliance tickets S1, S2, S4 — unit tests.
|
||||
*
|
||||
* S1 — Login rate-limit key uses anti-spoofed peer IP (x-omniroute-trusted-peer-ip)
|
||||
* S2 — A2A agent-card topology sanitisation (no hardcoded localhost:20128)
|
||||
* S4 — 429 Retry-After header always present on lockout responses
|
||||
*/
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// ── S2: agent-card route tests (no heavy mocking needed) ──────────────
|
||||
|
||||
describe("S2 — agent-card topology sanitisation", () => {
|
||||
const BASE_URL_SAVED = process.env.OMNIROUTE_BASE_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OMNIROUTE_BASE_URL;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (BASE_URL_SAVED !== undefined) {
|
||||
process.env.OMNIROUTE_BASE_URL = BASE_URL_SAVED;
|
||||
} else {
|
||||
delete process.env.OMNIROUTE_BASE_URL;
|
||||
}
|
||||
});
|
||||
|
||||
it("agent-card.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
|
||||
const mod = await import("../../src/app/.well-known/agent-card.json/route.ts");
|
||||
const request = new Request("https://gateway.example.com/.well-known/agent-card.json") as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("https://gateway.example.com/.well-known/agent-card.json"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await mod.GET(request);
|
||||
assert.equal(res.status, 200);
|
||||
const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] };
|
||||
assert.ok(card.url, "card must have a url");
|
||||
assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
|
||||
if (card.supportedInterfaces && card.supportedInterfaces.length > 0) {
|
||||
assert.ok(
|
||||
card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"),
|
||||
`interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("agent-card.json uses OMNIROUTE_BASE_URL when set", async () => {
|
||||
process.env.OMNIROUTE_BASE_URL = "https://custom.example.com";
|
||||
const mod = await import("../../src/app/.well-known/agent-card.json/route.ts");
|
||||
const request = new Request("http://localhost:20128/.well-known/agent-card.json") as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("http://localhost:20128/.well-known/agent-card.json"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await mod.GET(request);
|
||||
assert.equal(res.status, 200);
|
||||
const card = (await res.json()) as { url?: string };
|
||||
assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`);
|
||||
});
|
||||
|
||||
it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
|
||||
const mod = await import("../../src/app/.well-known/agent.json/route.ts");
|
||||
const request = new Request("https://gateway.example.com/.well-known/agent.json") as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("https://gateway.example.com/.well-known/agent.json"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await mod.GET(request);
|
||||
assert.equal(res.status, 200);
|
||||
const card = (await res.json()) as { url?: string };
|
||||
assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Login guard module (loaded once for S4 tests) ─────────────────────
|
||||
const loginGuardMod = await import("../../src/server/auth/loginGuard");
|
||||
|
||||
// ── S4: login guard Retry-After tests ─────────────────────────────────
|
||||
|
||||
describe("S4 — 429 Retry-After header", () => {
|
||||
const {
|
||||
checkLoginGuard,
|
||||
recordLoginFailure,
|
||||
resetLoginGuardForTests,
|
||||
LOGIN_GUARD_TUNABLES,
|
||||
} = loginGuardMod;
|
||||
|
||||
beforeEach(() => {
|
||||
resetLoginGuardForTests();
|
||||
});
|
||||
|
||||
it("checkLoginGuard returns retryAfterSeconds when locked", () => {
|
||||
const ip = "10.0.0.99";
|
||||
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
|
||||
recordLoginFailure(ip, { enabled: true });
|
||||
}
|
||||
const decision = checkLoginGuard(ip, { enabled: true });
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.ok(typeof decision.retryAfterSeconds === "number" && decision.retryAfterSeconds > 0,
|
||||
`retryAfterSeconds should be > 0, got ${decision.retryAfterSeconds}`);
|
||||
});
|
||||
|
||||
it("recordLoginFailure returns retryAfterSeconds on threshold hit", () => {
|
||||
const ip = "10.0.0.100";
|
||||
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
|
||||
const dec = recordLoginFailure(ip, { enabled: true });
|
||||
if (i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1) {
|
||||
assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`);
|
||||
} else {
|
||||
assert.equal(dec.allowed, false, `attempt #${i + 1} (threshold) should be locked`);
|
||||
assert.ok(typeof dec.retryAfterSeconds === "number" && dec.retryAfterSeconds > 0,
|
||||
`retryAfterSeconds should be > 0 on threshold hit, got ${dec.retryAfterSeconds}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("both guard functions provide retryAfterSeconds for the response header", () => {
|
||||
const ip = "10.0.0.101";
|
||||
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
|
||||
recordLoginFailure(ip, { enabled: true });
|
||||
}
|
||||
const guardDec = checkLoginGuard(ip, { enabled: true });
|
||||
assert.equal(guardDec.allowed, false);
|
||||
const headerValue = String(guardDec.retryAfterSeconds || 60);
|
||||
assert.ok(/^\d+$/.test(headerValue), `Retry-After should be an integer string, got ${headerValue}`);
|
||||
assert.ok(Number.parseInt(headerValue, 10) > 0, "Retry-After should be positive");
|
||||
|
||||
resetLoginGuardForTests();
|
||||
const ip2 = "10.0.0.102";
|
||||
let failureDec: ReturnType<typeof recordLoginFailure> | undefined;
|
||||
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
|
||||
failureDec = recordLoginFailure(ip2, { enabled: true });
|
||||
}
|
||||
assert.equal(failureDec!.allowed, false);
|
||||
const headerValue2 = String(failureDec!.retryAfterSeconds || 60);
|
||||
assert.ok(/^\d+$/.test(headerValue2), `Retry-After should be an integer string, got ${headerValue2}`);
|
||||
assert.ok(Number.parseInt(headerValue2, 10) > 0, "Retry-After should be positive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── S1: login route uses trusted peer IP for rate-limit key ───────────
|
||||
// Integration test: sets up the real DB, management password, and settings,
|
||||
// then calls the login route POST function to verify the clientIp derivation.
|
||||
// The route uses: clientIp = request.headers.get("x-omniroute-trusted-peer-ip") || auditContext.ipAddress || null
|
||||
|
||||
describe("S1 — login rate-limit key uses anti-spoofed peer IP", () => {
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-security-s1-s2-s4-"));
|
||||
const JWT_SAVED = process.env.JWT_SECRET;
|
||||
const INITIAL_PASSWORD_SAVED = process.env.INITIAL_PASSWORD;
|
||||
|
||||
let loginRoute: typeof import("../../src/app/api/auth/login/route.ts");
|
||||
let loginGuardModRef: typeof import("../../src/server/auth/loginGuard");
|
||||
let settingsDb: typeof import("../../src/lib/db/settings.ts");
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset env
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-s1-s2-s4-tests";
|
||||
// Use a bcrypt hash of "test-password" as the initial password so the
|
||||
// login route already has a valid hash in the DB settings.
|
||||
process.env.INITIAL_PASSWORD = "test-password";
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
delete process.env.OMNIROUTE_BASE_URL;
|
||||
|
||||
// Create data dir
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
|
||||
// Reset DB and set up settings
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
await settingsDb.updateSettings({ bruteForceProtection: true });
|
||||
|
||||
// Import login guard and reset state
|
||||
loginGuardModRef = await import("../../src/server/auth/loginGuard");
|
||||
loginGuardModRef.resetLoginGuardForTests();
|
||||
|
||||
// Now import the login route
|
||||
loginRoute = await import("../../src/app/api/auth/login/route.ts");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (JWT_SAVED !== undefined) {
|
||||
process.env.JWT_SECRET = JWT_SAVED;
|
||||
} else {
|
||||
delete process.env.JWT_SECRET;
|
||||
}
|
||||
// Restore INITIAL_PASSWORD
|
||||
if (INITIAL_PASSWORD_SAVED !== undefined) {
|
||||
process.env.INITIAL_PASSWORD = INITIAL_PASSWORD_SAVED;
|
||||
} else {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses x-omniroute-trusted-peer-ip for rate-limit key when header is present", async () => {
|
||||
// The login route derives clientIp from the trusted peer IP header.
|
||||
// We make multiple requests with the same trusted peer IP but different
|
||||
// forged XFF headers to verify they share the same rate-limit bucket.
|
||||
//
|
||||
// The route only trusts the header when OMNIROUTE_PEER_STAMP_TOKEN is set.
|
||||
// Without the token, spoofed headers are rejected (tested separately below).
|
||||
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "test-stamp-token";
|
||||
|
||||
const TRUSTED_IP = "203.0.113.42";
|
||||
const FORGED_XFF = "192.168.1.1, 10.0.0.1";
|
||||
|
||||
// Make enough requests to trigger the rate limit
|
||||
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
|
||||
const request = new Request("http://localhost:20128/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-trusted-peer-ip": TRUSTED_IP,
|
||||
"x-forwarded-for": i === 0 ? FORGED_XFF : `10.0.0.${i}, 172.16.0.1`,
|
||||
},
|
||||
body: JSON.stringify({ password: "wrong-password" }),
|
||||
}) as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("http://localhost:20128/api/auth/login"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// The email is not checked in the login route — only password matters
|
||||
// Let's also try the correct password to make sure login works
|
||||
const res = await loginRoute.POST(request);
|
||||
if (res.status === 429) {
|
||||
// Locked out — rate-limit key is tied to the trusted peer IP, not XFF
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
|
||||
assert.ok(/^\d+$/.test(retryAfter!), `Retry-After should be a positive integer, got ${retryAfter}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail("Expected at least one 429 response after threshold failed attempts with the same trusted peer IP");
|
||||
});
|
||||
|
||||
it("ignores spoofed x-omniroute-trusted-peer-ip when OMNIROUTE_PEER_STAMP_TOKEN is not set", async () => {
|
||||
loginGuardModRef.resetLoginGuardForTests();
|
||||
|
||||
// OMNIROUTE_PEER_STAMP_TOKEN is already deleted in beforeEach.
|
||||
// The route should NOT trust the spoofed header and fall back to
|
||||
// auditContext.ipAddress (derived from X-Forwarded-For).
|
||||
//
|
||||
// TDD: each iteration uses a DIFFERENT spoofed IP. With the bug
|
||||
// (unconditional trust), each request goes to a different rate-limit
|
||||
// bucket — no bucket reaches the threshold → test FAILS (RED).
|
||||
// With the fix (gate on OMNIROUTE_PEER_STAMP_TOKEN), all requests
|
||||
// share the REAL_IP bucket → threshold hit → test PASSES (GREEN).
|
||||
|
||||
const REAL_IP = "10.0.0.200";
|
||||
|
||||
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
|
||||
const SPOOFED_IP = `203.0.113.${i}`;
|
||||
const request = new Request("http://localhost:20128/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-trusted-peer-ip": SPOOFED_IP,
|
||||
"x-forwarded-for": REAL_IP,
|
||||
},
|
||||
body: JSON.stringify({ password: "wrong-password" }),
|
||||
}) as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("http://localhost:20128/api/auth/login"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await loginRoute.POST(request);
|
||||
if (res.status === 429) {
|
||||
// Locked out — rate-limit key is tied to REAL_IP (XFF), not the spoofed header
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail("Expected 429 after threshold failures — spoofed header should not bypass rate-limit");
|
||||
});
|
||||
|
||||
it("falls back to auditContext.ipAddress when trusted peer IP header is absent", async () => {
|
||||
loginGuardModRef.resetLoginGuardForTests();
|
||||
|
||||
// Without the trusted peer IP header, the rate-limit key falls back to
|
||||
// auditContext.ipAddress which reads from X-Forwarded-For / X-Real-IP.
|
||||
// We set XFF to a specific IP and verify that requests with that IP get
|
||||
// rate-limited, while requests with a different IP do not.
|
||||
|
||||
const REQUEST_IP = "10.0.0.99";
|
||||
|
||||
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
|
||||
const request = new Request("http://localhost:20128/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": REQUEST_IP,
|
||||
},
|
||||
body: JSON.stringify({ password: "wrong-password" }),
|
||||
}) as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("http://localhost:20128/api/auth/login"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await loginRoute.POST(request);
|
||||
if (res.status === 429) {
|
||||
// Locked out — rate-limit key is tied to the XFF-derived IP
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
|
||||
assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail("Expected 429 after threshold failures from the same IP");
|
||||
});
|
||||
|
||||
it("S4 — 429 response includes Retry-After header in login route", async () => {
|
||||
loginGuardModRef.resetLoginGuardForTests();
|
||||
|
||||
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
|
||||
const request = new Request("http://localhost:20128/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-trusted-peer-ip": "203.0.113.99",
|
||||
},
|
||||
body: JSON.stringify({ password: "wrong-password" }),
|
||||
}) as unknown as NextRequest;
|
||||
Object.defineProperty(request, "nextUrl", {
|
||||
value: new URL("http://localhost:20128/api/auth/login"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const res = await loginRoute.POST(request);
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
|
||||
assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail("Expected at least one 429 response after threshold failed attempts");
|
||||
});
|
||||
});
|
||||
20
tests/unit/static-model-operation-endpoints.test.ts
Normal file
20
tests/unit/static-model-operation-endpoints.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts";
|
||||
|
||||
test("speech-only static models advertise the speech operation", () => {
|
||||
const models = getStaticModelsForProvider("elevenlabs") || [];
|
||||
|
||||
assert.ok(models.length > 0);
|
||||
assert.ok(models.every((model) => model.supportedEndpoints?.includes("audio-speech")));
|
||||
assert.ok(models.every((model) => !model.supportedEndpoints?.includes("audio")));
|
||||
});
|
||||
|
||||
test("transcription-only static models advertise the transcription operation", () => {
|
||||
const models = getStaticModelsForProvider("gladia") || [];
|
||||
|
||||
assert.ok(models.length > 0);
|
||||
assert.ok(models.every((model) => model.supportedEndpoints?.includes("audio-transcriptions")));
|
||||
assert.ok(models.every((model) => !model.supportedEndpoints?.includes("audio")));
|
||||
});
|
||||
@@ -52,7 +52,7 @@ function writeOauthEnvExample(rootDir: string) {
|
||||
);
|
||||
}
|
||||
|
||||
test("syncEnv creates .env from .env.example and generates install-time secrets", () => {
|
||||
test("syncEnv creates .env from .env.example and leaves runtime-owned secrets blank", () => {
|
||||
const rootDir = createTempRoot();
|
||||
|
||||
// Temporarily override DATA_DIR so the encrypted-credentials guard doesn't
|
||||
@@ -66,8 +66,13 @@ test("syncEnv creates .env from .env.example and generates install-time secrets"
|
||||
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
|
||||
|
||||
assert.deepEqual(result, { created: true, added: 7 });
|
||||
assert.match(envContent, /^JWT_SECRET=.{32,}$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
|
||||
// The three secrets the server provisions itself stay blank here. Filling
|
||||
// them in the package directory hides ensureSecrets() (instrumentation-node),
|
||||
// which restores them from the durable store or generates and persists them
|
||||
// there — so a pre-filled value is silently replaced by a new one on every
|
||||
// reinstall. STORAGE_ENCRYPTION_KEY was pulled out for that reason (#1622).
|
||||
assert.match(envContent, /^JWT_SECRET=$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=$/m);
|
||||
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
|
||||
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
|
||||
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m);
|
||||
@@ -103,7 +108,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => {
|
||||
assert.deepEqual(result, { created: false, added: 5 });
|
||||
assert.match(envContent, /^JWT_SECRET=my-custom-secret-that-should-stay$/m);
|
||||
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=custom-claude$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=$/m);
|
||||
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
|
||||
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
|
||||
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { checkKimiWebConnectionIfNeeded } from "../../src/lib/tokenHealthCheckKimi.ts";
|
||||
import {
|
||||
checkKimiWebConnectionIfNeeded,
|
||||
defaultKimiRefreshJitterSec,
|
||||
} from "../../src/lib/tokenHealthCheckKimi.ts";
|
||||
|
||||
describe("Kimi Background Health Sweep", () => {
|
||||
it("skips non-kimi-web connections", async () => {
|
||||
@@ -16,9 +19,12 @@ describe("Kimi Background Health Sweep", () => {
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
it("triggers refresh when Kimi token is within jittered expiration window", async () => {
|
||||
it("triggers refresh when the token is inside the refresh window", async () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
// Token expiring in 90 seconds (within 60-240s window)
|
||||
// Token expiring in 90 seconds. The window is decided by the caller here, not
|
||||
// drawn: with the default spread of [60, 240) a 90 s token is refreshed only
|
||||
// when the draw lands >= 90, which is 150 of 180 values — so this assertion
|
||||
// used to fail 1 run in 6, and did so on the Node 26 nightly (#11361).
|
||||
const token =
|
||||
"eyJhbGciOiJIUzUxMiJ9." +
|
||||
Buffer.from(JSON.stringify({ exp: nowSec + 90, iat: nowSec })).toString("base64url") +
|
||||
@@ -38,6 +44,7 @@ describe("Kimi Background Health Sweep", () => {
|
||||
logError: () => {},
|
||||
getConnectionLogLabel: () => "kimi-web-1",
|
||||
logPrefix: "[Test]",
|
||||
jitterSecFn: () => 120,
|
||||
exchangeFn: async () => {
|
||||
calledRefresh = true;
|
||||
return {
|
||||
@@ -53,4 +60,51 @@ describe("Kimi Background Health Sweep", () => {
|
||||
assert.equal(handled, true);
|
||||
assert.equal(calledRefresh, true);
|
||||
});
|
||||
|
||||
it("leaves a token outside the window alone", async () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const token =
|
||||
"eyJhbGciOiJIUzUxMiJ9." +
|
||||
Buffer.from(JSON.stringify({ exp: nowSec + 900, iat: nowSec })).toString("base64url") +
|
||||
".sig";
|
||||
|
||||
let calledRefresh = false;
|
||||
const handled = await checkKimiWebConnectionIfNeeded({
|
||||
conn: {
|
||||
id: "kimi-conn-2",
|
||||
provider: "kimi-web",
|
||||
apiKey: token,
|
||||
refreshToken: "refresh_123",
|
||||
},
|
||||
now: new Date().toISOString(),
|
||||
log: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
getConnectionLogLabel: () => "kimi-web-2",
|
||||
logPrefix: "[Test]",
|
||||
jitterSecFn: () => 240,
|
||||
exchangeFn: async () => {
|
||||
calledRefresh = true;
|
||||
return {
|
||||
success: true,
|
||||
accessToken: "new_token",
|
||||
refreshToken: "new_refresh",
|
||||
expiresAtSec: nowSec + 900,
|
||||
};
|
||||
},
|
||||
persistFn: async () => {},
|
||||
});
|
||||
|
||||
// Handled (it is a kimi-web connection) but not refreshed.
|
||||
assert.equal(handled, true);
|
||||
assert.equal(calledRefresh, false);
|
||||
});
|
||||
|
||||
it("the default spread stays inside [60, 240)", () => {
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
const jitter = defaultKimiRefreshJitterSec();
|
||||
assert.ok(Number.isInteger(jitter), `jitter must be whole seconds, got ${jitter}`);
|
||||
assert.ok(jitter >= 60 && jitter < 240, `jitter out of range: ${jitter}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user