merge: bring feat/6636-codex-session-import to release/v3.8.49 tip

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 12:04:09 -03:00
106 changed files with 2913 additions and 379 deletions

View File

@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import {
OutboundUrlGuardError,
getProviderValidationGuard,
parseAndValidateNonMetadataUrl,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
function guardProviderNodeBaseUrl(baseUrl: string): void {
const guard = getProviderValidationGuard();

View File

@@ -8,7 +8,7 @@ import {
getSafeOutboundFetchErrorStatus,
safeOutboundFetch,
} from "@/shared/network/safeOutboundFetch";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

View File

@@ -1,5 +1,5 @@
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
import {
getAntigravityModelsDiscoveryUrls,
getAntigravityFetchAvailableModelsUrls,

View File

@@ -25,7 +25,7 @@ import {
import {
getProviderOutboundGuard,
getProviderValidationGuard,
} from "@/shared/network/outboundUrlGuard";
} from "@/shared/network/outboundUrlGuardPolicy";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig";
@@ -41,6 +41,10 @@ import {
discoverBedrockNativeModels,
isBedrockNativeApiError,
} from "@omniroute/open-sse/services/bedrock.ts";
import {
discoverNotionWebModels,
NOTION_WEB_FALLBACK_MODELS,
} from "@omniroute/open-sse/services/notionWebModels.ts";
import {
AZURE_AI_DEFAULT_BASE_URL,
buildAzureAiModelsUrl,
@@ -527,6 +531,64 @@ export async function GET(
if (localCatalog) return localCatalog;
}
// #7600 follow-up: notion-web live catalog via cookie-auth getAvailableModels.
// Needs spaceId (from cookie or getSpaces); falls back to seeded local catalog.
if (provider === "notion-web") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
const token = apiKey || accessToken;
if (!token) {
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "No token configured — using cached catalog",
localWarning: "No token configured — using local catalog",
});
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: NOTION_WEB_FALLBACK_MODELS,
source: "local_catalog",
intentional: true,
warning: "No token_v2 cookie — using seed Notion AI model list",
});
}
try {
const discovery = await discoverNotionWebModels({
token,
fetchImpl: (url, init) =>
safeOutboundFetch(url, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
...init,
}),
});
return buildApiDiscoveryResponse(discovery.models);
} catch (error) {
console.log("Error fetching models from notion-web", {
error: error instanceof Error ? error.message : String(error),
});
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "Notion getAvailableModels failed — using cached catalog",
localWarning: "Notion getAvailableModels failed — using seed catalog",
});
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: NOTION_WEB_FALLBACK_MODELS,
source: "local_catalog",
intentional: true,
warning: "API unavailable — using seed Notion AI model list",
});
}
}
if (provider === "bedrock") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;

View File

@@ -0,0 +1,57 @@
/**
* #7638: Mistral's quota-exhausted response is `401 {"detail":"Unauthorized"}` — byte-identical
* to a genuinely revoked key. Unlike other providers, a bare Mistral 401 with no auth-specific
* signal in the body cannot be trusted as a hard auth failure; the generic 401/403 branch in
* classifyFailure() delegates here so it can return an ambiguous diagnosis instead of asserting
* "Invalid API key" outright.
* A message that DOES carry an explicit auth signal (e.g. "Invalid API key") still falls
* through to the normal `upstream_auth_error` result — only the contentless case is ambiguous.
*/
export interface AuthOr401Diagnosis {
type: string;
source: string;
message: string | null;
code: string | null;
}
/** Param type for classifyFailure() in route.ts — extracted here to keep that frozen file's LOC flat. */
export interface ClassifyFailureArgs {
error: string;
statusCode?: number | null;
refreshFailed?: boolean;
unsupported?: boolean;
provider?: string;
}
function isMistralAmbiguous401(provider: string | undefined, normalized: string): boolean {
if (provider !== "mistral") return false;
const hasAuthSignal =
normalized.includes("invalid api key") ||
normalized.includes("token invalid") ||
normalized.includes("revoked") ||
normalized.includes("access denied");
return !hasAuthSignal;
}
/** Decides the diagnosis for a 401/403 status: ambiguous (Mistral-only) or the generic auth error. */
export function classifyAmbiguousOrAuthError(
provider: string | undefined,
normalized: string,
message: string,
numericStatus: number
): AuthOr401Diagnosis {
if (numericStatus === 401 && isMistralAmbiguous401(provider, normalized)) {
return {
type: "upstream_ambiguous_auth_or_quota",
source: "upstream",
message: message || null,
code: String(numericStatus),
};
}
return {
type: "upstream_auth_error",
source: "upstream",
message: message || null,
code: String(numericStatus),
};
}

View File

@@ -0,0 +1,129 @@
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
// OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a
// provider entry doesn't grow the frozen route.ts file past its check-file-size
// cap — this module carries no logic of its own beyond the GitLab URL builder.
export const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
refreshable: true,
},
codex: {
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
// Hitting the actual endpoint with a minimal invalid body returns 400 when
// auth is accepted (the body is the reason for the failure) and 401/403 when
// the token is bad. That is a real auth signal — checkExpiry alone could not
// distinguish a revoked-but-not-yet-expired token from a working one.
url: "https://chatgpt.com/backend-api/codex/responses",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
"Content-Type": "application/json",
originator: "codex-cli",
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
},
// Minimal invalid body — triggers a fast 400 without consuming quota.
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
// so a bad token looks the same as a good one. "gpt-5.5" is served for
// ChatGPT sessions; `input: []` still yields the intended 400.
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
acceptStatuses: [400],
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
xai: {
url: "https://api.x.ai/v1/chat/completions",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "grok-4.3",
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
stream: false,
reasoning: { effort: "high" },
}),
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
},
"gitlab-duo": {
getUrl: (connection: any) =>
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
.directAccessUrl,
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
qwen: {
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
// Use checkExpiry instead — actual connectivity is validated via real requests.
checkExpiry: true,
refreshable: true,
},
cursor: {
checkExpiry: true,
},
"kimi-coding": {
checkExpiry: true,
refreshable: true,
},
kilocode: {
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
// Validate using token presence/expiry as a lightweight auth check.
checkExpiry: true,
},
cline: {
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
// via real requests.
checkExpiry: true,
refreshable: true,
},
kiro: {
checkExpiry: true,
refreshable: true,
},
"amazon-q": {
checkExpiry: true,
refreshable: true,
},
"codebuddy-cn": {
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
// validate auth via token presence + refresh path. Live connectivity is
// verified through real /v2/chat/completions traffic.
checkExpiry: true,
refreshable: true,
},
"grok-cli": {
// #7610: was entirely absent from OAUTH_TEST_CONFIG, so "Test Connection"
// always fell through to the generic "Provider test not supported" branch
// below. Grok Build's cli-chat-proxy endpoint doesn't expose a lightweight
// userinfo probe, and it enforces cli-specific headers (see
// GrokCliExecutor.buildHeaders) that this shared prober doesn't send — so
// mirror qwen/cline/kilocode's checkExpiry pattern instead of a live probe.
// Real connectivity is still validated on every chat/completions request.
checkExpiry: true,
refreshable: true,
},
};

View File

@@ -17,133 +17,16 @@ import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer
import { saveCallLog } from "@/lib/usageDb";
import { logProxyEvent } from "@/lib/proxyLogger";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import {
buildGitLabOAuthEndpoints,
isGitLabDirectAccessDisabled,
resolveGitLabOAuthBaseUrl,
} from "@/lib/oauth/gitlab";
import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { OAUTH_TEST_CONFIG } from "./oauthTestConfig";
// Bound the OAuth probe so a hung upstream can't block the connection-test queue
// forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey.
const OAUTH_TEST_TIMEOUT_MS = 30_000;
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
refreshable: true,
},
codex: {
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
// Hitting the actual endpoint with a minimal invalid body returns 400 when
// auth is accepted (the body is the reason for the failure) and 401/403 when
// the token is bad. That is a real auth signal — checkExpiry alone could not
// distinguish a revoked-but-not-yet-expired token from a working one.
url: "https://chatgpt.com/backend-api/codex/responses",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
"Content-Type": "application/json",
originator: "codex-cli",
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
},
// Minimal invalid body — triggers a fast 400 without consuming quota.
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
// so a bad token looks the same as a good one. "gpt-5.5" is served for
// ChatGPT sessions; `input: []` still yields the intended 400.
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
acceptStatuses: [400],
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
xai: {
url: "https://api.x.ai/v1/chat/completions",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "grok-4.3",
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
stream: false,
reasoning: { effort: "high" },
}),
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
},
"gitlab-duo": {
getUrl: (connection: any) =>
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
.directAccessUrl,
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
qwen: {
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
// Use checkExpiry instead — actual connectivity is validated via real requests.
checkExpiry: true,
refreshable: true,
},
cursor: {
checkExpiry: true,
},
"kimi-coding": {
checkExpiry: true,
refreshable: true,
},
kilocode: {
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
// Validate using token presence/expiry as a lightweight auth check.
checkExpiry: true,
},
cline: {
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
// via real requests.
checkExpiry: true,
refreshable: true,
},
kiro: {
checkExpiry: true,
refreshable: true,
},
"amazon-q": {
checkExpiry: true,
refreshable: true,
},
"codebuddy-cn": {
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
// validate auth via token presence + refresh path. Live connectivity is
// verified through real /v2/chat/completions traffic.
checkExpiry: true,
refreshable: true,
},
};
import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap";
/** POST body is optional; when present, only known fields are validated. */
@@ -188,12 +71,8 @@ export function classifyFailure({
statusCode = null,
refreshFailed = false,
unsupported = false,
}: {
error: string;
statusCode?: number | null;
refreshFailed?: boolean;
unsupported?: boolean;
}) {
provider,
}: ClassifyFailureArgs) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
@@ -214,7 +93,7 @@ export function classifyFailure({
}
if (numericStatus === 401 || numericStatus === 403) {
return makeDiagnosis("upstream_auth_error", "upstream", message, String(numericStatus));
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
}
if (numericStatus === 429) {
@@ -722,14 +601,14 @@ async function testApiKeyConnection(connection: any) {
return {
valid: false,
error,
diagnosis: classifyFailure({ error, unsupported: true }),
diagnosis: classifyFailure({ error, unsupported: true, provider: connection.provider }),
};
}
const error = result.valid ? null : result.error || "Invalid API key";
const diagnosis = result.valid
? makeDiagnosis("ok", "upstream", null, null)
: classifyFailure({ error });
: classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider });
return {
valid: !!result.valid,
@@ -813,7 +692,7 @@ export async function testSingleConnection(connectionId: string, validationModel
result.diagnosis ||
(result.valid
? makeDiagnosis("ok", "local", null, null)
: classifyFailure({ error: result.error, statusCode: result.statusCode }));
: classifyFailure({ error: result.error, statusCode: result.statusCode, provider }));
const updateData: Record<string, any> = {
testStatus: result.valid ? "active" : "error",

View File

@@ -11,7 +11,8 @@ import { z } from "zod";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isPrivateHost, arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuard";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
import {
testProxiesAgainstTarget,
getProxyCandidates,

View File

@@ -13,7 +13,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { encryptMetadata } from "@/lib/webhookDispatcher";
import { isEncryptionEnabled } from "@/lib/db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
const WEBHOOK_EVENT_VALUES = [

View File

@@ -13,11 +13,8 @@ import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { insertDelivery } from "@/lib/db/webhookDeliveries";
import { recordWebhookDelivery } from "@/lib/localDb";
import {
parseAndValidateWebhookUrl,
isPrivateHost,
OutboundUrlGuardError,
} from "@/shared/network/outboundUrlGuard";
import { isPrivateHost, OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import crypto from "crypto";
const MAX_RESPONSE_BODY = 2048;

View File

@@ -12,7 +12,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { encryptMetadata } from "@/lib/webhookDispatcher";
import { isEncryptionEnabled } from "@/lib/db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;

View File

@@ -6,10 +6,8 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
parseAndValidateWebhookUrl,
OutboundUrlGuardError,
} from "@/shared/network/outboundUrlGuard";
import { OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
const validateUrlSchema = z.object({

View File

@@ -379,6 +379,22 @@ export async function registerNodejs(): Promise<void> {
console.warn("[STARTUP] Could not restore runtime settings:", msg);
}
// Proactively start the credential-health sweep at boot so stale web-session
// connections (cookies that expired overnight) get re-probed and recovered on
// startup — instead of staying red until the first real request lazily imports
// the on-demand credentialGate. Idempotent; self-disables via
// OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK and its cadence is tunable via
// CREDENTIAL_HEALTH_CHECK_INTERVAL. NOTE: this MUST live here (the real Next.js
// instrumentation startup), NOT in the unused src/server-init.ts.
try {
const { initCredentialHealthCheck } = await import("@/lib/credentialHealth/scheduler");
initCredentialHealthCheck();
console.log("[STARTUP] Credential health scheduler started");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not start credential health scheduler:", msg);
}
try {
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
initAuditLog();

View File

@@ -107,11 +107,36 @@ export function humanizeCursorModelId(id: string): string {
}
export function parseCursorAgentModels(text: string): string[] {
const match = text.match(/Available models:\s*([^\n]+)/);
if (!match) return [];
// Older Cursor Agent releases only exposed the catalog as part of the
// invalid-model error produced by `--model --help`.
const legacyMatch = text.match(/Available models:\s*([^\n]+)/);
if (legacyMatch) {
return deduplicateCursorModelIds(legacyMatch[1].split(","));
}
// Current releases expose an official `models` command whose output is:
//
// Available models
//
// auto - Auto (default)
// gpt-5.3-codex - Codex 5.3
const headerMatch = /(?:^|\n)Available models\s*(?:\n|$)/.exec(text);
if (!headerMatch) return [];
const lines = text.slice(headerMatch.index + headerMatch[0].length).split("\n");
const ids: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("Tip:")) break;
const separator = trimmed.indexOf(" - ");
if (separator > 0) ids.push(trimmed.slice(0, separator));
}
return deduplicateCursorModelIds(ids);
}
function deduplicateCursorModelIds(ids: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of match[1].split(",")) {
for (const raw of ids) {
const id = raw.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
@@ -138,12 +163,13 @@ export async function fetchCursorAgentModels(
);
}
// cursor-agent prints "Available models: ..." to stderr and exits non-zero
// when given an unknown model id, so we intentionally pass `--help` as the
// model value to coerce it into listing.
const startedAt = Date.now();
let result: { stdout: string; stderr: string };
try {
result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs);
// Modern Cursor Agent releases provide a dedicated catalog flag. Prefer the
// flag over the equivalent `models` subcommand because older releases can
// interpret an unknown positional subcommand as an agent prompt.
result = await runCursorAgent(binary, ["--list-models"], timeoutMs);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e?.code === "ENOENT") {
@@ -151,11 +177,24 @@ export async function fetchCursorAgentModels(
}
throw err;
}
const combined = `${result.stdout}\n${result.stderr}`;
let combined = `${result.stdout}\n${result.stderr}`;
let ids = parseCursorAgentModels(combined);
// Backward compatibility for releases from before the dedicated catalog interface.
if (ids.length === 0 && !/Authentication required|Not logged in/i.test(combined)) {
const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt);
if (remainingTimeoutMs > 0) {
result = await runCursorAgent(binary, ["--model", "--help"], remainingTimeoutMs);
combined = `${result.stdout}\n${result.stderr}`;
ids = parseCursorAgentModels(combined);
}
}
const ids = parseCursorAgentModels(combined);
if (ids.length === 0) {
throw new Error("cursor-agent did not return an 'Available models:' line");
if (/Authentication required|Not logged in/i.test(combined)) {
throw new Error("cursor-agent is not authenticated; run 'agent login' on the OmniRoute host");
}
throw new Error("cursor-agent did not return a model catalog from 'agent --list-models'");
}
return ids.map((id) => ({

View File

@@ -1,6 +1,6 @@
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,

View File

@@ -11,7 +11,7 @@ import {
WEB_COOKIE_PROVIDERS,
} from "@/shared/constants/providers";
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel";
import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal";
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
@@ -31,7 +31,12 @@ import {
resolveBaseUrl,
} from "./validation/urlHelpers";
import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders } from "./validation/headers";
import { validationRead, validationWrite, toValidationErrorResult } from "./validation/transport";
import {
validationRead,
validationWrite,
toValidationErrorResult,
toWebCookieValidationErrorResult,
} from "./validation/transport";
import {
validateDeepSeekWebProvider,
validateQwenWebProvider,
@@ -137,7 +142,7 @@ export async function validateWebCookieProvider({
if (!entry) {
// Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g.
// lmarena, gemini-business, poe-web, venice-web, v0-vercel-web) only expose a
// gemini-business, poe-web, venice-web, v0-vercel-web) only expose a
// marketing website URL, not a real API host. Probing `${website}/models`
// does not reliably signal session validity for these —
// live verification showed most return redirects or SPA 200s regardless of
@@ -183,7 +188,7 @@ export async function validateWebCookieProvider({
// for web-cookie auth, so a non-auth status is treated as a valid session.
return { valid: true, error: null, unsupported: false };
} catch (error: unknown) {
return toValidationErrorResult(error);
return toWebCookieValidationErrorResult(provider, error);
}
}

View File

@@ -2,7 +2,7 @@
// from validation.ts (god-file decomposition). Pure header construction except directHttpsRequest,
// which delegates to safeOutboundFetch with bypassProxyPatch. Behavior is byte-identical.
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
// Standardized desktop Chrome UA for web-cookie/no-auth session probes (minimizes anti-bot detection).
export const STANDARD_USER_AGENT =

View File

@@ -2,7 +2,7 @@
// …). Extracted from validation.ts (god-file decomposition) — top-level functions/data with no
// dispatcher-state captures; behavior is byte-identical to the original inline defs.
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { withCustomUserAgent } from "./headers";
import { toValidationErrorResult, validationWrite } from "./transport";

View File

@@ -7,7 +7,8 @@ import {
getSafeOutboundFetchErrorStatus,
safeOutboundFetch,
} from "@/shared/network/safeOutboundFetch";
import { getProviderValidationGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
/**
@@ -92,6 +93,35 @@ export function isSecurityBlockError(error: unknown): boolean {
return false;
}
// #7542 — web-cookie providers whose registry `baseUrl` is a POST-only streaming/completion
// endpoint (no real `/models` listing API), so the generic `/models` probe in
// validateWebCookieProvider() gets a redirect instead of a definitive 200/401/403. A blocked
// redirect there is not a session-expiry signal — the endpoint just isn't shaped for the probe
// — so it should degrade to "unsupported" the same way the discovery path already does for
// REDIRECT_BLOCKED (#6267's buildDiscoveryErrorFallbackResponse).
//
// Scoped to `lmarena` only (root-caused and regression-tested for #7542): the other web-cookie
// providers sharing a POST-only baseUrl shape (doubao-web, huggingchat, yuanbao-web,
// zenmux-free, zai-web) have not been individually verified to actually redirect on this probe
// rather than 404/405 — do not add them here without a proven repro per provider (see
// #7542 plan-file, "Risks").
const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]);
export function toWebCookieValidationErrorResult(provider: string, error: unknown) {
if (
error instanceof SafeOutboundFetchError &&
error.code === "REDIRECT_BLOCKED" &&
WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider)
) {
return {
valid: false,
error: "Provider validation not supported",
unsupported: true as const,
};
}
return toValidationErrorResult(error);
}
export function toValidationErrorResult(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "Validation failed");
const statusCode = getSafeOutboundFetchErrorStatus(error);

View File

@@ -6,7 +6,7 @@
import crypto from "crypto";
import { encrypt, decrypt } from "./db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import type { WebhookEvent } from "./webhooks/eventDescriptions";
export type { WebhookEvent };

View File

@@ -32,6 +32,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318)
"/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318)
"/api/cli-tools/grok-build-settings", // GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to locate + healthcheck the `grok` binary — same transitive-spawn surface that classified /api/skills/collect/ (Hard Rules #15 + #17). Writing ~/.grok/config.toml is inherently a local-machine operation, so loopback-only costs no real capability.
"/api/cli-tools/forge-settings", // spawns via getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263)
"/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263)
"/api/services/", // T-10: embedded service lifecycle (spawn child processes)
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass

View File

@@ -372,7 +372,8 @@ export const WEB_COOKIE_PROVIDERS = {
riskNoticeVariant: "webCookie",
authHint:
"Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). " +
"Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them.",
"Include `; space_id=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " +
"Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.",
},
};

View File

@@ -1,16 +1,7 @@
import { isIP } from "node:net";
import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL";
export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint";
export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
// #5066: scoped to provider validation/use. Allows local/private provider endpoints
// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while
// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators
// who only use public providers can disable it to restore strict SSRF blocking.
export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS";
// "block-metadata": allow private/LAN hosts but still reject cloud-metadata / link-local
// endpoints (the SSRF→IAM-credential pivot). Used by the provider-validation path under the
@@ -175,102 +166,11 @@ export function parseAndValidateNonMetadataUrl(input: string | URL) {
return url;
}
/**
* Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
* so the private-host block is gated behind the same explicit opt-in used for private
* provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and
* embedded-credential checks in {@link parseOutboundUrl} remain unconditional. (#3269)
*/
export function parseAndValidateWebhookUrl(input: string | URL) {
const url = parseOutboundUrl(input);
// Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them
// even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269)
if (isCloudMetadataHost(url.hostname)) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
return url;
}
function isTrueValue(raw: unknown): boolean {
if (typeof raw !== "string") return false;
return TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
}
export function arePrivateProviderUrlsAllowed() {
// 1) DB override takes precedence — it represents an explicit user toggle in
// the dashboard ("Allow Private Provider URLs"). This is critical for the
// Electron build (#2575) where the server is spawned with the env value
// captured at boot, so subsequent UI toggles only land in the DB and the
// env-first ordering would otherwise mask them.
try {
const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV);
if (isTrueValue(dbValue)) return true;
} catch {
// DB not initialized yet — fall through to env-only check.
}
// 2) Explicit env opt-in (for headless/Docker users who set it before boot).
if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true;
// 3) Legacy escape hatch — disabling the outbound guard implies allowing
// private URLs.
const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
if (
typeof legacyValue === "string" &&
["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase())
) {
return true;
}
return false;
}
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
}
/**
* #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON
* (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the
* box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to
* restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless.
*/
export function areLocalProviderUrlsAllowed(): boolean {
try {
const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV);
if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue);
} catch {
// DB not initialized yet — fall through to env / default.
}
const envValue = process.env[LOCAL_PROVIDER_URLS_ENV];
if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue);
// Default ON.
return true;
}
/**
* Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence:
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
* 3. otherwise → "public-only" (strict).
*/
export function getProviderValidationGuard(): OutboundUrlGuardMode {
if (arePrivateProviderUrlsAllowed()) return "none";
if (areLocalProviderUrlsAllowed()) return "block-metadata";
return "public-only";
}
// NOTE (#7682): `arePrivateProviderUrlsAllowed`, `areLocalProviderUrlsAllowed`,
// `getProviderOutboundGuard`, `getProviderValidationGuard`, and `parseAndValidateWebhookUrl`
// live in the sibling `./outboundUrlGuardPolicy.ts` module, NOT here. Those helpers need
// `@/shared/utils/featureFlags` (which transitively pulls in the DB layer), and this file is
// loaded by the packaged CLI (`omniroute setup-opencode` → cli-helper/config-generator/
// opencode.ts) where no `tsconfig.json` is present to resolve the `@/*` path alias. Keeping
// this module free of ANY `@/`-aliased import is what makes it safe to load from the CLI.
// Do not add a `@/`-aliased import here — see docs/security/… (packaging) and #7682.

View File

@@ -0,0 +1,123 @@
import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
import {
OutboundUrlGuardError,
PROVIDER_URL_BLOCKED_MESSAGE,
isCloudMetadataHost,
isPrivateHost,
parseOutboundUrl,
type OutboundUrlGuardMode,
} from "./outboundUrlGuard";
// #7682: this module is the DB/feature-flag-backed half of the outbound URL guard, split out
// of `./outboundUrlGuard.ts` so the CLI (`omniroute setup-opencode`, loaded via tsx with no
// tsconfig.json in a global npm install) never has to resolve the `@/` alias. Only Next.js /
// webpack-bundled server code (never the CLI) should import from here.
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
// #5066: scoped to provider validation/use. Allows local/private provider endpoints
// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while
// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators
// who only use public providers can disable it to restore strict SSRF blocking.
export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS";
function isTrueValue(raw: unknown): boolean {
if (typeof raw !== "string") return false;
return TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
}
export function arePrivateProviderUrlsAllowed() {
// 1) DB override takes precedence — it represents an explicit user toggle in
// the dashboard ("Allow Private Provider URLs"). This is critical for the
// Electron build (#2575) where the server is spawned with the env value
// captured at boot, so subsequent UI toggles only land in the DB and the
// env-first ordering would otherwise mask them.
try {
const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV);
if (isTrueValue(dbValue)) return true;
} catch {
// DB not initialized yet — fall through to env-only check.
}
// 2) Explicit env opt-in (for headless/Docker users who set it before boot).
if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true;
// 3) Legacy escape hatch — disabling the outbound guard implies allowing
// private URLs.
const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
if (
typeof legacyValue === "string" &&
["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase())
) {
return true;
}
return false;
}
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
}
/**
* #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON
* (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the
* box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to
* restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless.
*/
export function areLocalProviderUrlsAllowed(): boolean {
try {
const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV);
if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue);
} catch {
// DB not initialized yet — fall through to env / default.
}
const envValue = process.env[LOCAL_PROVIDER_URLS_ENV];
if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue);
// Default ON.
return true;
}
/**
* Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence:
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
* 3. otherwise → "public-only" (strict).
*/
export function getProviderValidationGuard(): OutboundUrlGuardMode {
if (arePrivateProviderUrlsAllowed()) return "none";
if (areLocalProviderUrlsAllowed()) return "block-metadata";
return "public-only";
}
/**
* Webhook variant of `parseAndValidatePublicUrl`. Webhooks legitimately point at
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
* so the private-host block is gated behind the same explicit opt-in used for private
* provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and
* embedded-credential checks in `parseOutboundUrl` remain unconditional. (#3269)
*/
export function parseAndValidateWebhookUrl(input: string | URL) {
const url = parseOutboundUrl(input);
// Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them
// even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269)
if (isCloudMetadataHost(url.hostname)) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
return url;
}

View File

@@ -2,11 +2,11 @@ import { isIP } from "node:net";
import dns from "node:dns";
import {
type OutboundUrlGuardMode,
getProviderOutboundGuard,
isPrivateHost,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;

View File

@@ -322,6 +322,11 @@ export const updateSettingsSchema = z.object({
cliproxyapi_fallback_enabled: z.boolean().optional(),
cliproxyapi_url: z.string().url().max(500).optional(),
cliproxyapi_fallback_codes: z.string().max(200).optional(),
// #7645: dedicated CLIProxyAPI credential. CLIProxyAPI requires its own
// separately-configured `api-keys:` credential and rejects any other token
// with 401 — without this field, the fallback/passthrough legs had no way
// to authenticate except by reusing the (incompatible) native provider key.
cliproxyapi_api_key: z.string().max(500).optional(),
// CLIProxyAPI model mapping (Record<string, string>)
cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(),
// Model lockout settings

View File

@@ -35,16 +35,41 @@ function getReservedProviderPrefixes(): Set<string> {
}
/**
* Build a combined model alias map that merges both alias stores:
* Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings
* UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias
* -> PATCH /api/settings) — into `pattern -> target` map entries so the T13
* wildcard step in getModelInfoCore() (which treats every key of the merged alias
* map as a candidate glob pattern) can see them (#7693). Without this the
* feature persists but is never consulted at request time.
*/
function buildWildcardAliasMap(settings: Record<string, unknown>): Record<string, unknown> {
const wildcardEntries = Array.isArray(settings.wildcardAliases)
? (settings.wildcardAliases as Array<{ pattern?: unknown; target?: unknown }>)
: [];
const wildcardMap: Record<string, unknown> = {};
for (const entry of wildcardEntries) {
if (entry && typeof entry.pattern === "string" && typeof entry.target === "string") {
wildcardMap[entry.pattern] = entry.target;
}
}
return wildcardMap;
}
/**
* Build a combined model alias map that merges all alias stores:
* 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') — set via
* /api/models/alias/ and seeded at startup.
* 2. Settings-based aliases (settings.modelAliases) — set via the Settings UI and
* 2. Settings-based exact aliases (settings.modelAliases) — set via the Settings UI and
* /api/settings/model-aliases/ (stored as a JSON blob in namespace='settings').
* 3. Settings-based wildcard aliases (settings.wildcardAliases) — set via the Settings
* UI's "Wildcard Pattern" mode, PATCH /api/settings (#7693).
*
* Settings-based aliases take priority so that UI configuration always wins.
* Without this merge, aliases configured via the Settings UI were never consulted
* during provider routing, causing provider inference (e.g. /^gpt-/ → openai) to
* silently override them (issue #2618 / #2208).
* Settings-based exact aliases take priority over DB-namespace aliases so that UI
* configuration always wins. Without this merge, aliases configured via the Settings
* UI were never consulted during provider routing, causing provider inference (e.g.
* /^gpt-/ → openai) to silently override them (issue #2618 / #2208). Wildcard entries
* are folded in last: they are keyed by pattern string (containing `*`/`?`), which
* cannot collide with a real model id, so ordering never affects exact-alias lookups.
*/
async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
const [dbAliases, settings] = await Promise.all([
@@ -59,8 +84,9 @@ async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
? (settings.modelAliases as Record<string, unknown>)
: {};
// Settings-based aliases win over DB-namespace aliases on key collision
return { ...dbAliases, ...settingsAliases };
const wildcardMap = buildWildcardAliasMap(settings);
return { ...dbAliases, ...settingsAliases, ...wildcardMap };
}
/**