diff --git a/README.md b/README.md
index 8d1e71ebfc..e539a260e2 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,8 @@
-[](#-250-ai-providers--90-free)
-[](#-250-ai-providers--90-free)
+[](#-251-ai-providers--90-free)
+[](#-251-ai-providers--90-free)
[](docs/reference/FREE_TIERS.md)
[](#%EF%B8%8F-save-1595-tokens--automatically)
[](#-combos--the-flagship)
@@ -61,7 +61,7 @@


-[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-250-ai-providers--90-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
+[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-251-ai-providers--90-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
[💥 The Promise](#-the-promise) • [🤔 Why](#-why-omniroute) • [🏆 What Sets Apart](#-what-sets-omniroute-apart) • [🤖 Compatible CLIs](#-compatible-clis--coding-agents) • [🖥️ Where It Runs](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 Private](#-private--local-first) • [🎬 In Action](#-omniroute-in-action) • [📚 Explore More](#-explore-more) • [📧 Support](#-support--community)
@@ -395,7 +395,7 @@ Result: 4 layers of fallback = zero downtime
-# 🌐 250 AI Providers — 90+ Free
+# 🌐 251 AI Providers — 90+ Free
diff --git a/changelog.d/features/6758-notion-web-provider.md b/changelog.d/features/6758-notion-web-provider.md
new file mode 100644
index 0000000000..c6c94262ab
--- /dev/null
+++ b/changelog.d/features/6758-notion-web-provider.md
@@ -0,0 +1 @@
+- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)
diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md
index 8e9f92350c..eb0afc556a 100644
--- a/docs/reference/PROVIDER_REFERENCE.md
+++ b/docs/reference/PROVIDER_REFERENCE.md
@@ -58,7 +58,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. |
| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. |
-## Web Cookie Providers (25)
+## Web Cookie Providers (26)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -78,6 +78,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. |
| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai |
+| `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | 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. |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai |
| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) |
| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). |
diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts
index 848ce8bfb7..9e03ba48a2 100644
--- a/open-sse/config/providers/index.ts
+++ b/open-sse/config/providers/index.ts
@@ -9,6 +9,7 @@ import { ideogramProvider } from "./registry/ideogram/index.ts";
import { friendliaiProvider } from "./registry/friendliai/index.ts";
import { sunoProvider } from "./registry/suno/index.ts";
import { adapta_webProvider } from "./registry/adapta-web/index.ts";
+import { notion_webProvider } from "./registry/notion-web/index.ts";
import { anthropicProvider } from "./registry/anthropic/index.ts";
import { sambanovaProvider } from "./registry/sambanova/index.ts";
import { puterProvider } from "./registry/puter/index.ts";
@@ -197,6 +198,7 @@ export const REGISTRY: Record = {
friendliai: friendliaiProvider,
suno: sunoProvider,
"adapta-web": adapta_webProvider,
+ "notion-web": notion_webProvider,
anthropic: anthropicProvider,
sambanova: sambanovaProvider,
puter: puterProvider,
diff --git a/open-sse/config/providers/registry/notion-web/index.ts b/open-sse/config/providers/registry/notion-web/index.ts
new file mode 100644
index 0000000000..cca2160a4c
--- /dev/null
+++ b/open-sse/config/providers/registry/notion-web/index.ts
@@ -0,0 +1,20 @@
+import type { RegistryEntry } from "../../shared.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.
+export const notion_webProvider: RegistryEntry = {
+ id: "notion-web",
+ alias: "nw",
+ format: "openai",
+ executor: "notion-web",
+ baseUrl: "https://www.notion.so/api/v3/runInferenceTranscript",
+ authType: "apikey",
+ authHeader: "cookie",
+ passthroughModels: true,
+ models: [{ id: "notion-ai", name: "Notion AI (Unofficial/Experimental)" }],
+};
diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts
index ca832b8a6d..9b58a644fd 100644
--- a/open-sse/executors/index.ts
+++ b/open-sse/executors/index.ts
@@ -46,6 +46,7 @@ import { HuggingChatExecutor } from "./huggingchat.ts";
import { YuanbaoWebExecutor } from "./yuanbao-web.ts";
import { PoeWebExecutor } from "./poe-web.ts";
import { VeniceWebExecutor } from "./venice-web.ts";
+import { NotionWebExecutor } from "./notion-web.ts";
import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
import { KimiWebExecutor } from "./kimi-web.ts";
import { DoubaoWebExecutor } from "./doubao-web.ts";
@@ -142,6 +143,8 @@ const executors = {
poe: new PoeWebExecutor(), // Alias
"venice-web": new VeniceWebExecutor(),
ven: new VeniceWebExecutor(), // Alias
+ "notion-web": new NotionWebExecutor(),
+ nw: new NotionWebExecutor(), // Alias
"v0-vercel-web": new V0VercelWebExecutor(),
v0: new V0VercelWebExecutor(), // Alias
"kimi-web": new KimiWebExecutor(),
diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts
new file mode 100644
index 0000000000..b018b40302
--- /dev/null
+++ b/open-sse/executors/notion-web.ts
@@ -0,0 +1,310 @@
+/**
+ * NotionWebExecutor — Notion AI Web Session Provider (Unofficial/Experimental)
+ *
+ * Notion AI has no public, documented inference API (see issue #3272, closed
+ * by the owner for that reason). This executor instead reverse-engineers the
+ * same cookie-authenticated internal endpoint two independent open-source
+ * projects already ship (`notion2api`, `Notion-AI-to-OpenAI-Compatible`, both
+ * cited in issue #6758): a `token_v2` session cookie posted to
+ * `POST /api/v3/runInferenceTranscript`, whose response is a newline-delimited
+ * JSON (NDJSON) stream of transcript-patch records. Each record's `value`
+ * field carries Notion's standard rich-text tuple shape (`[[text, marks?]]`,
+ * the same shape used by Notion's public page-property API) holding the
+ * *current* (cumulative, not delta) assistant text — mirroring the snapshot
+ * semantics `gemini-web.ts` already handles, so only the last non-empty frame
+ * is kept rather than concatenating every frame (see #7163 for why
+ * concatenating cumulative snapshots duplicates text).
+ *
+ * Because the endpoint is undocumented and can change without notice
+ * (acknowledged risk in issue #6758), streaming here is pseudo-streaming —
+ * the full response is read, parsed, then sent as a single SSE chunk. This is
+ * the same conservative tradeoff `gemini-web.ts` makes and is safer than
+ * assuming unverified incremental-delta semantics on a live, undocumented API.
+ *
+ * Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id])
+ * Method: Direct fetch — no browser automation required.
+ */
+import { randomUUID } from "node:crypto";
+import { BaseExecutor, type ExecuteInput } from "./base.ts";
+import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
+
+// ─── Constants ──────────────────────────────────────────────────────────────
+
+const BASE_URL = "https://www.notion.so";
+const NOTION_URL = `${BASE_URL}/api/v3/runInferenceTranscript`;
+const 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";
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+interface NotionMessage {
+ role: string;
+ content: string;
+}
+
+interface NotionRequestBody {
+ messages?: NotionMessage[];
+ model?: string;
+}
+
+// ─── Helpers — credential resolution ───────────────────────────────────────
+
+function readCredentialString(value: unknown): string {
+ if (typeof value !== "string") return "";
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : "";
+}
+
+function readProviderSpecificString(
+ providerSpecificData: unknown,
+ keys: readonly string[]
+): string {
+ if (
+ !providerSpecificData ||
+ typeof providerSpecificData !== "object" ||
+ Array.isArray(providerSpecificData)
+ ) {
+ return "";
+ }
+ const data = providerSpecificData as Record;
+ for (const key of keys) {
+ const value = readCredentialString(data[key]);
+ if (value) return value;
+ }
+ return "";
+}
+
+/** Normalize a pasted credential to a `name=value` cookie pair. Accepts a bare
+ * token or an already-prefixed `token_v2=...` value. */
+export function normalizeNotionCookieInput(raw: string, cookieName = "token_v2"): string {
+ const trimmed = raw.trim();
+ if (!trimmed) return "";
+ return trimmed.includes("=") ? trimmed : `${cookieName}=${trimmed}`;
+}
+
+/**
+ * Resolve the Cookie header to send upstream. Accepts, in priority order:
+ * 1. A full cookie header pasted as `apiKey` or `credentials.cookie`.
+ * 2. `providerSpecificData.cookie` (full header).
+ * 3. Structured `providerSpecificData.token_v2` (+ optional `space_id`,
+ * `notion_browser_id`), assembled into a cookie header.
+ */
+export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"]): string {
+ const directCookie =
+ readCredentialString(credentials?.apiKey) ||
+ readCredentialString((credentials as Record | undefined)?.cookie);
+ if (directCookie) return normalizeNotionCookieInput(directCookie);
+
+ const providerSpecificData = credentials?.providerSpecificData;
+ const cookie = readProviderSpecificString(providerSpecificData, ["cookie"]);
+ if (cookie) return normalizeNotionCookieInput(cookie);
+
+ const tokenV2 = readProviderSpecificString(providerSpecificData, ["token_v2", "tokenV2"]);
+ const spaceId = readProviderSpecificString(providerSpecificData, ["space_id", "spaceId"]);
+ const browserId = readProviderSpecificString(providerSpecificData, [
+ "notion_browser_id",
+ "notionBrowserId",
+ ]);
+ return [
+ tokenV2 ? normalizeNotionCookieInput(tokenV2) : "",
+ spaceId ? `space_id=${spaceId}` : "",
+ browserId ? `notion_browser_id=${browserId}` : "",
+ ]
+ .filter(Boolean)
+ .join("; ");
+}
+
+/** Pull `space_id` out of an assembled cookie header, if present. Notion's
+ * transcript endpoint accepts an explicit `spaceId` field in the body; when
+ * the operator supplied it via cookie we forward it rather than relying on
+ * Notion to infer it from the session alone. */
+export function extractSpaceIdFromCookie(cookie: string): string {
+ const match = cookie.match(/(?:^|;\s*)space_id=([^;]+)/i);
+ return match ? match[1].trim() : "";
+}
+
+// ─── Helpers — request/response translation ────────────────────────────────
+
+/**
+ * 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.
+ */
+export function buildNotionTranscript(
+ messages: NotionMessage[]
+): Array> {
+ return messages
+ .filter((m) => typeof m?.content === "string" && m.content.length > 0)
+ .map((m) => ({
+ id: randomUUID(),
+ type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human",
+ value: [[m.content]],
+ }));
+}
+
+/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
+function extractRichText(value: unknown): string {
+ if (!Array.isArray(value)) return "";
+ return value
+ .map((segment) => (Array.isArray(segment) && typeof segment[0] === "string" ? segment[0] : ""))
+ .join("");
+}
+
+/**
+ * Parse Notion's NDJSON `runInferenceTranscript` response body. Each line is
+ * an independent JSON record; the assistant text lives under a `value` field
+ * using the rich-text tuple shape. Frames are cumulative snapshots (mirroring
+ * `gemini-web.ts`'s `parseStreamResponse`), so only the last non-empty frame
+ * is kept — never concatenated.
+ */
+export function parseNotionInferenceStream(raw: string): string {
+ if (!raw) return "";
+ const lines = raw.split("\n");
+ let lastText = "";
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ let record: unknown;
+ try {
+ record = JSON.parse(line);
+ } catch {
+ continue; // Skip unparseable lines (keep-alive pings, partial frames)
+ }
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
+ const text = extractRichText((record as Record).value);
+ if (text) lastText = text;
+ }
+ return lastText;
+}
+
+function chatCompletionResponse(content: string, model: string) {
+ return new Response(
+ JSON.stringify({
+ id: `chatcmpl-notion-${Date.now()}`,
+ object: "chat.completion",
+ created: Math.floor(Date.now() / 1000),
+ model,
+ choices: [
+ { index: 0, message: { role: "assistant", content }, finish_reason: "stop" },
+ ],
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+}
+
+function pseudoStreamResponse(content: string, model: string) {
+ const encoder = new TextEncoder();
+ const chunk = (delta: string, finishReason: string | null) => ({
+ id: `chatcmpl-notion-${Date.now()}`,
+ object: "chat.completion.chunk",
+ created: Math.floor(Date.now() / 1000),
+ model,
+ choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }],
+ });
+ const readable = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(content, null))}\n\n`));
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`));
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ },
+ });
+ return new Response(readable, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ });
+}
+
+// ─── Executor ───────────────────────────────────────────────────────────────
+
+export class NotionWebExecutor extends BaseExecutor {
+ constructor() {
+ super("notion-web", { id: "notion-web", baseUrl: NOTION_URL });
+ }
+
+ async execute(input: ExecuteInput) {
+ const { model, body, stream: wantStream, credentials, signal } = input;
+ const requestBody = (body || {}) as NotionRequestBody;
+
+ const cookie = resolveNotionWebCookie(credentials);
+ if (!cookie) {
+ return makeErrorResult(
+ 401,
+ "Missing Notion token_v2 cookie — paste it from notion.so DevTools → Application → Cookies",
+ body,
+ NOTION_URL
+ );
+ }
+
+ const messages = requestBody.messages || [];
+ if (!messages.some((m) => m.role === "user")) {
+ return makeErrorResult(400, "No user message found", body, NOTION_URL);
+ }
+
+ const spaceId = extractSpaceIdFromCookie(cookie);
+ const modelId = model || "notion-ai";
+ const reqBody: Record = {
+ traceId: randomUUID(),
+ transcript: buildNotionTranscript(messages),
+ createThread: false,
+ };
+ if (spaceId) reqBody.spaceId = spaceId;
+
+ const reqHeaders: Record = {
+ "Content-Type": "application/json",
+ "User-Agent": USER_AGENT,
+ Accept: "application/x-ndjson",
+ Cookie: cookie,
+ Origin: BASE_URL,
+ Referer: `${BASE_URL}/`,
+ };
+
+ let upstream: Response;
+ try {
+ upstream = await fetch(NOTION_URL, {
+ method: "POST",
+ headers: reqHeaders,
+ body: JSON.stringify(reqBody),
+ signal: signal ?? undefined,
+ });
+ } catch (err) {
+ return makeErrorResult(
+ 502,
+ `Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
+ reqBody,
+ NOTION_URL
+ );
+ }
+
+ if (upstream.status === 401 || upstream.status === 403) {
+ return makeErrorResult(
+ upstream.status,
+ "Notion session expired or invalid — re-paste token_v2 from notion.so",
+ reqBody,
+ NOTION_URL
+ );
+ }
+
+ if (!upstream.ok) {
+ const errText = await upstream.text().catch(() => "");
+ return makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL);
+ }
+
+ const rawText = await upstream.text();
+ const finalText = parseNotionInferenceStream(rawText);
+ if (!finalText) {
+ return makeErrorResult(502, "No response from Notion AI", reqBody, NOTION_URL);
+ }
+
+ const response = wantStream
+ ? pseudoStreamResponse(finalText, modelId)
+ : chatCompletionResponse(finalText, modelId);
+
+ return { response, url: NOTION_URL, headers: reqHeaders, transformedBody: reqBody };
+ }
+}
diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts
index b0a82f841f..d2734ea883 100644
--- a/src/lib/providers/validation.ts
+++ b/src/lib/providers/validation.ts
@@ -52,6 +52,7 @@ import {
validateJulesProvider,
validateDevinCloudAgentProvider,
validateInnerAiProvider,
+ validateNotionWebProvider,
} from "./validation/webProvidersB";
import {
validateHerokuProvider,
@@ -532,6 +533,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
"adapta-web": validateAdaptaWebProvider,
"claude-web": validateClaudeWebProvider,
"gemini-web": validateGeminiWebProvider,
+ "notion-web": validateNotionWebProvider,
"copilot-m365-web": validateCopilotM365WebProvider,
"copilot-web": validateCopilotWebProvider,
"t3-web": validateT3WebProvider,
diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts
index 9a1afb128b..3d9ddd84d4 100644
--- a/src/lib/providers/validation/webProvidersB.ts
+++ b/src/lib/providers/validation/webProvidersB.ts
@@ -486,6 +486,57 @@ export async function validateDevinCloudAgentProvider({ apiKey }: { apiKey: stri
}
}
+// ── Notion AI Web (Unofficial/Experimental) cookie validator ──
+// #6758: no public Notion inference API exists; validate by probing a stable,
+// low-privilege authenticated Notion endpoint (getSpaces) with the session
+// cookie rather than the experimental runInferenceTranscript endpoint itself
+// (a live inference call is expensive and unnecessary just to confirm the
+// session is valid).
+export async function validateNotionWebProvider({ apiKey, providerSpecificData = {} }: any) {
+ try {
+ const raw = String(apiKey || "").trim();
+ if (!raw) {
+ return { valid: false, error: "Paste your token_v2 cookie value from notion.so" };
+ }
+
+ const cookieHeader = raw.includes("=") ? raw : `token_v2=${raw}`;
+
+ const response = await validationWrite("https://www.notion.so/api/v3/getSpaces", {
+ method: "POST",
+ headers: applyCustomUserAgent(
+ {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ Cookie: cookieHeader,
+ Origin: "https://www.notion.so",
+ Referer: "https://www.notion.so/",
+ },
+ providerSpecificData
+ ),
+ body: "{}",
+ });
+
+ if (response.status === 401 || response.status === 403) {
+ return {
+ valid: false,
+ error: "Invalid or expired token_v2 cookie — re-paste from notion.so DevTools → Cookies",
+ };
+ }
+
+ if (response.status >= 500) {
+ return { valid: false, error: `Notion unavailable (${response.status})` };
+ }
+
+ if (response.ok) {
+ return { valid: true, error: null };
+ }
+
+ return { valid: false, error: `Notion validation failed (${response.status})` };
+ } catch (error: any) {
+ return toValidationErrorResult(error);
+ }
+}
+
export async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) {
try {
const raw = typeof apiKey === "string" ? apiKey.trim() : "";
diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts
index e284720fa9..e47c4d9c8d 100644
--- a/src/shared/constants/providers/web-cookie.ts
+++ b/src/shared/constants/providers/web-cookie.ts
@@ -342,6 +342,25 @@ export const WEB_COOKIE_PROVIDERS = {
riskNoticeVariant: "webCookie",
authHint: "Paste the full Cookie header from chat.z.ai (must include the token= cookie)",
},
+ "notion-web": {
+ id: "notion-web",
+ alias: "nw",
+ name: "Notion AI Web (Unofficial/Experimental)",
+ icon: "auto_awesome",
+ color: "#000000",
+ textIcon: "NW",
+ website: "https://www.notion.so",
+ // #6758: Notion has no public inference API (see closed request #3272) — this
+ // reverse-engineers the same undocumented internal endpoint two independent
+ // open-source projects already use. Undocumented endpoints can change without
+ // notice; label clearly so operators understand the risk before pasting a
+ // session cookie of an account they already pay for.
+ subscriptionRisk: true,
+ 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.",
+ },
};
/** Resolved public site for a web-session provider (href + display host). */
diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts
index 544083ec5e..e3e7c96588 100644
--- a/src/shared/providers/webSessionCredentials.ts
+++ b/src/shared/providers/webSessionCredentials.ts
@@ -53,6 +53,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"],
},
+ "notion-web": {
+ kind: "cookie",
+ credentialName: "token_v2 (optional: space_id, notion_browser_id)",
+ placeholder: "token_v2=...; space_id=...; notion_browser_id=...",
+ acceptsFullCookieHeader: true,
+ storageKeys: ["cookie", "token_v2", "space_id", "notion_browser_id"],
+ },
"gemini-business": {
kind: "cookie",
credentialName: "__Secure-1PSID (optional: __Secure-1PSIDTS)",
diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json
index 3cf5ae91fc..41f9b431d3 100644
--- a/tests/snapshots/provider/translate-path.json
+++ b/tests/snapshots/provider/translate-path.json
@@ -3009,6 +3009,29 @@
"stream": "https://api.nlpcloud.io/v1/chat/completions"
}
},
+ "notion-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://www.notion.so/api/v3/runInferenceTranscript",
+ "stream": "https://www.notion.so/api/v3/runInferenceTranscript"
+ }
+ },
"nous-research": {
"format": "openai",
"headers": {
diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts
new file mode 100644
index 0000000000..ab873f3167
--- /dev/null
+++ b/tests/unit/executor-notion-web.test.ts
@@ -0,0 +1,322 @@
+// Tests for the Notion AI Web executor (#6758) — cookie auth + NDJSON
+// transcript-patch parsing for Notion's undocumented runInferenceTranscript
+// endpoint. Covers: registry consistency, request/response translation
+// against a mocked upstream, and the error-sanitization contract.
+
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+const mod = await import("../../open-sse/executors/notion-web.ts");
+const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts");
+const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers/web-cookie.ts");
+
+describe("NotionWebExecutor — registry consistency", () => {
+ it("is present in WEB_COOKIE_PROVIDERS with the expected shape", () => {
+ const entry = (WEB_COOKIE_PROVIDERS as Record>)["notion-web"];
+ assert.ok(entry, "notion-web missing from WEB_COOKIE_PROVIDERS");
+ assert.equal(entry.id, "notion-web");
+ assert.equal(entry.alias, "nw");
+ assert.equal(entry.subscriptionRisk, true);
+ assert.equal(entry.riskNoticeVariant, "webCookie");
+ assert.match(String(entry.name), /unofficial|experimental/i);
+ });
+
+ it("registers a model catalog reachable via getModelsByProviderId", () => {
+ const models = getModelsByProviderId("notion-web");
+ assert.ok(models.length >= 1);
+ assert.ok(models.some((m) => m.id === "notion-ai"));
+ });
+});
+
+describe("NotionWebExecutor — instantiation & auth errors", () => {
+ it("can be instantiated", () => {
+ const executor = new mod.NotionWebExecutor();
+ assert.ok(executor);
+ assert.equal(executor.getProvider(), "notion-web");
+ });
+
+ it("returns 401 when no cookie credential is supplied", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: {},
+ signal: null,
+ } as never);
+ assert.equal(result.response.status, 401);
+ const errBody = (await result.response.json()) as { error: { message: string } };
+ assert.match(errBody.error.message, /token_v2/i);
+ });
+
+ it("returns 400 when no user message is present", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "assistant", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "token_v2=fake" },
+ signal: null,
+ } as never);
+ assert.equal(result.response.status, 400);
+ });
+});
+
+describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
+ it("posts the transcript to runInferenceTranscript with the cookie header and returns a chat.completion", async () => {
+ const executor = new mod.NotionWebExecutor();
+ let capturedUrl = "";
+ let capturedHeaders: Record = {};
+ let capturedBody: { transcript: Array<{ type: string; value: unknown }> } | null = null;
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async (url: string | URL, opts: RequestInit) => {
+ capturedUrl = String(url);
+ capturedHeaders = opts.headers as Record;
+ capturedBody = JSON.parse(String(opts.body));
+ const ndjson = [
+ JSON.stringify({ value: [["Hel"]] }),
+ JSON.stringify({ value: [["Hello"]] }),
+ JSON.stringify({ value: [["Hello there!"]] }),
+ ].join("\n");
+ return new Response(ndjson, { status: 200 });
+ }) as typeof fetch;
+
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "abc123" },
+ signal: null,
+ } as never);
+
+ assert.equal(capturedUrl, "https://www.notion.so/api/v3/runInferenceTranscript");
+ assert.equal(capturedHeaders.Cookie, "token_v2=abc123");
+ assert.ok(capturedBody);
+ assert.equal(capturedBody.transcript[0].type, "human");
+ assert.deepEqual(capturedBody.transcript[0].value, [["hi"]]);
+
+ assert.equal(result.response.status, 200);
+ const json = (await result.response.json()) as {
+ object: string;
+ choices: Array<{ message: { content: string } }>;
+ };
+ assert.equal(json.object, "chat.completion");
+ // Cumulative NDJSON frames: only the LAST non-empty frame is kept, never
+ // concatenated (mirrors gemini-web.ts's snapshot handling, #7163).
+ assert.equal(json.choices[0].message.content, "Hello there!");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("accepts a full cookie header verbatim (already containing token_v2=)", async () => {
+ const executor = new mod.NotionWebExecutor();
+ let capturedHeaders: Record = {};
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
+ capturedHeaders = opts.headers as Record;
+ return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 });
+ }) as typeof fetch;
+
+ await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "token_v2=xyz; space_id=abc-def" },
+ signal: null,
+ } as never);
+
+ assert.equal(capturedHeaders.Cookie, "token_v2=xyz; space_id=abc-def");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("returns a pseudo-streamed SSE response with [DONE] when stream=true", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ value: [["Streamed reply"]] }), {
+ status: 200,
+ })) as typeof fetch;
+
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: true,
+ credentials: { apiKey: "token_v2=xyz" },
+ signal: null,
+ } as never);
+
+ assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
+ const text = await result.response.text();
+ assert.match(text, /Streamed reply/);
+ assert.match(text, /data: \[DONE\]/);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("returns 502 when Notion sends no parseable text (endpoint drift)", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async () => new Response("not-json\n{}", { status: 200 })) as typeof fetch;
+
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "token_v2=xyz" },
+ signal: null,
+ } as never);
+ assert.equal(result.response.status, 502);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("returns a sanitized 403 error without leaking raw upstream error text shape", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async () =>
+ new Response("Forbidden", { status: 403 })) as typeof fetch;
+
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "token_v2=expired" },
+ signal: null,
+ } as never);
+ assert.equal(result.response.status, 403);
+ const errBody = (await result.response.json()) as { error: { message: string; code: string } };
+ assert.match(errBody.error.message, /session expired|invalid/i);
+ assert.equal(errBody.error.code, "HTTP_403");
+ // No stack trace / file path leakage (Hard Rule #12).
+ assert.ok(!errBody.error.message.includes("at /"));
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("returns 502 with a sanitized message when the fetch itself throws", async () => {
+ const executor = new mod.NotionWebExecutor();
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async () => {
+ throw new Error("getaddrinfo ENOTFOUND www.notion.so at /some/internal/path.ts:42");
+ }) as typeof fetch;
+
+ const result = await executor.execute({
+ model: "notion-ai",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "token_v2=xyz" },
+ signal: null,
+ } as never);
+ assert.equal(result.response.status, 502);
+ const errBody = (await result.response.json()) as { error: { message: string } };
+ assert.ok(!errBody.error.message.includes("at /some/internal/path.ts"));
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+});
+
+describe("parseNotionInferenceStream", () => {
+ const { parseNotionInferenceStream } = mod;
+
+ it("returns empty string for empty input", () => {
+ assert.equal(parseNotionInferenceStream(""), "");
+ });
+
+ it("keeps only the last non-empty cumulative frame (snapshot semantics)", () => {
+ const ndjson = [
+ JSON.stringify({ value: [["H"]] }),
+ JSON.stringify({ value: [["He"]] }),
+ JSON.stringify({ value: [["Hello world"]] }),
+ ].join("\n");
+ assert.equal(parseNotionInferenceStream(ndjson), "Hello world");
+ });
+
+ it("skips unparseable lines without throwing", () => {
+ const ndjson = ["not json", JSON.stringify({ value: [["ok"]] }), ""].join("\n");
+ assert.equal(parseNotionInferenceStream(ndjson), "ok");
+ });
+
+ it("ignores records with no usable rich-text value", () => {
+ const ndjson = [JSON.stringify({ recordMap: { block: {} } }), JSON.stringify({ value: [["final"]] })].join(
+ "\n"
+ );
+ assert.equal(parseNotionInferenceStream(ndjson), "final");
+ });
+});
+
+describe("buildNotionTranscript", () => {
+ const { buildNotionTranscript } = mod;
+
+ it("maps roles to Notion transcript entry types", () => {
+ const transcript = buildNotionTranscript([
+ { role: "system", content: "be nice" },
+ { role: "user", content: "hi" },
+ { role: "assistant", content: "hello" },
+ ]);
+ assert.deepEqual(
+ transcript.map((t) => t.type),
+ ["context", "human", "ai"]
+ );
+ assert.deepEqual(
+ transcript.map((t) => t.value),
+ [[["be nice"]], [["hi"]], [["hello"]]]
+ );
+ assert.ok(transcript.every((t) => typeof t.id === "string" && (t.id as string).length > 0));
+ });
+
+ it("drops messages with empty/non-string content", () => {
+ const transcript = buildNotionTranscript([
+ { role: "user", content: "" },
+ { role: "user", content: "keep me" },
+ ]);
+ assert.equal(transcript.length, 1);
+ });
+});
+
+describe("resolveNotionWebCookie", () => {
+ const { resolveNotionWebCookie, normalizeNotionCookieInput } = mod;
+
+ it("normalizes a bare token to token_v2=...", () => {
+ assert.equal(normalizeNotionCookieInput("abc"), "token_v2=abc");
+ });
+
+ it("leaves an already-prefixed cookie untouched", () => {
+ assert.equal(normalizeNotionCookieInput("token_v2=abc"), "token_v2=abc");
+ });
+
+ it("prefers apiKey over providerSpecificData", () => {
+ const cookie = resolveNotionWebCookie({
+ apiKey: "token_v2=direct",
+ providerSpecificData: { token_v2: "ignored" },
+ } as never);
+ assert.equal(cookie, "token_v2=direct");
+ });
+
+ it("assembles a cookie from structured providerSpecificData fields", () => {
+ const cookie = resolveNotionWebCookie({
+ providerSpecificData: {
+ token_v2: "abc",
+ space_id: "space-1",
+ notion_browser_id: "browser-1",
+ },
+ } as never);
+ assert.equal(cookie, "token_v2=abc; space_id=space-1; notion_browser_id=browser-1");
+ });
+
+ it("returns empty string when no credential is present", () => {
+ assert.equal(resolveNotionWebCookie({} as never), "");
+ });
+});