mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
7b564ab5db
commit
df1ed57876
@@ -31,8 +31,8 @@
|
||||
|
||||
</br>
|
||||
|
||||
[](#-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
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 250 AI Providers — 90+ Free
|
||||
# 🌐 251 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
1
changelog.d/features/6758-notion-web-provider.md
Normal file
1
changelog.d/features/6758-notion-web-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)
|
||||
@@ -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). |
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
open-sse/config/providers/registry/notion-web/index.ts
Normal file
20
open-sse/config/providers/registry/notion-web/index.ts
Normal 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)" }],
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
310
open-sse/executors/notion-web.ts
Normal file
310
open-sse/executors/notion-web.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
322
tests/unit/executor-notion-web.test.ts
Normal file
322
tests/unit/executor-notion-web.test.ts
Normal 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), "");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user