merge release/v3.8.49 into feat/7567-grokweb-ua-hint

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

View File

@@ -27,6 +27,8 @@ jobs:
cache: npm
- run: npm ci
- name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute
env:

View File

@@ -19,7 +19,9 @@ jobs:
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (block mode)
env:
@@ -72,7 +74,9 @@ jobs:
if: steps.gate.outputs.run == 'true'
- name: Build CLI bundle
if: steps.gate.outputs.run == 'true'
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute
if: steps.gate.outputs.run == 'true'

View File

@@ -51,6 +51,7 @@ jobs:
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (background)
env:

View File

@@ -20,7 +20,9 @@ jobs:
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (background)
env:

View File

@@ -14,7 +14,7 @@
* All other commands are routed through Commander (bin/cli/program.mjs).
*/
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import updateNotifier from "update-notifier";
@@ -59,6 +59,28 @@ if (process.argv.includes("--mcp")) {
console.warn = stderrConsole.warn.bind(stderrConsole);
}
// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to
// `<DATA_DIR>/server.env` (electron/main.js), never `.env`. Migrating an existing
// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable —
// the CLI only ever looked for `.env`, so the STORAGE_ENCRYPTION_KEY needed to decrypt
// the migrated database was silently dropped (#7302). One-time, one-directory migration:
// if `<dataDir>/.env` is absent but `<dataDir>/server.env` is present, copy it to `.env`
// so it flows through the normal env-loading path below. Never overwrites an existing
// `.env` — an explicit `.env` always wins over a legacy `server.env`.
function migrateElectronServerEnv(dataDir) {
try {
const envPath = join(dataDir, ".env");
const serverEnvPath = join(dataDir, "server.env");
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
console.log(
` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`
);
} catch {
// Ignore errors migrating server.env — fall back to normal env loading below.
}
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
@@ -69,6 +91,8 @@ function loadEnvFile() {
envPaths.push(envPath);
};
migrateElectronServerEnv(process.env.DATA_DIR || getDefaultDataDir());
if (process.env.DATA_DIR) {
addEnvPath(join(process.env.DATA_DIR, ".env"));
}

View File

@@ -0,0 +1 @@
- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie)

View File

@@ -0,0 +1 @@
- fix(ci): build dast-smoke and nightly API-only smoke workflows with `OMNIROUTE_BUILD_BACKEND_ONLY=1` to skip the unused dashboard UI graph and stop the multi-minute build variance/timeouts (#7226)

View File

@@ -0,0 +1 @@
- fix(docs): correct stale `/api/version` and migration-125 references + realign `no-explicit-any` suppression counts drifted by base-red realignment commits (#7253)

View File

@@ -0,0 +1 @@
- fix(authz): classify /api/cli-tools/forge-settings and /api/cli-tools/jcode-settings as LOCAL_ONLY, closing an RCE-via-tunnel gap where getCliRuntimeStatus() spawns a child process without loopback enforcement (#7263)

View File

@@ -0,0 +1 @@
- fix(cli): load DATA_DIR/server.env as a fallback for .env when migrating from Electron, so STORAGE_ENCRYPTION_KEY/JWT_SECRET/API_KEY_SECRET survive an Electron→CLI install migration (#7302)

View File

@@ -0,0 +1 @@
- fix(providers): stop reporting Arena (lmarena) cookie validation as Invalid when the `/models` probe hits a 307 redirect — degrade to `unsupported` instead, and drop the stale "no registry entry" comment for lmarena (#7542)

View File

@@ -0,0 +1 @@
- fix(claude-web): unify Turnstile solver, executor and httpBackedChat fast-path User-Agents behind one shared fingerprint module so `cf_clearance` is never solved under a different UA than the one that replays it (#7548)

View File

@@ -0,0 +1 @@
- fix(sse): proactively refresh Grok Build's expiring OAuth token before dispatch, and add it to the connection-test config so "Test Connection" no longer reports "unsupported" (#7610)

View File

@@ -0,0 +1 @@
- fix(routing): strip `prompt_cache_key` for NVIDIA NIM — Codex CLI injects it, NIM's OpenAI-compatible wrapper 400s on it (#7617)

View File

@@ -0,0 +1 @@
- fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo candidate pools (#7620)

View File

@@ -0,0 +1 @@
- fix(providers): correct Chutes registry baseUrl from api.chutesai.com to llm.chutes.ai (#7621)

View File

@@ -0,0 +1 @@
- fix(providers): classify Mistral ambiguous 401 (quota vs revoked key) instead of asserting hard auth failure (#7638)

View File

@@ -0,0 +1 @@
- fix(sse): route CLIProxyAPI fallback/passthrough legs through a dedicated `cliproxyapi_api_key` credential instead of the failed native provider's own key (#7645)

View File

@@ -0,0 +1 @@
- fix(packaging): move fumadocs-mdx from dependencies to devDependencies to avoid pulling its build-only yuku-analyzer/yuku-ast toolchain into `npm install -g omniroute` (#7661)

View File

@@ -0,0 +1 @@
- fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676)

View File

@@ -0,0 +1 @@
- fix(cli): split `outboundUrlGuard.ts`'s DB/feature-flag helpers into `outboundUrlGuardPolicy.ts` so `omniroute setup-opencode` no longer crashes with `Cannot find package '@/shared'` on a global npm install (#7682)

View File

@@ -0,0 +1 @@
- fix(sse): wire settings.wildcardAliases into model resolution so wildcard model aliases created in Settings actually take effect (#7693)

View File

@@ -0,0 +1 @@
- fix(mcp): copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701)

View File

@@ -0,0 +1 @@
- fix(security): bump adm-zip to ^0.6.0 (dev transitive via promptfoo/onnxruntime-node) to clear the crafted-ZIP 4GB-allocation advisory, and tighten mitm DNS test host assertions to exact/suffix matching (CodeQL js/incomplete-url-substring-sanitization)

View File

@@ -0,0 +1 @@
- Register 5 covering unit tests (account-fallback lockout eviction, cliproxyapi dedicated credential #7645, combo least-used account, combo recovery-hint, route-guard forge/jcode local-only) in `stryker.conf.json` `tap.testFiles` to clear the release base-red.

View File

@@ -599,7 +599,7 @@
},
"tests/unit/base-executor-sanitize-effort.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 45
"count": 48
}
},
"tests/unit/batch_api.test.ts": {
@@ -1024,7 +1024,7 @@
},
"tests/unit/combo-routing-engine.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 261
"count": 269
}
},
"tests/unit/combo-same-provider-cascade.test.ts": {

View File

@@ -99,7 +99,7 @@ not** skip steps; each is timed.
### 4.2 Cluster-wide latency regression
1. Check the most recent deploy (`/api/version` returns the SHA).
1. Check the most recent deploy (`/api/system/version` returns the SHA).
2. If p95 doubled vs the 7-day baseline, **roll back** to the prior
SHA via `bin/rollback.sh`.
3. If the regression is provider-side, see § 4.1.

View File

@@ -125,7 +125,7 @@ flag in the weekly perf review.
| Endpoint | Method | p50 | p95 | p99 |
|---|---|---|---|---|
| `/api/health/ping` | GET | 5 ms | 20 ms | 50 ms |
| `/api/version` | GET | 5 ms | 20 ms | 50 ms |
| `/api/system/version` | GET | 5 ms | 20 ms | 50 ms |
| `/api/docs` | GET | 20 ms | 80 ms | 200 ms (HTML shell, no provider call) |
---

View File

@@ -64,7 +64,7 @@ executed there. The rule decision is stored in the existing route trace without
## Persistence
The migration `src/lib/db/migrations/125_reasoning_routing_rules.sql` creates the
The migration `src/lib/db/migrations/126_reasoning_routing_rules.sql` creates the
`reasoning_routing_rules` table. Rules reference stored API keys, combos, and provider connections.
Deletes clean up related rules. The database access layer in
`src/lib/db/reasoningRoutingRules.ts` maintains an invalidatable cache for the request path.

View File

@@ -0,0 +1,30 @@
/**
* Claude Web — shared browser fingerprint source of truth
*
* Cloudflare binds the `cf_clearance` cookie minted by the Turnstile solver
* to the User-Agent (+ TLS/JA3 fingerprint + IP) that solved the challenge.
* If the completion request later replays that cookie under a *different*
* User-Agent, Cloudflare rejects it and the executor surfaces a persistent
* 429 (see #7548).
*
* Every part of the claude-web pipeline that talks to claude.ai — the
* Turnstile solver, the direct-fetch executor, and the httpBackedChat
* fast path — MUST derive its User-Agent / Client-Hints headers from this
* single constant so they can never drift apart again.
*
* Platform choice: Linux, matching the `chrome_146` TLS/JA3 profile used by
* `claudeTlsClient.ts` and the browser-pool default (`browserPool.ts`).
*/
export const CLAUDE_WEB_FINGERPRINT = {
userAgent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
secChUa: '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"',
secChUaPlatform: '"Linux"',
} as const;
/**
* Bump this whenever `CLAUDE_WEB_FINGERPRINT` changes so any previously
* cached `cf_clearance` token (minted under the old fingerprint) is treated
* as stale rather than replayed under the new one.
*/
export const CLAUDE_WEB_FINGERPRINT_VERSION = "v2-linux-unified";

View File

@@ -5,7 +5,7 @@ export const chutesProvider: RegistryEntry = {
alias: "chutes",
format: "openai",
executor: "default",
baseUrl: "https://api.chutesai.com/v1/chat/completions",
baseUrl: "https://llm.chutes.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "Qwen2.5-72B-Instruct", name: "Qwen2.5 72B" }],

View File

@@ -1,12 +1,9 @@
import type { RegistryEntry } from "../../shared.ts";
import { NOTION_WEB_FALLBACK_MODELS } from "../../../../services/notionWebModels.ts";
// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts
// for the reverse-engineering rationale (issue #6758, closed native-provider
// request #3272). Notion AI does not expose a documented, selectable model
// catalog through its internal endpoint — the assistant response is server-side
// routed. `passthroughModels: true` lets an operator pass any model id the
// endpoint may honor in the future without a registry change; the single
// `notion-ai` entry is the default/safe fallback shown in the picker.
// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts.
// Live catalog comes from cookie-auth POST /api/v3/getAvailableModels (models route).
// The registry seed below is the offline fallback when discovery fails.
export const notion_webProvider: RegistryEntry = {
id: "notion-web",
alias: "nw",
@@ -16,5 +13,5 @@ export const notion_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
passthroughModels: true,
models: [{ id: "notion-ai", name: "Notion AI (Unofficial/Experimental)" }],
models: NOTION_WEB_FALLBACK_MODELS.map((m) => ({ id: m.id, name: m.name })),
};

View File

@@ -23,6 +23,7 @@ import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { tlsFetchClaude } from "../services/claudeTlsClient.ts";
import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts";
import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts";
import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
import { randomUUID } from "crypto";
import { sanitizeErrorMessage } from "../utils/error.ts";
@@ -37,8 +38,7 @@ import {
const CLAUDE_WEB_API_BASE = "https://claude.ai/api";
const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`;
const CLAUDE_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent;
// Session cookie constants
const CLAUDE_SESSION_COOKIE_NAME = "sessionKey";
@@ -108,9 +108,9 @@ function getBrowserHeaders(deviceId?: string): Record<string, string> {
Pragma: "no-cache",
Priority: "u=1, i",
Referer: "https://claude.ai/new",
"Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"',
"Sec-Ch-Ua": CLAUDE_WEB_FINGERPRINT.secChUa,
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Linux"',
"Sec-Ch-Ua-Platform": CLAUDE_WEB_FINGERPRINT.secChUaPlatform,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",

View File

@@ -169,6 +169,40 @@ function readProviderSpecificString(
return "";
}
/**
* Merge rotated __Secure-1PSID* cookies read back from the live Playwright
* cookie jar into the original cookie string. Only the three long-lived
* Gemini auth cookies are considered — pulling in the entire jar would risk
* treating short-lived Google analytics/consent cookies as credentials
* (#7676). Cookies the jar didn't return, or that are unchanged, are left
* untouched in the original string.
*/
export function mergeRotatedGeminiCookies(
originalCookie: string,
jarCookies: Array<{ name: string; value: string }>
): string {
const ROTATABLE_NAMES = ["__Secure-1PSID", "__Secure-1PSIDTS", "__Secure-1PSIDCC"];
const jarByName = new Map(jarCookies.map((c) => [c.name, c.value]));
const pairs = parseCookies(originalCookie);
const seen = new Set<string>();
const merged = pairs.map(({ name, value }) => {
seen.add(name);
if (ROTATABLE_NAMES.includes(name) && jarByName.has(name)) {
return { name, value: jarByName.get(name) as string };
}
return { name, value };
});
for (const name of ROTATABLE_NAMES) {
if (!seen.has(name) && jarByName.has(name)) {
merged.push({ name, value: jarByName.get(name) as string });
}
}
return merged.map(({ name, value }) => `${name}=${value}`).join("; ");
}
function normalizeGeminiCookieInput(raw: string, cookieName = "__Secure-1PSID"): string {
const trimmed = raw.trim();
if (!trimmed) return "";
@@ -202,8 +236,38 @@ export class GeminiWebExecutor extends BaseExecutor {
super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL });
}
/**
* Read the live Playwright cookie jar back after a successful run and, if
* Google rotated any of the __Secure-1PSID* cookies, forward the merged
* cookie string through onCredentialsRefreshed so it gets persisted to the
* encrypted provider_connections.api_key field. Mirrors the rotate-and-
* persist pattern already shipped in chatgpt-web.ts. A persistence failure
* must never fail the user-facing response (#7676).
*/
private async persistRotatedCookies(
context: import("playwright").BrowserContext,
cookie: string,
credentials: ExecuteInput["credentials"],
onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"],
log: ExecuteInput["log"]
): Promise<void> {
if (!onCredentialsRefreshed) return;
try {
const jarCookies = await context.cookies();
const mergedCookie = mergeRotatedGeminiCookies(cookie, jarCookies);
if (mergedCookie && mergedCookie !== cookie) {
await onCredentialsRefreshed({ ...credentials, apiKey: mergedCookie });
}
} catch (err) {
log?.warn?.(
"GEMINI-WEB",
`Failed to persist rotated cookie: ${err instanceof Error ? err.message : String(err)}`
);
}
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal } = input;
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
const requestBody = body as GeminiRequestBody;
const cookie = resolveGeminiWebCookie(credentials);
@@ -314,6 +378,8 @@ export class GeminiWebExecutor extends BaseExecutor {
};
}
await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log);
const modelId = model || "gemini-2.5-pro";
if (stream) {

View File

@@ -16,6 +16,7 @@ import {
import { PROVIDERS } from "../config/constants.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { resolveProxyForRequest } from "../utils/proxyFetch.ts";
import { runWithOnPersist, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts";
import https from "node:https";
import { HttpsProxyAgent } from "https-proxy-agent";
@@ -76,17 +77,78 @@ export class GrokCliExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal } = input;
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
const url = this.buildUrl(model, stream, 0, credentials);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
// #7610: unlike BaseExecutor.execute() (which most executors inherit or
// delegate to via super.execute()), this executor talks upstream via raw
// https.request() (nativePost) instead of the shared fetch path, so it
// never picked up the base class's proactive refresh gate. Without it,
// xAI's rotating refresh_token idled until real expiry — the only refresh
// that fired was the reactive one on a 401/403 from upstream — matching
// the "unusable within minutes" report. Apply the same gate here.
const activeCredentials = await this.applyProactiveRefresh(
credentials,
log,
onCredentialsRefreshed
);
const url = this.buildUrl(model, stream, 0, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream);
const transformedBody = this.transformRequest(model, body, stream, activeCredentials);
const bodyStr = JSON.stringify(transformedBody);
const response = await this.nativePost(url, headers, bodyStr, signal);
return { response, url, headers, transformedBody };
}
/**
* Proactive-refresh gate mirroring BaseExecutor.execute()'s (base.ts:599-685),
* scoped to grok-cli's single-URL nativePost dispatch (no fallback-URL retry
* loop to thread through). xAI uses rotating refresh tokens (same family as
* Codex/Claude) — `runWithOnPersist` keeps the [refresh + persist] atomic
* under the same per-connection mutex `getAccessToken` uses, and
* `isUnrecoverableRefreshError` keeps a reused/invalid sentinel from being
* spread into the outgoing credentials — see base.ts:622-673 for the full
* regression history this mirrors.
*/
private async applyProactiveRefresh(
credentials: ProviderCredentials,
log?: ExecutorLog | null,
onCredentialsRefreshed?: ExecuteInput["onCredentialsRefreshed"]
): Promise<ProviderCredentials> {
if (!this.needsRefresh(credentials)) return credentials;
try {
let persistRan = false;
const onPersist = onCredentialsRefreshed
? async (refreshResult: Record<string, unknown>) => {
persistRan = true;
await onCredentialsRefreshed(refreshResult as Partial<ProviderCredentials>);
}
: null;
const refreshed = await runWithOnPersist(onPersist, () =>
this.refreshCredentials(credentials, log || null)
);
if (!refreshed || isUnrecoverableRefreshError(refreshed)) {
return credentials;
}
const merged = { ...credentials, ...refreshed };
if (onCredentialsRefreshed && !persistRan) {
await onCredentialsRefreshed(refreshed);
}
return merged;
} catch (error) {
log?.error?.(
"TOKEN",
`Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}`
);
return credentials;
}
}
async refreshCredentials(
credentials: ProviderCredentials,
log?: ExecutorLog | null

View File

@@ -127,19 +127,39 @@ export function extractSpaceIdFromCookie(cookie: string): string {
/**
* Build a Notion `runInferenceTranscript` transcript array from OpenAI-style
* chat messages. Each entry uses Notion's rich-text tuple value shape
* (`[[text]]`) and a role tag Notion's own web client sends.
* chat messages. When `notionModel` is set (and not the synthetic `notion-ai`
* default), a leading `config` entry carries `value.model` so Notion routes the
* request to the selected codename from getAvailableModels.
*/
export function buildNotionTranscript(
messages: NotionMessage[]
messages: NotionMessage[],
notionModel?: string
): Array<Record<string, unknown>> {
return messages
.filter((m) => typeof m?.content === "string" && m.content.length > 0)
.map((m) => ({
const entries: Array<Record<string, unknown>> = [];
const trimmedModel = typeof notionModel === "string" ? notionModel.trim() : "";
const model = trimmedModel && trimmedModel !== "notion-ai" ? trimmedModel : "";
if (model) {
entries.push({
id: randomUUID(),
type: "config",
value: {
type: "workflow",
model,
modelFromUser: true,
useWebSearch: false,
searchScopes: [{ type: "everything" }],
},
});
}
for (const m of messages) {
if (typeof m?.content !== "string" || m.content.length === 0) continue;
entries.push({
id: randomUUID(),
type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human",
value: [[m.content]],
}));
});
}
return entries;
}
/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
@@ -250,8 +270,11 @@ export class NotionWebExecutor extends BaseExecutor {
const modelId = model || "notion-ai";
const reqBody: Record<string, unknown> = {
traceId: randomUUID(),
transcript: buildNotionTranscript(messages),
transcript: buildNotionTranscript(messages, modelId),
createThread: false,
asPatchResponse: true,
threadType: "workflow",
createdSource: "ai_module",
};
if (spaceId) reqBody.spaceId = spaceId;

View File

@@ -0,0 +1,76 @@
/**
* CLIProxyAPI dedicated-credential resolution (#7645).
*
* CLIProxyAPI requires its own separately-configured `api-keys:` credential
* and rejects any other token with 401. Before this fix, both the direct
* `mode: "cliproxyapi"` passthrough leg and the `mode: "fallback"` retry leg
* (`open-sse/handlers/chatCore/executorProxy.ts::resolveExecutorWithProxy`)
* reused the resolved connection's own credentials — the native provider's
* key — as the Authorization header sent to CLIProxyAPI, making the fallback
* path a permanent no-op for every provider configured this way.
*
* This module resolves and applies the dedicated `cliproxyapi_api_key`
* setting at the executor boundary, so `CliproxyapiExecutor` itself stays
* credential-source-agnostic (it just uses whatever `credentials` it's
* handed — see `buildHeaders()`).
*/
import type { ProviderCredentials } from "../../executors/base.ts";
type ExecutorInput = {
credentials: ProviderCredentials;
[key: string]: unknown;
};
type ExecutorLike = {
execute: (input: ExecutorInput) => Promise<unknown>;
[key: string]: unknown;
};
/**
* Reads the dedicated CLIProxyAPI key out of a settings blob (as returned by
* `getCachedSettings()`), trimmed and normalized to `null` when absent/blank.
*/
export function resolveDedicatedCliproxyapiApiKey(
settings: Record<string, unknown> | null | undefined
): string | null {
const raw = settings?.cliproxyapi_api_key;
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
}
/**
* Builds the credentials to use for a CLIProxyAPI-bound request. When a
* dedicated key is configured it always wins — CLIProxyAPI is a single
* shared instance serving every provider, so the resolved connection's own
* (provider-specific, and possibly already-failed) credential is never the
* right token for it. Falls back to the connection's own credentials only
* when no dedicated key is configured, preserving the pre-existing behavior
* for operators who previously worked around this by pasting a valid
* CLIProxyAPI key into the connection's own `apiKey` field.
*/
export function resolveCliproxyapiCredentials(
connectionCredentials: ProviderCredentials,
dedicatedApiKey: string | null
): ProviderCredentials {
if (!dedicatedApiKey) return connectionCredentials;
return { ...connectionCredentials, apiKey: dedicatedApiKey, accessToken: undefined };
}
/**
* Wraps an executor so every `execute()` call is routed with the dedicated
* CLIProxyAPI credential substituted in when one is configured. No-op
* wrapper when no dedicated key is set (returns the executor unchanged).
*/
export function wrapExecutorWithCliproxyapiCredentials<T extends ExecutorLike>(
executor: T,
dedicatedApiKey: string | null
): T {
if (!dedicatedApiKey) return executor;
const wrapped = Object.create(executor) as T;
wrapped.execute = (input: ExecutorInput) =>
executor.execute({
...input,
credentials: resolveCliproxyapiCredentials(input.credentials, dedicatedApiKey),
});
return wrapped;
}

View File

@@ -14,6 +14,10 @@ import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
import { getCachedSettings } from "@/lib/db/readCache";
import { getUpstreamProxyConfigCached } from "./comboContextCache.ts";
import { wrapExecutorWithCliproxyapiModelMapping } from "./cliproxyModelMapping.ts";
import {
resolveDedicatedCliproxyapiApiKey,
wrapExecutorWithCliproxyapiCredentials,
} from "./cliproxyapiCredentials.ts";
type LoggerLike =
| {
@@ -24,6 +28,40 @@ type LoggerLike =
| null
| undefined;
const DEFAULT_FALLBACK_CODES = [429, 500, 502, 503, 504];
function parseFallbackCodes(raw: unknown): number[] | null {
if (typeof raw !== "string" || !raw.trim()) return null;
const parsed = raw
.split(",")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n));
return parsed.length > 0 ? parsed : null;
}
/**
* Reads the CLIProxyAPI-related settings shared by both the direct
* `mode: "cliproxyapi"` passthrough leg and the `mode: "fallback"` retry leg:
* the custom fallback status codes and the dedicated credential (#7645).
* Falls back to defaults / no dedicated key on any read failure.
*/
async function loadCliproxyapiSettings(): Promise<{
fallbackCodes: number[];
dedicatedApiKey: string | null;
}> {
try {
const allSettings = await getCachedSettings();
return {
fallbackCodes: parseFallbackCodes(allSettings.cliproxyapi_fallback_codes) ?? [
...DEFAULT_FALLBACK_CODES,
],
dedicatedApiKey: resolveDedicatedCliproxyapiApiKey(allSettings),
};
} catch {
return { fallbackCodes: [...DEFAULT_FALLBACK_CODES], dedicatedApiKey: null };
}
}
export async function resolveExecutorWithProxy(
prov: string,
log?: LoggerLike,
@@ -48,9 +86,10 @@ export async function resolveExecutorWithProxy(
if (cfg.mode === "cliproxyapi") {
log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`);
return wrapExecutorWithCliproxyapiModelMapping(
getExecutor("cliproxyapi"),
cfg.cliproxyapiModelMapping
const { dedicatedApiKey } = await loadCliproxyapiSettings();
return wrapExecutorWithCliproxyapiCredentials(
wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping),
dedicatedApiKey
);
}
@@ -58,28 +97,13 @@ export async function resolveExecutorWithProxy(
// The model mapping applies only to the CLIProxyAPI retry leg (proxyExec) — the
// native leg must keep seeing the original, unmapped model.
const nativeExec = getExecutor(prov);
const proxyExec = wrapExecutorWithCliproxyapiModelMapping(
getExecutor("cliproxyapi"),
cfg.cliproxyapiModelMapping
const { fallbackCodes, dedicatedApiKey } = await loadCliproxyapiSettings();
// #7645: the CLIProxyAPI retry leg must authenticate with the dedicated
// key, never the native provider's own (already-failed) credential.
const proxyExec = wrapExecutorWithCliproxyapiCredentials(
wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping),
dedicatedApiKey
);
// Read custom fallback codes from settings. Default: 5xx + 429 + network errors.
let fallbackCodes: number[] = [429, 500, 502, 503, 504];
try {
const allSettings = await getCachedSettings();
if (
typeof allSettings.cliproxyapi_fallback_codes === "string" &&
allSettings.cliproxyapi_fallback_codes.trim()
) {
const parsed = allSettings.cliproxyapi_fallback_codes
.split(",")
.map((s: string) => Number.parseInt(s.trim(), 10))
.filter((n: number) => !Number.isNaN(n));
if (parsed.length > 0) fallbackCodes = parsed;
}
} catch {
/* use defaults */
}
const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0;
const wrapper = Object.create(nativeExec);

View File

@@ -140,7 +140,8 @@ function getNoAuthCandidates(
excludedProviders: Set<string>,
blockedProviders: Set<string>,
disabledNoAuthProviders: Set<string>,
noAuthProviderSpecificData: Map<string, Record<string, unknown> | null | undefined>
noAuthProviderSpecificData: Map<string, Record<string, unknown> | null | undefined>,
hiddenModelsMap: Map<string, Set<string>>
): VirtualAutoComboCandidate[] {
const registry = getProviderRegistry();
const candidates: VirtualAutoComboCandidate[] = [];
@@ -190,10 +191,19 @@ function getNoAuthCandidates(
? noAuthProviderSpecificData.get(providerDef.alias)
: undefined);
// #7620: honor the eye-icon "hidden" flag (isHidden, from the
// modelCompatOverrides/customModels key_value namespaces) the same way the
// credentialed-connection loop below does, so a hidden no-auth model never
// enters the auto-combo/fusion candidate pool either.
const hiddenModels =
hiddenModelsMap.get(providerId) ??
(typeof providerDef.alias === "string" ? hiddenModelsMap.get(providerDef.alias) : undefined);
for (const model of registryModels) {
const modelId = typeof model?.id === "string" && model.id.trim().length > 0 ? model.id : null;
if (!modelId) continue;
if (isModelExcludedByConnection(modelId, providerSpecificData)) continue;
if (hiddenModels?.has(modelId)) continue;
candidates.push({
provider: providerId,
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
@@ -323,7 +333,8 @@ export async function createVirtualAutoCombo(
new Set(validConnections.map((conn) => conn.provider)),
blockedProviders,
disabledNoAuthProviders,
noAuthProviderSpecificData
noAuthProviderSpecificData,
hiddenModelsMap
)
);

View File

@@ -26,6 +26,7 @@ import {
} from "./browserPool.ts";
import tlsClient from "../utils/tlsClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { resolveHttpBackedChatFingerprint } from "./httpBackedChatFingerprint.ts";
// Safety constants
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
@@ -441,11 +442,10 @@ export async function httpBackedChat(
const t0 = Date.now();
const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req;
const fingerprint = resolveHttpBackedChatFingerprint(chatUrlMatchDomain); // #7548
// Build browser-emulated headers
const headers: Record<string, string> = {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
"User-Agent": fingerprint.userAgent,
Accept: "text/event-stream, application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
@@ -460,9 +460,9 @@ export async function httpBackedChat(
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"',
"Sec-Ch-Ua": fingerprint.secChUa,
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Ch-Ua-Platform": fingerprint.secChUaPlatform,
Priority: "u=1, i",
};

View File

@@ -11,6 +11,10 @@
*/
import type { Browser, Page } from "playwright";
import {
CLAUDE_WEB_FINGERPRINT,
CLAUDE_WEB_FINGERPRINT_VERSION,
} from "../config/claudeWebFingerprint.ts";
const CLAUDE_WEB_URL = "https://claude.ai";
const CHALLENGE_TIMEOUT = 60000; // 60s to solve challenge
@@ -85,8 +89,7 @@ export async function solveTurnstile(options?: {
const { chromium } = await import("playwright");
browser = await chromium.launch({ headless });
const context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
userAgent: CLAUDE_WEB_FINGERPRINT.userAgent,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: process.env.OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS === "true",
});
@@ -149,7 +152,7 @@ export async function getCfClearanceToken(options?: {
force?: boolean;
headless?: boolean;
}): Promise<string> {
const cacheKey = "claude-cf-clearance";
const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`;
const cached = tokenCache.get(cacheKey);
if (cfClearanceTokenOverride) {
@@ -193,7 +196,7 @@ export function getCacheStatus(): {
hasCached: boolean;
expiresIn?: number;
} {
const cacheKey = "claude-cf-clearance";
const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`;
const cached = tokenCache.get(cacheKey);
if (!cached) {

View File

@@ -0,0 +1,29 @@
/**
* Header fingerprint resolution for `httpBackedChat()`.
*
* claude.ai MUST reuse the exact fingerprint the Turnstile solver used to
* mint `cf_clearance` (see `open-sse/config/claudeWebFingerprint.ts`) —
* otherwise Cloudflare rejects the replayed cookie and every request 429s
* (#7548). Other `httpBackedChat` callers (e.g. duckduckgo-web) keep their
* own independent fingerprint, which never needs to match a solved cookie.
*/
import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts";
export interface HttpBackedChatFingerprint {
userAgent: string;
secChUa: string;
secChUaPlatform: string;
}
const DUCKDUCKGO_FALLBACK_FINGERPRINT: HttpBackedChatFingerprint = {
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
secChUa: '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"',
secChUaPlatform: '"macOS"',
};
export function resolveHttpBackedChatFingerprint(
chatUrlMatchDomain: string
): HttpBackedChatFingerprint {
return chatUrlMatchDomain === "claude.ai" ? CLAUDE_WEB_FINGERPRINT : DUCKDUCKGO_FALLBACK_FINGERPRINT;
}

View File

@@ -0,0 +1,319 @@
/**
* Notion AI Web model discovery helpers.
*
* Notion has no public model catalog API. The browser AI surface loads models via
* cookie-auth `POST /api/v3/getAvailableModels` with body `{ spaceId }` (see
* browser capture against app.notion.com). These helpers parse that response and
* build the cookie/headers/body the models-discovery route needs.
*/
const NOTION_APP_ORIGIN = "https://www.notion.so";
const NOTION_MODELS_URL = `${NOTION_APP_ORIGIN}/api/v3/getAvailableModels`;
const NOTION_SPACES_URL = `${NOTION_APP_ORIGIN}/api/v3/getSpaces`;
const NOTION_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
/** Recent Notion web client version — accepted loosely but required by some paths. */
const NOTION_CLIENT_VERSION = "23.13.20260718.1805";
export type NotionDiscoveredModel = {
id: string;
name: string;
owned_by: string;
supportsReasoning?: boolean;
disabled?: boolean;
};
/** Offline fallback when getAvailableModels is unreachable (seeded from live picker). */
export const NOTION_WEB_FALLBACK_MODELS: NotionDiscoveredModel[] = [
{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" },
{ id: "orange-mousse", name: "GPT-5.6 Sol", owned_by: "openai" },
{ id: "orchid-muffin", name: "GPT-5.6 Terra", owned_by: "openai" },
{ id: "olive-jellyroll", name: "GPT-5.6 Luna", owned_by: "openai" },
{ id: "oatmeal-cookie", name: "GPT-5.2", owned_by: "openai" },
{ id: "oval-kumquat-medium", name: "GPT-5.4", owned_by: "openai" },
{ id: "opal-quince-medium", name: "GPT-5.5", owned_by: "openai" },
{ id: "oregon-grape-medium", name: "GPT-5.4 Mini", owned_by: "openai" },
{ id: "otaheite-apple-medium", name: "GPT-5.4 Nano", owned_by: "openai" },
{ id: "vertex-gemini-3.5-flash", name: "Gemini 3.5 Flash", owned_by: "gemini" },
{ id: "gingerbread", name: "Gemini 3 Flash", owned_by: "gemini" },
{ id: "galette-medium-thinking", name: "Gemini 3.1 Pro", owned_by: "gemini" },
{ id: "almond-croissant-low", name: "Sonnet 4.6", owned_by: "anthropic" },
{ id: "angel-cake-high", name: "Sonnet 5", owned_by: "anthropic" },
{ id: "avocado-froyo-medium", name: "Opus 4.6", owned_by: "anthropic" },
{ id: "apricot-sorbet-high", name: "Opus 4.7", owned_by: "anthropic" },
{ id: "ambrosia-tart-high", name: "Opus 4.8", owned_by: "anthropic" },
{ id: "anthropic-haiku-4.5", name: "Haiku 4.5", owned_by: "anthropic" },
{ id: "acai-budino-high", name: "Fable 5", owned_by: "anthropic" },
{ id: "fireworks-kimi-k2.6", name: "Kimi K2.6", owned_by: "mystery" },
{ id: "fireworks-kimi-k2.7", name: "Kimi K2.7 Code", owned_by: "mystery" },
{ id: "baseten-deepseek-v4-pro", name: "DeepSeek V4 Pro", owned_by: "mystery" },
{ id: "baseten-glm-5.2", name: "GLM 5.2", owned_by: "mystery" },
{ id: "xigua-mochi-medium", name: "Grok 4.3", owned_by: "xai" },
{ id: "strawberry-whoopiepie", name: "Grok 4.5", owned_by: "xai" },
{ id: "xinomavro-cake", name: "Grok Build 0.1", owned_by: "xai" },
];
/** Normalize a pasted credential to a Cookie header string. */
export function normalizeNotionWebCookie(raw: string): string {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
return trimmed.includes("=") ? trimmed : `token_v2=${trimmed}`;
}
/** Read `name=value` from a cookie header (case-insensitive name). */
export function readCookieValue(cookie: string, name: string): string {
if (!cookie || !name) return "";
const re = new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`, "i");
const m = cookie.match(re);
if (!m) return "";
const raw = m[1].trim();
// Malformed % sequences in cookie values must not throw (Gemini review).
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function extractSpaceIdFromNotionCookie(cookie: string): string {
return (
readCookieValue(cookie, "space_id") ||
readCookieValue(cookie, "spaceId") ||
""
);
}
export function extractNotionUserIdFromCookie(cookie: string): string {
return (
readCookieValue(cookie, "notion_user_id") ||
readCookieValue(cookie, "notion_user_id_v2") ||
readCookieValue(cookie, "user_id") ||
""
);
}
/** Trim to a non-empty string, or fall back to `fallback`. */
function trimmedOrFallback(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
/** True when the row's `modelConfiguration.supportedReasoningEfforts` is a non-empty array. */
function rowSupportsReasoning(row: Record<string, unknown>): boolean {
const efforts = (row.modelConfiguration as { supportedReasoningEfforts?: unknown } | undefined)
?.supportedReasoningEfforts;
return Array.isArray(efforts) && efforts.length > 0;
}
/**
* Parse one getAvailableModels list entry into a model, or `null` when the entry
* should be skipped (disabled, malformed, or a duplicate id already in `seen`).
*/
function parseNotionModelEntry(
entry: unknown,
seen: Set<string>
): NotionDiscoveredModel | null {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const row = entry as Record<string, unknown>;
if (row.isDisabled === true) return null;
const id = typeof row.model === "string" ? row.model.trim() : "";
if (!id || seen.has(id)) return null;
seen.add(id);
return {
id,
name: trimmedOrFallback(row.modelMessage, id),
owned_by: trimmedOrFallback(row.modelFamily, "notion"),
...(rowSupportsReasoning(row) ? { supportsReasoning: true } : {}),
};
}
/** Ensure a stable default id always exists for clients that still request notion-ai. */
function withDefaultNotionModel(
out: NotionDiscoveredModel[],
seen: Set<string>
): NotionDiscoveredModel[] {
if (out.length === 0 || seen.has("notion-ai")) return out;
return [{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" }, ...out];
}
/**
* Parse getAvailableModels JSON into OpenAI-style model entries.
* Skips disabled models; prefers display `modelMessage` as name and internal
* `model` codename as id (what runInferenceTranscript expects).
*/
export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel[] {
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
const list = (data as { models?: unknown }).models;
if (!Array.isArray(list)) return [];
const seen = new Set<string>();
const out: NotionDiscoveredModel[] = [];
for (const entry of list) {
const model = parseNotionModelEntry(entry, seen);
if (model) out.push(model);
}
return withDefaultNotionModel(out, seen);
}
export function buildNotionModelsDiscoveryHeaders(token: string): Record<string, string> {
const cookie = normalizeNotionWebCookie(token);
const spaceId = extractSpaceIdFromNotionCookie(cookie);
const userId = extractNotionUserIdFromCookie(cookie);
const headers: Record<string, string> = {
accept: "*/*",
"content-type": "application/json",
"user-agent": NOTION_USER_AGENT,
origin: NOTION_APP_ORIGIN,
referer: `${NOTION_APP_ORIGIN}/ai`,
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
...(cookie ? { cookie } : {}),
};
if (spaceId) headers["x-notion-space-id"] = spaceId;
if (userId) headers["x-notion-active-user-header"] = userId;
return headers;
}
export function buildNotionModelsDiscoveryBody(token: string): { spaceId?: string } {
const cookie = normalizeNotionWebCookie(token);
const spaceId = extractSpaceIdFromNotionCookie(cookie);
return spaceId ? { spaceId } : {};
}
export function getNotionModelsDiscoveryUrl(): string {
return NOTION_MODELS_URL;
}
/**
* Try to resolve a workspace spaceId from getSpaces when the cookie has none.
* Returns "" on any failure (caller falls back to local catalog).
*/
export async function resolveNotionSpaceIdFromGetSpaces(
cookie: string,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const normalized = normalizeNotionWebCookie(cookie);
if (!normalized) return "";
try {
const res = await fetchImpl(NOTION_SPACES_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: normalized,
origin: NOTION_APP_ORIGIN,
referer: `${NOTION_APP_ORIGIN}/`,
"user-agent": NOTION_USER_AGENT,
},
body: "{}",
});
if (!res.ok) return "";
const data = (await res.json()) as unknown;
return pickFirstSpaceId(data);
} catch {
return "";
}
}
/** Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } */
function pickSpaceIdFromUserMap(root: Record<string, unknown>): string {
for (const value of Object.values(root)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const spaceMap = (value as Record<string, unknown>).space;
if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) {
const ids = Object.keys(spaceMap as Record<string, unknown>);
if (ids.length > 0) return ids[0];
}
}
return "";
}
/** Flat shape: { spaces: [{ id }] } */
function pickSpaceIdFromSpacesArray(spaces: unknown): string {
if (!Array.isArray(spaces)) return "";
for (const s of spaces) {
if (s && typeof s === "object" && typeof (s as { id?: string }).id === "string") {
return (s as { id: string }).id;
}
}
return "";
}
/** Flat shape: { spaceIds: [] } */
function pickSpaceIdFromSpaceIdsArray(spaceIds: unknown): string {
return Array.isArray(spaceIds) && typeof spaceIds[0] === "string" ? spaceIds[0] : "";
}
/** Best-effort spaceId extraction from getSpaces response shapes. */
export function pickFirstSpaceId(data: unknown): string {
if (!data || typeof data !== "object") return "";
const root = data as Record<string, unknown>;
return (
pickSpaceIdFromUserMap(root) ||
pickSpaceIdFromSpacesArray(root.spaces) ||
pickSpaceIdFromSpaceIdsArray(root.spaceIds)
);
}
/**
* End-to-end discovery used by the models route special-case (and unit tests).
* Resolves spaceId from cookie or getSpaces, then calls getAvailableModels.
*/
export async function discoverNotionWebModels(opts: {
token: string;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}): Promise<{ models: NotionDiscoveredModel[]; spaceId: string; source: "api" }> {
const fetchImpl = opts.fetchImpl ?? fetch;
const cookie = normalizeNotionWebCookie(opts.token);
if (!cookie) {
throw new Error("Missing Notion token_v2 cookie");
}
let spaceId = extractSpaceIdFromNotionCookie(cookie);
if (!spaceId) {
spaceId = await resolveNotionSpaceIdFromGetSpaces(cookie, fetchImpl);
}
if (!spaceId) {
throw new Error(
"Missing Notion spaceId — include space_id=… in the cookie header or re-login so getSpaces can resolve a workspace"
);
}
// Prefer the canonical space id extractor (case-insensitive) so we do not
// append a second space_id= when the cookie used spaceId= or mixed case.
const cookieForHeaders = extractSpaceIdFromNotionCookie(cookie)
? cookie
: `${cookie}; space_id=${spaceId}`;
const headers = buildNotionModelsDiscoveryHeaders(cookieForHeaders);
headers["x-notion-space-id"] = spaceId;
const res = await fetchImpl(NOTION_MODELS_URL, {
method: "POST",
headers,
body: JSON.stringify({ spaceId }),
signal: opts.signal ?? undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`getAvailableModels failed (${res.status}): ${text.slice(0, 200)}`);
}
const data = await res.json();
const models = parseNotionAvailableModels(data);
if (models.length === 0) {
throw new Error("getAvailableModels returned no enabled models");
}
return { models, spaceId, source: "api" };
}
export {
NOTION_MODELS_URL,
NOTION_SPACES_URL,
NOTION_APP_ORIGIN,
NOTION_CLIENT_VERSION,
};

View File

@@ -50,6 +50,11 @@ const STRIP_RULES: StripRule[] = [
// (format:"openai") does not accept the Claude-style `thinking` body field
// and returns 400 "Unsupported parameter(s): thinking". Upstream #2268.
{ provider: "nvidia", match: /minimax-m2\.7/i, drop: ["thinking"] },
// NVIDIA NIM: OpenAI-compatible wrapper 400s on `prompt_cache_key` (Codex CLI
// injects it natively for its own prompt caching). NIM has no documented
// support for this field (providerSupportsCaching already treats nvidia as
// non-cache-capable) — safe to drop provider-wide, not model-specific. #7617.
{ provider: "nvidia", match: /.*/, drop: ["prompt_cache_key"] },
// VolcEngine Ark caps the Kimi coding-plan endpoint at max_tokens <= 32768
// server-side ("integer above maximum value, expected a value <= 32768"),
// independent of the model's own catalog ceiling. Confirmed against two

57
package-lock.json generated
View File

@@ -36,7 +36,6 @@
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fumadocs-core": "^16.10.5",
"fumadocs-mdx": "^15.0.7",
"fumadocs-ui": "^16.10.5",
"http-proxy-middleware": "^4.0.0",
"https-proxy-agent": "^9.0.0",
@@ -104,7 +103,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "*",
"@types/bun": "latest",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@@ -121,6 +120,7 @@
"eslint-config-next": "16.2.10",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"fumadocs-mdx": "^15.0.7",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
@@ -4976,6 +4976,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
"integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -12844,6 +12845,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -12856,6 +12858,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -12868,6 +12871,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -12880,6 +12884,7 @@
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
@@ -12895,6 +12900,7 @@
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
@@ -12910,6 +12916,7 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
@@ -12925,6 +12932,7 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
@@ -12940,6 +12948,7 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
@@ -12955,6 +12964,7 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
@@ -12970,6 +12980,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -12982,6 +12993,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -12991,6 +13003,7 @@
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.5.43.tgz",
"integrity": "sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/a-sync-waterfall": {
@@ -13052,20 +13065,21 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0"
"node": ">=14.0"
}
},
"node_modules/afinn-165": {
@@ -13593,6 +13607,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
"integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
"dev": true,
"license": "MIT",
"bin": {
"astring": "bin/astring"
@@ -14679,6 +14694,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
@@ -15327,6 +15343,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -17860,6 +17877,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
"integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
@@ -17876,6 +17894,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz",
"integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
@@ -18616,6 +18635,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz",
"integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
@@ -18642,6 +18662,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz",
"integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -18656,6 +18677,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz",
"integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
@@ -18683,6 +18705,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz",
"integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
@@ -18697,6 +18720,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
@@ -19699,6 +19723,7 @@
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.2.0.tgz",
"integrity": "sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/mdx": "^3.1.1",
@@ -25691,6 +25716,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -25753,6 +25779,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
"integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
@@ -26491,6 +26518,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
"integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
@@ -26517,6 +26545,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz",
"integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -26539,6 +26568,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz",
"integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
@@ -26552,6 +26582,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz",
"integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.0.0",
@@ -26572,6 +26603,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz",
"integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -26636,6 +26668,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz",
"integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
@@ -26864,6 +26897,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz",
"integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==",
"dev": true,
"funding": [
{
"type": "GitHub Sponsors",
@@ -32158,6 +32192,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -32210,6 +32245,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz",
"integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -32225,6 +32261,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz",
"integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn-jsx": "^5.0.0",
@@ -32245,6 +32282,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz",
"integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -32261,6 +32299,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz",
"integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -32504,6 +32543,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz",
"integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -32553,6 +32593,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
"integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdast-util-mdx": "^3.0.0",
@@ -33961,6 +34002,7 @@
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
@@ -35941,6 +35983,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz",
"integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
@@ -35954,6 +35997,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
@@ -37527,6 +37571,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/yuku-analyzer/-/yuku-analyzer-0.6.3.tgz",
"integrity": "sha512-RQ02dPtOa5d2AA3Np45EWD3EJUwZDruCrMMulPwUT/9GK1P7aKAhjaxG4Jv/1qqzMUIt0RUe3Dn2RR6d7+qTrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yuku-toolchain/types": "0.5.43"

View File

@@ -254,7 +254,6 @@
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fumadocs-core": "^16.10.5",
"fumadocs-mdx": "^15.0.7",
"fumadocs-ui": "^16.10.5",
"http-proxy-middleware": "^4.0.0",
"https-proxy-agent": "^9.0.0",
@@ -345,6 +344,7 @@
"eslint-config-next": "16.2.10",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"fumadocs-mdx": "^15.0.7",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
@@ -406,6 +406,7 @@
},
"node-gyp": {
"undici": "^6.27.0"
}
},
"adm-zip": "^0.6.0"
}
}

View File

@@ -188,6 +188,17 @@ const EXTRA_MODULE_ENTRIES = [
src: ["node_modules", "playwright-core"],
dest: ["node_modules", "playwright-core"],
},
{
// esbuild's `--packages=external` leaves `undici` as a static top-level ESM
// import in the compiled MCP server bundle (dist/open-sse/mcp-server/server.js),
// resolved at module-link time. Next.js's standalone output-file tracer (nft)
// sometimes emits a hollow dist/node_modules/undici/ (package.json only), which
// SHADOWS the fully-populated sibling node_modules/undici and crashes
// `omniroute --mcp` at startup. See #7701.
label: "undici (MCP server static import — #7701)",
src: ["node_modules", "undici"],
dest: ["node_modules", "undici"],
},
{
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
src: ["node_modules", "sqlite-vec"],

View File

@@ -50,6 +50,8 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/cli-tools/runtime",
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
"src/app/api/skills/collect", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus() (Hard Rules #15 + #17, PR #6294 review)
"src/app/api/cli-tools/forge-settings", // GET calls getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263)
"src/app/api/cli-tools/jcode-settings", // GET calls getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified

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 };
}
/**

View File

@@ -43,6 +43,7 @@
"tap": {
"testFiles": [
"tests/unit/account-fallback-anthropic-quota.test.ts",
"tests/unit/account-fallback-lockout-eviction.test.ts",
"tests/unit/account-fallback-retry-after-json.test.ts",
"tests/unit/account-fallback-route-restriction-403.test.ts",
"tests/unit/account-fallback-service.test.ts",
@@ -100,6 +101,9 @@
"tests/unit/circuit-breaker-failure-kind.test.ts",
"tests/unit/circuit-breaker-registry-cap.test.ts",
"tests/unit/circuit-breaker-stream-controller-4602.test.ts",
"tests/unit/cliproxyapi-dedicated-credential-7645.test.ts",
"tests/unit/combo-least-used-account.test.ts",
"tests/unit/combo/recovery-hint.test.ts",
"tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts",
"tests/unit/claude-code-parity.test.ts",
"tests/unit/claude-effort-suffix-strip.test.ts",
@@ -231,6 +235,7 @@
"tests/unit/responses-handler.test.ts",
"tests/unit/rotation-config-omniroute.test.ts",
"tests/unit/route-explainability.test.ts",
"tests/unit/route-guard-forge-jcode-settings-local-only.test.ts",
"tests/unit/route-guard-grok-build-settings-local-only.test.ts",
"tests/unit/route-guard-middleware-local-only.test.ts",
"tests/unit/route-guard-plugins-local-only.test.ts",

View File

@@ -746,8 +746,8 @@
}
},
"url": {
"nonStream": "https://api.chutesai.com/v1/chat/completions",
"stream": "https://api.chutesai.com/v1/chat/completions"
"nonStream": "https://llm.chutes.ai/v1/chat/completions",
"stream": "https://llm.chutes.ai/v1/chat/completions"
}
},
"claude": {

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
// Import BEFORE mocking global.fetch — open-sse/utils/proxyFetch.ts overwrites
// globalThis.fetch as a module-load side effect, so a mock installed before the
// import gets clobbered (same pattern as tests/unit/provider-models-qwen-web-redirect-6267.test.ts).
const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts");
const originalFetch = globalThis.fetch;
test.after(() => {
globalThis.fetch = originalFetch;
});
test("should_not_report_Invalid_when_lmarena_models_probe_307_redirects", async () => {
const fetchCalls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
fetchCalls.push(url);
return new Response(null, { status: 307, headers: { location: "https://arena.ai/" } });
}) as typeof fetch;
const result = await validateWebCookieProvider({
provider: "lmarena",
apiKey: "arena_session=abc123",
providerSpecificData: {},
});
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0], "https://arena.ai/nextjs-api/stream/create-evaluation/models");
assert.equal(result.valid, false);
assert.equal(
result.unsupported,
true,
"BUG #7542: current code returns unsupported:false — dashboard renders hard Invalid"
);
});
test("should_still_report_SESSION_EXPIRED_for_lmarena_401", async () => {
globalThis.fetch = (async () => {
return new Response(null, { status: 401 });
}) as typeof fetch;
const result = await validateWebCookieProvider({
provider: "lmarena",
apiKey: "arena_session=abc123",
providerSpecificData: {},
});
assert.equal(result.valid, false);
assert.equal(result.unsupported, false);
assert.equal((result as { error?: string }).error, "SESSION_EXPIRED");
});

View File

@@ -0,0 +1,88 @@
// Regression guard for #7226: API-only smoke/nightly workflows must build with
// OMNIROUTE_BUILD_BACKEND_ONLY=1 so `npm run build:cli`'s fallback full build
// (scripts/build/prepublish.ts -> build-next-isolated.mjs) skips the ~126-leaf-page
// dashboard UI graph these workflows never exercise. Without this env var, the
// "Build CLI bundle" step silently runs a full Next.js production build inline,
// which is the actual source of the multi-minute variance/timeouts reported in #7226.
//
// npm-publish.yml is intentionally excluded: its "Build CLI bundle (standalone app)"
// step legitimately ships the full dashboard UI in the published npm package.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
interface WorkflowStep {
name?: string;
run?: string;
env?: Record<string, string>;
[key: string]: unknown;
}
interface WorkflowJob {
steps: WorkflowStep[];
[key: string]: unknown;
}
interface WorkflowDoc {
jobs: Record<string, WorkflowJob>;
[key: string]: unknown;
}
const WORKFLOWS_DIR = path.join(process.cwd(), ".github", "workflows");
function loadWorkflow(fileName: string): WorkflowDoc {
const raw = fs.readFileSync(path.join(WORKFLOWS_DIR, fileName), "utf8");
return yaml.load(raw) as WorkflowDoc;
}
function isBackendOnly(step: WorkflowStep): boolean {
const env = step.env || {};
return env.OMNIROUTE_BUILD_BACKEND_ONLY === "1" || env.OMNIROUTE_BUILD_PROFILE === "backend";
}
// jobName: null selector means "any job" — used when a file has exactly one
// "Build CLI bundle" step but we don't want to hardcode/duplicate the job key.
interface Target {
file: string;
jobName: string;
stepName: string;
}
const TARGETS: Target[] = [
{ file: "dast-smoke.yml", jobName: "dast-smoke", stepName: "Build CLI bundle" },
{ file: "nightly-schemathesis.yml", jobName: "schemathesis", stepName: "Build CLI bundle" },
{ file: "nightly-resilience.yml", jobName: "k6-soak", stepName: "Build CLI bundle" },
{ file: "nightly-llm-security.yml", jobName: "promptfoo-guard", stepName: "Build CLI bundle" },
{ file: "nightly-llm-security.yml", jobName: "garak", stepName: "Build CLI bundle" },
];
for (const { file, jobName, stepName } of TARGETS) {
test(`${file} :: ${jobName} '${stepName}' step sets OMNIROUTE_BUILD_BACKEND_ONLY=1 (skips dashboard UI build the API-only smoke job never exercises)`, () => {
const doc = loadWorkflow(file);
const job = doc.jobs[jobName];
assert.ok(job, `${file} must have a '${jobName}' job`);
const step = job.steps.find((s) => s.name === stepName);
assert.ok(step, `${file}'s '${jobName}' job must have a '${stepName}' step`);
assert.equal(
isBackendOnly(step),
true,
`${file}'s '${jobName}' -> '${stepName}' step must set OMNIROUTE_BUILD_BACKEND_ONLY=1 or OMNIROUTE_BUILD_PROFILE=backend`
);
});
}
test("npm-publish.yml 'Build CLI bundle (standalone app)' step must NOT be backend-only (it legitimately ships the full dashboard UI)", () => {
const doc = loadWorkflow("npm-publish.yml");
const publishJob = Object.values(doc.jobs).find((job) =>
job.steps.some((s) => s.name === "Build CLI bundle (standalone app)")
);
assert.ok(publishJob, "npm-publish.yml must have a job with a 'Build CLI bundle (standalone app)' step");
const step = publishJob!.steps.find((s) => s.name === "Build CLI bundle (standalone app)")!;
assert.equal(
isBackendOnly(step),
false,
"npm-publish.yml's build step must ship the full dashboard UI, not the backend-only stub"
);
});

View File

@@ -107,8 +107,8 @@ test("buildCallLogListRows adds providerDisplay to active and completed in-memor
],
});
const pending = rows.find((row: any) => row.id === "pending-1");
const completed = rows.find((row: any) => row.id === "completed-1");
const pending = rows.find((row) => row.id === "pending-1");
const completed = rows.find((row) => row.id === "completed-1");
assert.equal(pending?.providerDisplay, "Bynara");
assert.equal(completed?.providerDisplay, "Bynara");

View File

@@ -0,0 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chutesProvider } from "../../open-sse/config/providers/registry/chutes/index.ts";
test("#7621: chutes registry baseUrl must use the resolvable llm.chutes.ai domain", () => {
assert.equal(
chutesProvider.baseUrl,
"https://llm.chutes.ai/v1/chat/completions",
"chutesProvider.baseUrl must point at the resolvable llm.chutes.ai host, not the " +
"non-resolving api.chutesai.com"
);
});

View File

@@ -0,0 +1,70 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { CLAUDE_WEB_FINGERPRINT } from "../../open-sse/config/claudeWebFingerprint.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..", "..");
const readSource = (rel: string) => readFileSync(join(REPO_ROOT, rel), "utf8");
function detectPlatform(ua: string): string {
if (/Windows/i.test(ua)) return "Windows";
if (/Macintosh|Mac OS X/i.test(ua)) return "macOS";
if (/X11; Linux/i.test(ua)) return "Linux";
return "unknown";
}
const IMPORT_PATTERN = /import\s*\{\s*CLAUDE_WEB_FINGERPRINT[^}]*\}\s*from\s*"[^"]*config\/claudeWebFingerprint\.ts"/;
test("claude-web: Turnstile solver derives its UA from the shared fingerprint module (#7548)", () => {
const solverSrc = readSource("open-sse/services/claudeTurnstileSolver.ts");
assert.ok(
IMPORT_PATTERN.test(solverSrc),
"claudeTurnstileSolver.ts must import CLAUDE_WEB_FINGERPRINT from the shared module"
);
assert.match(solverSrc, /userAgent:\s*CLAUDE_WEB_FINGERPRINT\.userAgent/);
// No stray hardcoded UA literal left behind (that's exactly how #7548 regressed).
assert.ok(
!/Mozilla\/5\.0[^"]*"/.test(solverSrc),
"solver must not hardcode a UA literal anymore — it must come from CLAUDE_WEB_FINGERPRINT"
);
});
test("claude-web: executor CLAUDE_USER_AGENT and Sec-Ch-Ua-Platform derive from the shared fingerprint (#7548)", () => {
const executorSrc = readSource("open-sse/executors/claude-web.ts");
assert.ok(
IMPORT_PATTERN.test(executorSrc),
"claude-web.ts must import CLAUDE_WEB_FINGERPRINT from the shared module"
);
assert.match(executorSrc, /const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT\.userAgent;/);
assert.match(executorSrc, /"Sec-Ch-Ua-Platform":\s*CLAUDE_WEB_FINGERPRINT\.secChUaPlatform/);
});
test("claude-web: httpBackedChat fast path uses the shared fingerprint for the claude.ai branch (#7548)", () => {
const fastPathSrc = readSource("open-sse/services/browserBackedChat.ts");
const resolverSrc = readSource("open-sse/services/httpBackedChatFingerprint.ts");
assert.match(
fastPathSrc,
/resolveHttpBackedChatFingerprint/,
"browserBackedChat.ts must resolve headers via resolveHttpBackedChatFingerprint()"
);
assert.ok(
IMPORT_PATTERN.test(resolverSrc),
"httpBackedChatFingerprint.ts must import CLAUDE_WEB_FINGERPRINT from the shared module"
);
assert.match(resolverSrc, /chatUrlMatchDomain === "claude\.ai" \? CLAUDE_WEB_FINGERPRINT/);
});
test("claude-web: solver, executor and fast-path UAs are all the same value at runtime, platform Linux (#7548)", () => {
// This is the actual regression: before the fix these three literals were
// "Windows", "Linux" and "macOS" respectively — a cf_clearance token minted
// under one UA got replayed under a different one and Cloudflare rejected
// it, surfacing as a persistent 429 on every claude-web request.
assert.equal(detectPlatform(CLAUDE_WEB_FINGERPRINT.userAgent), "Linux");
assert.equal(CLAUDE_WEB_FINGERPRINT.secChUaPlatform, '"Linux"');
});

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const BIN = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"bin",
"omniroute.mjs"
);
function runCli(dataDir: string): { code: number | null; stdout: string; stderr: string } {
const cleanEnv = { ...process.env };
delete cleanEnv.STORAGE_ENCRYPTION_KEY;
delete cleanEnv.JWT_SECRET;
delete cleanEnv.API_KEY_SECRET;
delete cleanEnv.DATA_DIR;
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-home-"));
try {
const res = spawnSync("node", [BIN, "config", "list", "--json"], {
cwd: dataDir,
env: {
...cleanEnv,
DATA_DIR: dataDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
NO_UPDATE_NOTIFIER: "1",
OMNIROUTE_CLI_SKIP_REPO_ENV: "1",
},
timeout: 60_000,
encoding: "utf-8",
});
return { code: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
} finally {
fs.rmSync(isolatedHome, { recursive: true, force: true });
}
}
// #7302: Electron persists secrets to <DATA_DIR>/server.env (electron/main.js), but the CLI
// (bin/omniroute.mjs) only ever loaded <DATA_DIR>/.env — so migrating storage.sqlite +
// server.env from the desktop app to the CLI silently lost STORAGE_ENCRYPTION_KEY and
// permanently corrupted every encrypted credential. The CLI must recognize server.env as a
// legacy/migration fallback source when .env is absent, without letting it override an
// existing .env.
test("#7302: CLI must recognize DATA_DIR/server.env (Electron's secrets file) when migrating an existing database", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-"));
try {
fs.writeFileSync(path.join(dir, "storage.sqlite"), "fake-existing-db-with-real-data");
const electronKey = "electron-storage-key-0123456789abcdef0123456789abcdef";
fs.writeFileSync(
path.join(dir, "server.env"),
[
"JWT_SECRET=electron-jwt-secret-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"API_KEY_SECRET=electron-api-key-secret-bbbbbbbbbbbbbbbbbbbbbbbbbbbb",
`STORAGE_ENCRYPTION_KEY=${electronKey}`,
"STORAGE_ENCRYPTION_KEY_VERSION=v1",
"",
].join("\n")
);
const { stderr } = runCli(dir);
const envPath = path.join(dir, ".env");
const envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
assert.match(
envContent,
new RegExp(`STORAGE_ENCRYPTION_KEY=${electronKey}`),
"the Electron-persisted STORAGE_ENCRYPTION_KEY from server.env must be honored " +
"after migrating to the CLI install — got .env content: " + JSON.stringify(envContent)
);
assert.doesNotMatch(
stderr,
/STORAGE_ENCRYPTION_KEY is not set but a database already exists/,
"the CLI should not need to refuse key generation — it should have found the " +
"Electron-persisted key in server.env"
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("#7302: an existing DATA_DIR/.env must still win over DATA_DIR/server.env when both exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-winner-"));
try {
fs.writeFileSync(path.join(dir, "storage.sqlite"), "fake-existing-db-with-real-data");
const cliKey = "cli-owned-storage-key-fedcba9876543210fedcba9876543210";
const electronKey = "electron-storage-key-0123456789abcdef0123456789abcdef";
fs.writeFileSync(path.join(dir, ".env"), `STORAGE_ENCRYPTION_KEY=${cliKey}\n`);
fs.writeFileSync(path.join(dir, "server.env"), `STORAGE_ENCRYPTION_KEY=${electronKey}\n`);
runCli(dir);
const envContent = fs.readFileSync(path.join(dir, ".env"), "utf-8");
assert.match(
envContent,
new RegExp(`STORAGE_ENCRYPTION_KEY=${cliKey}`),
"an existing .env must never be overwritten by server.env"
);
assert.doesNotMatch(
envContent,
new RegExp(electronKey),
"server.env must not leak into an existing .env"
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, cpSync, symlinkSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(HERE, "..", "..");
test("config-generator/opencode.ts imports cleanly with no tsconfig.json in scope (repro #7682)", () => {
const stage = mkdtempSync(join(tmpdir(), "omniroute-pkg-stage-7682-"));
try {
for (const rel of ["bin", "src/lib", "src/shared"]) {
cpSync(join(REPO_ROOT, rel), join(stage, rel), { recursive: true });
}
cpSync(join(REPO_ROOT, "package.json"), join(stage, "package.json"));
symlinkSync(join(REPO_ROOT, "node_modules"), join(stage, "node_modules"), "dir");
const probeScript = join(stage, "probe-import.mjs");
writeFileSync(
probeScript,
`await import("tsx/esm");
await import("./src/lib/cli-helper/config-generator/opencode.ts");
console.log("IMPORT_OK");
`
);
const result = spawnSync(process.execPath, [probeScript], { cwd: stage, encoding: "utf8" });
assert.equal(
result.stdout.includes("IMPORT_OK"),
true,
`expected config-generator/opencode.ts to import cleanly from a tsconfig-less ` +
`directory (as it will inside a real global npm install), but it failed:\n` +
`stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
} finally {
rmSync(stage, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,196 @@
/**
* Regression tests for #7645 — CLIProxyAPI fallback/passthrough legs reused
* the failed native provider's own credential as the Authorization header
* sent to CLIProxyAPI, which requires its own dedicated `api-keys:`
* credential and rejects any other token with 401 — a permanent no-op for
* every provider configured with `mode: "fallback"` or `mode: "cliproxyapi"`.
*
* All tests exercise REAL production functions end-to-end:
* - updateSettings / getSettings (src/lib/db/settings.ts)
* - upsertUpstreamProxyConfig (src/lib/db/upstreamProxy.ts)
* - resolveExecutorWithProxy (open-sse/handlers/chatCore/executorProxy.ts)
* - CliproxyapiExecutor.execute (open-sse/executors/cliproxyapi.ts)
* `globalThis.fetch` is stubbed only to capture the outbound wire headers,
* distinguishing the native-provider host from the CLIProxyAPI host
* (127.0.0.1:8317).
*/
import { describe, it, before, after, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-7645-cpa-cred-"));
process.env.DATA_DIR = testDataDir;
const coreDb = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
const { resolveExecutorWithProxy } = await import(
"../../open-sse/handlers/chatCore/executorProxy.ts"
);
const { clearUpstreamProxyConfigCache } = await import(
"../../open-sse/handlers/chatCore/comboContextCache.ts"
);
const { updateSettingsSchema } = await import("../../src/shared/validation/settingsSchemas.ts");
const NATIVE_KEY = "sk-native-provider-key-cliproxyapi-must-not-see";
const DEDICATED_KEY = "cpa-dedicated-key-configured-by-operator";
before(async () => {
await coreDb.ensureDbInitialized();
});
afterEach(async () => {
clearUpstreamProxyConfigCache();
const { dbCache } = await import("../../src/lib/db/readCache.ts");
dbCache?.invalidate?.("settings");
});
after(() => {
coreDb.resetDbInstance();
if (fs.existsSync(testDataDir)) fs.rmSync(testDataDir, { recursive: true, force: true });
});
type ExecuteInput = {
model: string;
body: unknown;
stream: boolean;
credentials: unknown;
};
type ExecutorLike = { execute: (input: ExecuteInput) => Promise<unknown> };
/**
* Stubs fetch so calls to CLIProxyAPI's host (127.0.0.1:8317) are captured
* (headers + succeed with 200), while calls to any other host throw a
* simulated native-provider network failure — driving the "fallback" retry
* leg for real.
*/
async function withCapturedCliproxyapiRequest(
fn: () => Promise<unknown>
): Promise<{ headers: Record<string, string>; called: boolean }> {
let capturedHeaders: Record<string, string> | null = null;
const originalFetch = globalThis.fetch;
// @ts-expect-error test stub
globalThis.fetch = async (url: string, init: RequestInit) => {
if (String(url).includes("8317")) {
capturedHeaders = init.headers as Record<string, string>;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
throw new Error("simulated native provider network failure");
};
try {
await fn();
} finally {
globalThis.fetch = originalFetch;
}
return { headers: capturedHeaders ?? {}, called: capturedHeaders !== null };
}
describe("#7645 — settingsSchemas has a dedicated cliproxyapi_api_key field", () => {
it("updateSettingsSchema accepts cliproxyapi_api_key", () => {
const shape = (updateSettingsSchema as unknown as { shape: Record<string, unknown> }).shape;
assert.equal(
Object.prototype.hasOwnProperty.call(shape, "cliproxyapi_api_key"),
true,
"settingsSchemas.ts must define a dedicated cliproxyapi_api_key field"
);
});
});
describe("#7645 — CLIProxyAPI fallback leg authenticates with the dedicated key", () => {
it("uses the dedicated cliproxyapi_api_key, not the failed native provider's own credential", async () => {
await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_KEY });
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "openai-7645-fallback",
mode: "fallback",
enabled: true,
});
const executor = await resolveExecutorWithProxy("openai-7645-fallback", undefined, null);
const { headers, called } = await withCapturedCliproxyapiRequest(() =>
(executor as ExecutorLike).execute({
model: "gpt-4",
body: { model: "gpt-4", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: NATIVE_KEY },
})
);
assert.equal(called, true, "the CLIProxyAPI retry leg must have been invoked");
assert.equal(
headers.Authorization,
`Bearer ${DEDICATED_KEY}`,
"CLIProxyAPI fallback leg must authenticate with the dedicated key"
);
assert.notEqual(
headers.Authorization,
`Bearer ${NATIVE_KEY}`,
"CLIProxyAPI fallback leg must not reuse the failed native provider's own credential"
);
});
it("direct cliproxyapi passthrough mode also uses the dedicated key", async () => {
await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_KEY });
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "anthropic-7645-passthrough",
mode: "cliproxyapi",
enabled: true,
});
const executor = await resolveExecutorWithProxy("anthropic-7645-passthrough", undefined, null);
const { headers, called } = await withCapturedCliproxyapiRequest(() =>
(executor as ExecutorLike).execute({
model: "claude-3-opus",
body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: NATIVE_KEY },
})
);
assert.equal(called, true, "the CLIProxyAPI passthrough leg must have been invoked");
assert.equal(
headers.Authorization,
`Bearer ${DEDICATED_KEY}`,
"CLIProxyAPI passthrough mode must authenticate with the dedicated key"
);
});
it("falls back to the connection's own credential when no dedicated key is configured (no regression)", async () => {
await settingsDb.updateSettings({ cliproxyapi_api_key: "" });
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "anthropic-7645-no-dedicated-key",
mode: "cliproxyapi",
enabled: true,
});
const executor = await resolveExecutorWithProxy(
"anthropic-7645-no-dedicated-key",
undefined,
null
);
const { headers, called } = await withCapturedCliproxyapiRequest(() =>
(executor as ExecutorLike).execute({
model: "claude-3-opus",
body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: NATIVE_KEY },
})
);
assert.equal(called, true);
assert.equal(
headers.Authorization,
`Bearer ${NATIVE_KEY}`,
"with no dedicated key configured, the pre-existing (workaround) behavior must be preserved"
);
});
});

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
// The credential-health sweep re-probes web-session connections and recovers ones
// whose cookies expired (the "*-web providers go red on restart" bug). scheduler.ts
// only auto-inits when something imports it; nothing did at boot, so the sweep never
// ran proactively at startup. This guards that the wiring lives in the REAL startup
// (src/instrumentation-node.ts) and NOT in the unused src/server-init.ts — the exact
// mistake that made the earlier attempt (closed PR #7432) a no-op.
const read = (rel: string) =>
readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
const instrumentation = read("../../src/instrumentation-node.ts");
test("credential health scheduler is started from the real Next.js instrumentation startup", () => {
assert.match(
instrumentation,
/import\(["']@\/lib\/credentialHealth\/scheduler["']\)/,
"instrumentation-node.ts must import the scheduler at boot"
);
assert.match(
instrumentation,
/initCredentialHealthCheck\(\)/,
"instrumentation-node.ts must call initCredentialHealthCheck() at boot"
);
assert.match(
instrumentation,
/\[STARTUP\] Credential health scheduler started/,
"a [STARTUP] log line proves the boot wiring ran (grep-able in app.log)"
);
});
test("the wiring is NOT placed in the dead src/server-init.ts (the #7432 no-op)", () => {
const url = new URL("../../src/server-init.ts", import.meta.url);
const p = fileURLToPath(url);
if (!existsSync(p)) return; // file removed upstream → nothing to guard
assert.doesNotMatch(
readFileSync(p, "utf8"),
/initCredentialHealthCheck/,
"server-init.ts is unused in production; wiring there never runs"
);
});

View File

@@ -22,6 +22,21 @@ test("parseCursorAgentModels deduplicates and trims", () => {
assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]);
});
test("parseCursorAgentModels parses the multiline output from the models command", () => {
const text = `Available models
auto - Auto (default)
gpt-5.3-codex - Codex 5.3
claude-opus-4-8-thinking-high-fast - Opus 4.8 1M Thinking Fast
Tip: use --model <id> to switch.`;
assert.deepEqual(parseCursorAgentModels(text), [
"auto",
"gpt-5.3-codex",
"claude-opus-4-8-thinking-high-fast",
]);
});
test("parseCursorAgentModels returns [] when the marker is missing", () => {
assert.deepEqual(parseCursorAgentModels("nothing here"), []);
});
@@ -40,7 +55,10 @@ test("humanizeCursorModelId pretty-prints common patterns", () => {
humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"),
"Claude Opus 4.8 Thinking High Fast"
);
assert.equal(humanizeCursorModelId("claude-fable-5-thinking-xhigh"), "Claude Fable 5 Thinking XHigh");
assert.equal(
humanizeCursorModelId("claude-fable-5-thinking-xhigh"),
"Claude Fable 5 Thinking XHigh"
);
assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max");
assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5");
assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro");

View File

@@ -25,6 +25,8 @@ describe("NotionWebExecutor — registry consistency", () => {
const models = getModelsByProviderId("notion-web");
assert.ok(models.length >= 1);
assert.ok(models.some((m) => m.id === "notion-ai"));
// Seed catalog includes real Notion codenames (live discovery still preferred).
assert.ok(models.some((m) => m.id === "ambrosia-tart-high" || m.id === "orange-mousse"));
});
});
@@ -93,6 +95,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
assert.equal(capturedUrl, "https://www.notion.so/api/v3/runInferenceTranscript");
assert.equal(capturedHeaders.Cookie, "token_v2=abc123");
assert.ok(capturedBody);
// notion-ai default does not inject a config entry (server-side default model).
assert.equal(capturedBody.transcript[0].type, "human");
assert.deepEqual(capturedBody.transcript[0].value, [["hi"]]);
@@ -110,6 +113,34 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
}
});
it("injects a config transcript entry with the selected Notion model codename", async () => {
const executor = new mod.NotionWebExecutor();
let capturedBody: { transcript: Array<{ type: string; value?: { model?: string } }> } | null =
null;
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
capturedBody = JSON.parse(String(opts.body));
return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 });
}) as typeof fetch;
await executor.execute({
model: "orange-mousse",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=xyz; space_id=space-1" },
signal: null,
} as never);
assert.ok(capturedBody);
assert.equal(capturedBody.transcript[0].type, "config");
assert.equal(capturedBody.transcript[0].value?.model, "orange-mousse");
assert.equal(capturedBody.transcript[1].type, "human");
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts a full cookie header verbatim (already containing token_v2=)", async () => {
const executor = new mod.NotionWebExecutor();
let capturedHeaders: Record<string, string> = {};

View File

@@ -0,0 +1,93 @@
// Repro probe for issue #7676:
// gemini-web executor never reads back the live Playwright cookie jar after a
// successful run, so rotated __Secure-1PSIDTS / __Secure-1PSIDCC values are
// never persisted via onCredentialsRefreshed — unlike chatgpt-web.ts, which
// already forwards its rotated cookie through the same callback
// (open-sse/executors/chatgpt-web.ts:2843).
import test from "node:test";
import assert from "node:assert/strict";
const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts");
test("#7676: GeminiWebExecutor persists rotated __Secure-1PSIDTS/__Secure-1PSIDCC via onCredentialsRefreshed after a successful run", async () => {
const playwright = await import("playwright");
const originalLaunch = playwright.chromium.launch;
const staleCookie =
"__Secure-1PSID=abc123; __Secure-1PSIDTS=OLD_TS_VALUE; __Secure-1PSIDCC=OLD_CC_VALUE";
const rotatedJarCookies = [
{ name: "__Secure-1PSID", value: "abc123", domain: ".google.com", path: "/" },
{ name: "__Secure-1PSIDTS", value: "ROTATED_TS_VALUE", domain: ".google.com", path: "/" },
{ name: "__Secure-1PSIDCC", value: "ROTATED_CC_VALUE", domain: ".google.com", path: "/" },
];
playwright.chromium.launch = async () =>
({
newContext: async () => ({
addCookies: async () => {},
cookies: async () => rotatedJarCookies,
newPage: async () => ({
on: (event: string, handler: (resp: { url: () => string; text: () => Promise<string> }) => void) => {
if (event === "response") {
const body =
")]}'\n" +
"30\n" +
JSON.stringify([
[
"wrb.fr",
null,
JSON.stringify([null, null, null, null, [[null, ["hello back"]]]]),
],
]) +
"\n";
handler({
url: () =>
"https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
text: async () => body,
});
}
},
goto: async () => {},
waitForTimeout: async () => {},
waitForSelector: async () => ({ click: async () => {} }),
keyboard: { type: async () => {}, press: async () => {} },
}),
}),
close: async () => {},
}) as unknown as typeof originalLaunch;
let persistedCredentials: Record<string, unknown> | null = null;
try {
const executor = new GeminiWebExecutor();
const result = await executor.execute({
model: "gemini-3.1-pro",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: staleCookie },
signal: AbortSignal.timeout(5000),
log: null,
onCredentialsRefreshed: async (newCreds: Record<string, unknown>) => {
persistedCredentials = newCreds;
},
} as unknown as Parameters<InstanceType<typeof GeminiWebExecutor>["execute"]>[0]);
assert.equal(result.response.status, 200, "run should succeed with a Gemini response");
assert.ok(
persistedCredentials,
"onCredentialsRefreshed must be called so the rotated cookie jar is persisted to provider_connections (#7676)"
);
assert.ok(
typeof persistedCredentials.apiKey === "string" &&
persistedCredentials.apiKey.includes("ROTATED_TS_VALUE"),
`persisted apiKey must contain the rotated __Secure-1PSIDTS value, got: ${persistedCredentials?.apiKey}`
);
assert.ok(
persistedCredentials.apiKey.includes("ROTATED_CC_VALUE"),
`persisted apiKey must contain the rotated __Secure-1PSIDCC value, got: ${persistedCredentials?.apiKey}`
);
} finally {
playwright.chromium.launch = originalLaunch;
}
});

View File

@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
// #7610 bug #2: `grok-cli` was absent from OAUTH_TEST_CONFIG in
// src/app/api/providers/[id]/test/route.ts, so "Test Connection" for a Grok
// Build (OAuth) connection always fell through to the generic
// "Provider test not supported" branch, regardless of whether the token was
// actually healthy.
const { testOAuthConnection } = await import("../../src/app/api/providers/[id]/test/route.ts");
test("#7610: grok-cli OAuth connection test is no longer 'unsupported'", async () => {
const connection = {
provider: "grok-cli",
accessToken: "healthy-access-token",
refreshToken: "healthy-refresh-token",
// Far in the future — not expired, so this exercises the checkExpiry
// "still valid" branch rather than the refresh path.
tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(),
};
const result = await testOAuthConnection(connection);
assert.notEqual(result.diagnosis?.type, "unsupported");
assert.notEqual(result.error, "Provider test not supported");
assert.equal(result.valid, true);
});

View File

@@ -0,0 +1,64 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
import type { ExecuteInput, ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts";
type TestableGrokCliExecutor = {
execute: (input: ExecuteInput) => Promise<{ response: Response }>;
refreshCredentials: (
credentials: ProviderCredentials,
log?: ExecutorLog | null
) => Promise<Partial<ProviderCredentials> | null>;
nativePost: (
url: string,
headers: Record<string, string>,
bodyStr: string,
signal?: AbortSignal | null
) => Promise<Response>;
};
test("GrokCliExecutor.execute() proactively refreshes an expired access token (#7610)", async () => {
const executor = new GrokCliExecutor() as unknown as TestableGrokCliExecutor;
// Stub the real network call (nativeHttpsPost → auth.x.ai) so the test never
// touches the network — only the wiring (does execute() call
// refreshCredentials() at all, and does the refreshed token reach the
// outgoing Authorization header) is under test here.
let refreshCalled = false;
executor.refreshCredentials = async () => {
refreshCalled = true;
return {
accessToken: "FRESH_ACCESS_TOKEN",
refreshToken: "rotated-refresh-token",
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
};
};
let capturedHeaders: Record<string, string> | null = null;
executor.nativePost = async (_url, headers) => {
capturedHeaders = headers;
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
const expiredAt = new Date(Date.now() - 60_000).toISOString();
const credentials: ProviderCredentials = {
accessToken: "STALE_ACCESS_TOKEN",
refreshToken: "valid-refresh-token",
expiresAt: expiredAt,
};
await executor.execute({
model: "grok-composer-2.5-fast",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials,
} as ExecuteInput);
assert.equal(
refreshCalled,
true,
"expected GrokCliExecutor.execute() to proactively call refreshCredentials()"
);
assert.notEqual(capturedHeaders?.["Authorization"], "Bearer STALE_ACCESS_TOKEN");
assert.equal(capturedHeaders?.["Authorization"], "Bearer FRESH_ACCESS_TOKEN");
});

View File

@@ -5,8 +5,7 @@ import test from "node:test";
import assert from "node:assert/strict";
const B = await import("../../src/lib/providers/validation/webProvidersB.ts");
const extract = (raw: string) =>
(B as Record<string, any>).extractM365CredentialParts(raw, {});
const extract = (raw: string) => B.extractM365CredentialParts(raw, {});
test("#7078 m365.cloud.microsoft wss URL extracts access_token + chathubPath", () => {
const raw =

View File

@@ -0,0 +1,113 @@
import { test } 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 { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
// Regression guard for issue #7701.
//
// dist/open-sse/mcp-server/server.js is produced by:
// esbuild open-sse/mcp-server/server.ts --bundle --platform=node
// --packages=external --format=esm --outfile=dist/open-sse/mcp-server/server.js
// (scripts/build/prepublish.ts, Step 8.5)
//
// `--packages=external` means every bare-specifier `import ... from "pkg"` in the
// MCP server's source graph survives UNBUNDLED in the compiled output. Node's ESM
// loader resolves every one of those STATIC top-level imports at *module-link
// time* -- before any of the MCP server's own code (including its startup log
// lines) executes.
//
// Separately, `dist/node_modules/` is populated by Next.js's standalone output
// file tracer (nft), which walks the compiled Next.js app's require graph. nft is
// known (and separately documented in this repo -- see the #6559 comment in
// src/shared/utils/rateLimiter.ts, plus the sqlite-vec/#3066, tls-options/#5452,
// head-response-guard/#7065, and @swc/helpers precedents in assembleStandalone.mjs)
// to sometimes emit a HOLLOW `dist/node_modules/<pkg>/` directory containing only
// package.json, no code. Node's module resolution stops at the FIRST
// node_modules/<pkg> directory found while walking up from the importer -- so a
// hollow dist/node_modules/<pkg> SHADOWS the fully-populated sibling
// node_modules/<pkg> that npm installed for the published package, and the MCP
// server crashes with:
// Error: Cannot find package '.../dist/node_modules/undici/index.js'
//
// The project's existing mitigation for this bug class is an explicit copy
// guarantee (EXTRA_MODULE_ENTRIES / NATIVE_ASSET_ENTRIES in assembleStandalone.mjs)
// that force-overwrites whatever nft did with a full copy from the sibling
// node_modules. This test proves that `undici` -- a real, static, top-level
// external import of the actual esbuild-compiled MCP server bundle -- has NO such
// guarantee, so a hollow nft trace for it is packaging-fatal.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const ASSEMBLE = path.join(ROOT, "scripts", "build", "assembleStandalone.mjs");
const NODE_BUILTINS = new Set([
"assert", "async_hooks", "buffer", "child_process", "crypto", "dns", "events",
"fs", "http", "https", "module", "net", "os", "path", "stream", "tls", "url",
"util", "worker_threads", "zlib",
]);
function isBuiltin(pkg: string): boolean {
return pkg.startsWith("node:") || NODE_BUILTINS.has(pkg);
}
/** Bare-specifier packages that survive as STATIC top-level imports in the real,
* esbuild-compiled (--packages=external) MCP server bundle. */
function mcpBundleStaticExternalImports(): string[] {
const outFile = path.join(
os.tmpdir(),
`omniroute-mcp-server-probe-${process.pid}-${Date.now()}.js`
);
try {
execFileSync(
"npx",
[
"esbuild", "open-sse/mcp-server/server.ts", "--bundle", "--platform=node",
"--packages=external", "--format=esm", `--outfile=${outFile}`,
],
{ cwd: ROOT, stdio: ["ignore", "ignore", "inherit"] }
);
const src = fs.readFileSync(outFile, "utf8");
const re = /^import\s+(?:[^;]*?\s+from\s+)?["']([^."][^"']*)["'];?$/gm;
const pkgs = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(src))) {
const spec = m[1];
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
if (!isBuiltin(name)) pkgs.add(name);
}
return [...pkgs].sort();
} finally {
fs.rmSync(outFile, { force: true });
}
}
function explicitlyGuaranteedPackages(): Set<string> {
const text = fs.readFileSync(ASSEMBLE, "utf8");
const entries = [...text.matchAll(/src:\s*\[([^\]]+)\]/gs)];
const guaranteed = new Set<string>();
for (const [, segList] of entries) {
const segments = segList.split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean);
if (segments[0] !== "node_modules") continue;
const pkg = segments[1]?.startsWith("@") ? `${segments[1]}/${segments[2]}` : segments[1];
if (pkg) guaranteed.add(pkg);
}
return guaranteed;
}
test("sanity: MCP server bundle probe finds real external packages (parser didn't break)", () => {
const pkgs = mcpBundleStaticExternalImports();
assert.ok(pkgs.length > 5, `expected several external packages, got: ${pkgs.join(", ")}`);
assert.ok(pkgs.includes("better-sqlite3"), `missing better-sqlite3: ${pkgs.join(", ")}`);
});
test("undici (a static top-level external import of the real MCP server bundle) has an explicit dist/node_modules copy guarantee (#7701)", () => {
const staticExternals = mcpBundleStaticExternalImports();
assert.ok(
staticExternals.includes("undici"),
`expected undici among the MCP bundle's static external imports (sanity check on the repro itself): ${staticExternals.join(", ")}`
);
const guaranteed = explicitlyGuaranteedPackages();
assert.ok(guaranteed.has("undici"), "undici is statically imported at module-link time by the esbuild-compiled MCP server bundle but has NO explicit copy entry in EXTRA_MODULE_ENTRIES (scripts/build/assembleStandalone.mjs) ... (issue #7701).");
});

View File

@@ -80,7 +80,7 @@ test("provisionDnsEntries: a failing agent/custom step does not stop the others
},
addHostsDns: async (hosts: string[]) => {
// Custom-hosts call must still happen even after default + agent errors.
if (hosts.includes("custom.example.com")) customCalled = true;
if (hosts.some((h) => h === "custom.example.com")) customCalled = true;
},
getAgentStates: () => [{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never,
listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never,
@@ -205,8 +205,8 @@ test("provisionDnsEntries: canElevate() returning true proceeds with DNS provisi
},
addHostsDns: async (hosts: string[], sudoPassword: string) => {
capturedPasswords.push(sudoPassword);
if (hosts.some((h) => h.includes("googleapis.com"))) agentCalled = true;
if (hosts.includes("custom.example.com")) customCalled = true;
if (hosts.some((h) => h.endsWith(".googleapis.com"))) agentCalled = true;
if (hosts.some((h) => h === "custom.example.com")) customCalled = true;
},
canElevate: () => true,
getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never,

View File

@@ -0,0 +1,73 @@
/**
* #7620 — hiding a no-auth-provider model with the EYE icon (Dashboard → Models,
* `isHidden: true` written via setModelIsHidden()/mergeModelCompatOverride()) does
* remove it from `/v1/models`, but `getNoAuthCandidates()` in
* `open-sse/services/autoCombo/virtualFactory.ts` never consults
* `getHiddenModelsByProvider()` at all (unlike the credentialed-connection loop a
* few lines above it, which does). A hidden no-auth model therefore stays in the
* `auto/*` candidate pool and can still be selected, causing a 401 when the
* upstream account for that hidden model is no longer valid/allowed.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7620-noauth-hidden-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
test("#7620: a no-auth model hidden via the eye icon (isHidden:true) must be ABSENT from the auto-combo candidate pool", async () => {
modelsDb.setModelIsHidden("opencode", "mimo-v2.5-free", true);
const hiddenMap = modelsDb.getHiddenModelsByProvider();
assert.equal(
hiddenMap.get("opencode")?.has("mimo-v2.5-free"),
true,
"sanity: getHiddenModelsByProvider() must report opencode/mimo-v2.5-free as hidden"
);
const combo = await virtualFactory.createVirtualAutoCombo(undefined);
const modelStrings = combo.models.map((m: { model: string }) => m.model);
assert.ok(
!modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")),
"BUG #7620: the eye-hidden model 'mimo-v2.5-free' must not appear in the auto-combo " +
`candidate pool, but it did. Pool: ${JSON.stringify(modelStrings)}`
);
});
test("#7620 baseline: with nothing hidden, opencode/mimo-v2.5-free is present in the pool", async () => {
const combo = await virtualFactory.createVirtualAutoCombo(undefined);
const modelStrings = combo.models.map((m: { model: string }) => m.model);
assert.ok(
modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")),
`baseline: with nothing hidden, mimo-v2.5-free must be present. Pool: ${JSON.stringify(modelStrings)}`
);
});

View File

@@ -0,0 +1,182 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-notion-web-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const notionModels = await import("../../open-sse/services/notionWebModels.ts");
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
const SAMPLE_RESPONSE = {
models: [
{
model: "orange-mousse",
modelMessage: "GPT-5.6 Sol",
modelFamily: "openai",
isDisabled: false,
modelConfiguration: { supportedReasoningEfforts: ["medium", "high"] },
},
{
model: "ambrosia-tart-high",
modelMessage: "Opus 4.8",
modelFamily: "anthropic",
isDisabled: false,
},
{
model: "disabled-model",
modelMessage: "Hidden",
modelFamily: "openai",
isDisabled: true,
},
],
};
test("parseNotionAvailableModels maps enabled models and skips disabled", () => {
const models = notionModels.parseNotionAvailableModels(SAMPLE_RESPONSE);
assert.equal(
models.some((m) => m.id === "disabled-model"),
false
);
assert.ok(models.some((m) => m.id === "orange-mousse" && m.name === "GPT-5.6 Sol"));
assert.ok(models.some((m) => m.id === "ambrosia-tart-high" && m.name === "Opus 4.8"));
assert.ok(models.some((m) => m.id === "notion-ai"));
const sol = models.find((m) => m.id === "orange-mousse");
assert.equal(sol?.supportsReasoning, true);
assert.equal(sol?.owned_by, "openai");
});
test("parseNotionAvailableModels returns empty for invalid payloads", () => {
assert.deepEqual(notionModels.parseNotionAvailableModels(null), []);
assert.deepEqual(notionModels.parseNotionAvailableModels({}), []);
assert.deepEqual(notionModels.parseNotionAvailableModels({ models: "nope" }), []);
});
test("cookie helpers extract space_id and user id", () => {
const cookie =
"token_v2=abc; space_id=5e43fbd2-c09b-815a-8045-000311a1f620; notion_user_id=28bd872b-594c-81cb-9638-0002a411fd83";
assert.equal(
notionModels.extractSpaceIdFromNotionCookie(cookie),
"5e43fbd2-c09b-815a-8045-000311a1f620"
);
assert.equal(
notionModels.extractNotionUserIdFromCookie(cookie),
"28bd872b-594c-81cb-9638-0002a411fd83"
);
assert.equal(notionModels.normalizeNotionWebCookie("baretoken"), "token_v2=baretoken");
// CamelCase spaceId= must still resolve (case-insensitive name match).
assert.equal(
notionModels.extractSpaceIdFromNotionCookie("token_v2=x; spaceId=space-camel"),
"space-camel"
);
// Malformed % sequences must not throw.
assert.equal(notionModels.readCookieValue("token_v2=%E0%A4%A", "token_v2"), "%E0%A4%A");
});
test("pickFirstSpaceId reads nested getSpaces shape", () => {
const data = {
"user-1": {
space: {
"space-aaa": { name: "Work" },
"space-bbb": { name: "Personal" },
},
},
};
assert.equal(notionModels.pickFirstSpaceId(data), "space-aaa");
});
test("discoverNotionWebModels posts getAvailableModels with spaceId from cookie", async () => {
const calls: Array<{ url: string; body: string }> = [];
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
calls.push({ url: String(url), body: String(init?.body || "") });
return Response.json(SAMPLE_RESPONSE);
}) as typeof fetch;
const result = await notionModels.discoverNotionWebModels({
token: "token_v2=xyz; space_id=space-from-cookie",
fetchImpl,
});
assert.equal(calls.length, 1);
assert.equal(calls[0].url, notionModels.NOTION_MODELS_URL);
assert.equal(JSON.parse(calls[0].body).spaceId, "space-from-cookie");
assert.ok(result.models.some((m) => m.id === "orange-mousse"));
assert.equal(result.source, "api");
});
test("discoverNotionWebModels falls back to getSpaces when space_id missing", async () => {
const calls: string[] = [];
const fetchImpl = (async (url: string | URL) => {
calls.push(String(url));
if (String(url).includes("getSpaces")) {
return Response.json({
u1: { space: { "resolved-space": { name: "WS" } } },
});
}
return Response.json(SAMPLE_RESPONSE);
}) as typeof fetch;
const result = await notionModels.discoverNotionWebModels({
token: "token_v2=xyz",
fetchImpl,
});
assert.deepEqual(calls, [notionModels.NOTION_SPACES_URL, notionModels.NOTION_MODELS_URL]);
assert.equal(result.spaceId, "resolved-space");
assert.ok(result.models.length >= 2);
});
test("notion-web models route returns live getAvailableModels catalog", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "notion-web",
authType: "apikey",
name: "notion-web-discovery",
apiKey: "token_v2=sess; space_id=space-live-1",
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.includes("getAvailableModels")) {
assert.equal(init?.method, "POST");
const body = JSON.parse(String(init?.body || "{}"));
assert.equal(body.spaceId, "space-live-1");
const headers = init?.headers as Record<string, string>;
assert.match(String(headers.cookie || headers.Cookie || ""), /token_v2=sess/);
return Response.json(SAMPLE_RESPONSE);
}
return new Response("unexpected", { status: 500 });
}) as typeof globalThis.fetch;
try {
const response = await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, "api");
const ids = body.models.map((m: { id: string }) => m.id);
assert.ok(ids.includes("orange-mousse"));
assert.ok(ids.includes("ambrosia-tart-high"));
assert.equal(ids.includes("disabled-model"), false);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,59 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve, extname } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../");
function listSourceFiles(dir: string, exts: string[]): string[] {
const out: string[] = [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return out;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === ".git" || entry === ".source") continue;
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...listSourceFiles(full, exts));
else if (exts.includes(extname(entry))) out.push(full);
}
return out;
}
test("#7661 — fumadocs-mdx must not be a runtime dependency (npm install -g ETARGET exposure)", () => {
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
const deps: Record<string, string> = pkg.dependencies || {};
const devDeps: Record<string, string> = pkg.devDependencies || {};
assert.ok(
!("fumadocs-mdx" in deps),
'fumadocs-mdx is declared under "dependencies" — npm install -g omniroute fetches it ' +
"and its transitive yuku-analyzer/yuku-ast native-binding tree even though nothing at " +
'runtime imports it. Move it to "devDependencies".'
);
assert.ok(
"fumadocs-mdx" in devDeps || "fumadocs-mdx" in deps,
"fumadocs-mdx must remain declared somewhere"
);
const runtimeDirs = ["src", "open-sse", "bin"].map((d) => join(REPO_ROOT, d));
const runtimeFiles = runtimeDirs.flatMap((d) =>
listSourceFiles(d, [".ts", ".tsx", ".js", ".mjs", ".cjs"])
);
assert.ok(runtimeFiles.length > 100, "sanity: scanner should find the runtime source tree");
const offenders = runtimeFiles.filter((f) => {
const src = readFileSync(f, "utf8");
return /from\s+["']fumadocs-mdx(\/|["'])|require\(\s*["']fumadocs-mdx(\/|["'])/.test(src);
});
assert.deepEqual(
offenders,
[],
`fumadocs-mdx is imported at runtime by: ${offenders.join(", ")}`
);
});

View File

@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts";
test("#7617: stripUnsupportedParams strips prompt_cache_key for nvidia when present", () => {
const body: Record<string, unknown> = {
model: "some-nvidia-model",
prompt_cache_key: "codex-cli-session-abc123",
max_tokens: 512,
};
stripUnsupportedParams("nvidia", "some-nvidia-model", body);
assert.equal(
body.prompt_cache_key,
undefined,
"prompt_cache_key must be stripped for nvidia"
);
assert.equal(body.max_tokens, 512, "unrelated params must be preserved");
});
test("#7617: stripUnsupportedParams preserves prompt_cache_key for non-nvidia providers (e.g. openai)", () => {
const body: Record<string, unknown> = {
model: "gpt-5.4",
prompt_cache_key: "some-cache-key",
};
stripUnsupportedParams("openai", "gpt-5.4", body);
assert.equal(
body.prompt_cache_key,
"some-cache-key",
"prompt_cache_key must be preserved for non-nvidia providers"
);
});
test("#7617: stripUnsupportedParams is a no-op for nvidia when prompt_cache_key is absent", () => {
const body: Record<string, unknown> = {
model: "some-nvidia-model",
max_tokens: 256,
};
stripUnsupportedParams("nvidia", "some-nvidia-model", body);
assert.equal("prompt_cache_key" in body, false);
assert.equal(body.max_tokens, 256);
});

View File

@@ -39,7 +39,7 @@ test("arePrivateProviderUrlsAllowed honors DB override = 'true' even when env is
await withEnv("false", async () => {
await withDbOverride("true", async () => {
const { arePrivateProviderUrlsAllowed } =
await import("../../src/shared/network/outboundUrlGuard.ts");
await import("../../src/shared/network/outboundUrlGuardPolicy.ts");
assert.equal(
arePrivateProviderUrlsAllowed(),
true,
@@ -53,7 +53,7 @@ test("arePrivateProviderUrlsAllowed returns false when DB override = 'false' and
await withEnv(undefined, async () => {
await withDbOverride("false", async () => {
const { arePrivateProviderUrlsAllowed } =
await import("../../src/shared/network/outboundUrlGuard.ts");
await import("../../src/shared/network/outboundUrlGuardPolicy.ts");
assert.equal(arePrivateProviderUrlsAllowed(), false);
});
});
@@ -63,7 +63,7 @@ test("arePrivateProviderUrlsAllowed honors env = 'true' when DB has no override"
await withEnv("true", async () => {
await withDbOverride(undefined, async () => {
const { arePrivateProviderUrlsAllowed } =
await import("../../src/shared/network/outboundUrlGuard.ts");
await import("../../src/shared/network/outboundUrlGuardPolicy.ts");
assert.equal(arePrivateProviderUrlsAllowed(), true);
});
});
@@ -73,7 +73,7 @@ test("arePrivateProviderUrlsAllowed default (no env, no DB) returns false", asyn
await withEnv(undefined, async () => {
await withDbOverride(undefined, async () => {
const { arePrivateProviderUrlsAllowed } =
await import("../../src/shared/network/outboundUrlGuard.ts");
await import("../../src/shared/network/outboundUrlGuardPolicy.ts");
assert.equal(arePrivateProviderUrlsAllowed(), false);
});
});

View File

@@ -19,6 +19,7 @@ const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
const outboundUrlGuard = await import("../../src/shared/network/outboundUrlGuard.ts");
const outboundUrlGuardPolicy = await import("../../src/shared/network/outboundUrlGuardPolicy.ts");
const originalFetch = globalThis.fetch;
const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
@@ -77,7 +78,7 @@ test("#6939: getProviderOutboundGuard() and getProviderValidationGuard() agree f
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS;
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
const validationGuard = outboundUrlGuard.getProviderValidationGuard();
const validationGuard = outboundUrlGuardPolicy.getProviderValidationGuard();
assert.equal(validationGuard, "block-metadata");
// The models route must resolve a guard for LAN-local model discovery that is at least as

Some files were not shown because too many files have changed in this diff Show More