From df1ed578769dc2eb37ccece036b71c101eefd1ca Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:32:09 -0300 Subject: [PATCH] feat(sse): add Notion AI Web (Unofficial/Experimental) provider (#6758) (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 +- .../features/6758-notion-web-provider.md | 1 + docs/reference/PROVIDER_REFERENCE.md | 3 +- open-sse/config/providers/index.ts | 2 + .../providers/registry/notion-web/index.ts | 20 ++ open-sse/executors/index.ts | 3 + open-sse/executors/notion-web.ts | 310 +++++++++++++++++ src/lib/providers/validation.ts | 2 + src/lib/providers/validation/webProvidersB.ts | 51 +++ src/shared/constants/providers/web-cookie.ts | 19 ++ src/shared/providers/webSessionCredentials.ts | 7 + tests/snapshots/provider/translate-path.json | 23 ++ tests/unit/executor-notion-web.test.ts | 322 ++++++++++++++++++ 13 files changed, 766 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/6758-notion-web-provider.md create mode 100644 open-sse/config/providers/registry/notion-web/index.ts create mode 100644 open-sse/executors/notion-web.ts create mode 100644 tests/unit/executor-notion-web.test.ts 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](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
-# 🌐 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), ""); + }); +});