feat(sse): add Notion AI Web (Unofficial/Experimental) provider (#6758) (#7600)

Notion AI has no public inference API (see closed request #3272), so this
adds it as a new entry in the established web-cookie provider category
(chatgpt-web, claude-web, grok-web, ...): cookie-based auth via the
token_v2 session cookie posted to Notion's undocumented internal
POST /api/v3/runInferenceTranscript endpoint, translating its NDJSON
transcript-patch stream into OpenAI-compatible chat completions.

- NotionWebExecutor (open-sse/executors/notion-web.ts): resolves the
  token_v2 cookie (+ optional space_id/notion_browser_id), builds a
  Notion transcript from the chat messages, parses the NDJSON response
  (cumulative-snapshot semantics, mirroring gemini-web.ts's handling of
  #7163), and returns a chat.completion or pseudo-streamed SSE response.
  All error paths route through makeExecutorErrorResult (sanitized).
- RegistryEntry under open-sse/config/providers/registry/notion-web/,
  registered in providers/index.ts REGISTRY and executors/index.ts
  (alias "nw").
- WEB_COOKIE_PROVIDERS entry (src/shared/constants/providers/web-cookie.ts)
  with subscriptionRisk + webCookie risk notice, clearly labeled
  "(Unofficial/Experimental)".
- Cookie-probe validator (validateNotionWebProvider) against Notion's
  getSpaces endpoint, and a webSessionCredentials.ts UI entry for the
  "Add session cookie" flow.
- Regenerated docs/reference/PROVIDER_REFERENCE.md and the
  provider/translate-path golden snapshot (purely additive diffs); synced
  the "251 providers" count across README/AGENTS/CLAUDE.md
  (check:docs-counts STRICT gate).

Tests: tests/unit/executor-notion-web.test.ts (22 cases — registry
consistency, mocked-upstream request/response translation, NDJSON
snapshot parsing, cookie resolution, sanitized error paths) plus the
existing executor-web-cookie-sweep, provider-alias-uniqueness,
check-provider-consistency, web-session-credentials, and
provider-translate-path-golden suites all pass with notion-web included.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 11:32:09 -03:00
committed by GitHub
parent 7b564ab5db
commit df1ed57876
13 changed files with 766 additions and 5 deletions

View File

@@ -31,8 +31,8 @@
</br>
[![250 AI Providers](https://img.shields.io/badge/250-AI_Providers-6C5CE7?style=for-the-badge)](#-250-ai-providers--90-free)
[![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-250-ai-providers--90-free)
[![251 AI Providers](https://img.shields.io/badge/251-AI_Providers-6C5CE7?style=for-the-badge)](#-251-ai-providers--90-free)
[![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-251-ai-providers--90-free)
[![1.6B Free Tokens/mo](https://img.shields.io/badge/1.6B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md)
[![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically)
[![18 Strategies](https://img.shields.io/badge/18-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship)
@@ -61,7 +61,7 @@
![Docker Pulls](https://img.shields.io/docker/pulls/diegosouzapw/omniroute?label=docker%20pulls&logo=docker&color=2496ED)
![Electron Downloads](https://img.shields.io/github/downloads/diegosouzapw/omniroute/total?style=flat&label=electron%20downloads&logo=electron&color=47848F)
[**🚀 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
<div align="center">
# 🌐 250 AI Providers — 90+ Free
# 🌐 251 AI Providers — 90+ Free
</div>

View File

@@ -0,0 +1 @@
- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)

View File

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

View File

@@ -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<string, RegistryEntry> = {
friendliai: friendliaiProvider,
suno: sunoProvider,
"adapta-web": adapta_webProvider,
"notion-web": notion_webProvider,
anthropic: anthropicProvider,
sambanova: sambanovaProvider,
puter: puterProvider,

View File

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

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown> | 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<Record<string, unknown>> {
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<string, unknown>).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<string, unknown> = {
traceId: randomUUID(),
transcript: buildNotionTranscript(messages),
createThread: false,
};
if (spaceId) reqBody.spaceId = spaceId;
const reqHeaders: Record<string, string> = {
"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 };
}
}

View File

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

View File

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

View File

@@ -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=<JWT> 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). */

View File

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

View File

@@ -3009,6 +3009,29 @@
"stream": "https://api.nlpcloud.io/v1/chat/completions"
}
},
"notion-web": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"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": {

View File

@@ -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<string, Record<string, unknown>>)["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<string, string> = {};
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<string, string>;
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<string, string> = {};
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
capturedHeaders = opts.headers as Record<string, string>;
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), "");
});
});