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:
Dizzle
2026-08-25 01:04:11 +02:00
committed by GitHub
parent 66ecc09050
commit 6e8fc94732
33 changed files with 914 additions and 114 deletions

View File

@@ -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>

View File

@@ -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",
},

View File

@@ -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,

View File

@@ -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) },
}
);
}

View File

@@ -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 }

View File

@@ -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);

View File

@@ -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

View File

@@ -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");

View File

@@ -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"],
});
}

View File

@@ -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
View 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;
}

View File

@@ -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,
];

View File

@@ -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

View File

@@ -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": {

View 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" };
}

View File

@@ -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,

View File

@@ -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).