fix(notion-web): production-ready labels, multi-workspace, inference, usage (FINAL) (#7768)

* fix(notion-web): use real picker labels as primary model ids

Catalog /v1/models now surfaces web-picker names (fable-5, gpt-5.6-sol)
instead of Notion food codenames (acai-budino-high, orange-mousse).
Food codenames stay internal via notionCodename + resolveNotionCodename
for runInferenceTranscript. Legacy codename requests still work; responses
echo the client-facing id.

Also points discovery/inference at app.notion.com (same host as the AI picker).

Follow-up to #7696.

* fix(notion-web): explain plan-locked models like Fable 5

Notion returns Fable 5 (acai-budino-high) with isDisabled=true and
disabledReason=business_or_enterprise_plan_required. Keep it out of the
enabled catalog (requests would fail) but surface a discovery warning so
operators know why it is missing. Also warn when space_id is resolved via
getSpaces instead of the cookie.

* feat(notion-web): auto-detect workspace without pasting space_id

Operators only need the raw token_v2 value. When space_id is omitted:
- getSpaces loads all workspaces (browser-like headers + user id)
- each workspace is probed via getAvailableModels
- the richest AI catalog wins

Also softens auth hints so they no longer demand a cookie blob with =.

* fix(notion-web): pick Business workspace so Fable 5 is listed

Probe ALL workspaces instead of early-exiting on the first catalog with
>=8 models. Prefer spaces where Fable is enabled over personal spaces
where Notion returns isDisabled=business_or_enterprise_plan_required.
Cache the chosen spaceId for inference when cookie has no space_id.

* fix(notion-web): working inference + honest token estimates

- runInferenceTranscript: createThread+threadId, config/context/user
  transcript, space/user headers (fixes ValidationError 400)
- Parse modern NDJSON patch/record-map; strip lang tags
- Estimate usage from text (Notion has no metering); mark estimated
- Treat all-zero usage as missing; skip USAGE_TOKEN_BUFFER on estimated
- Keep estimated flag through response sanitizer (was stripped -> flat 2000)

Verified live: fable-5/gpt-5.6-sol chat 200; usage 7 / 65 not constant 2000.

* refactor(notion-web): extract helpers to keep discoverNotionWebModels/execute under the complexity cap

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
NOXX - Commiter
2026-07-19 22:21:18 +03:00
committed by GitHub
parent 25ef8d05c0
commit 7a68a7961c
12 changed files with 1551 additions and 288 deletions

View File

@@ -1,17 +1,25 @@
import type { RegistryEntry } from "../../shared.ts";
import { NOTION_WEB_FALLBACK_MODELS } from "../../../../services/notionWebModels.ts";
import {
NOTION_WEB_FALLBACK_MODELS,
withFriendlyNotionAliases,
} from "../../../../services/notionWebModels.ts";
// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts.
// Live catalog comes from cookie-auth POST /api/v3/getAvailableModels (models route).
// The registry seed below is the offline fallback when discovery fails.
// Catalog ids are real web-picker labels (fable-5, gpt-5.6-sol); food codenames
// stay internal for runInferenceTranscript via resolveNotionCodename.
export const notion_webProvider: RegistryEntry = {
id: "notion-web",
alias: "nw",
format: "openai",
executor: "notion-web",
baseUrl: "https://www.notion.so/api/v3/runInferenceTranscript",
baseUrl: "https://app.notion.com/api/v3/runInferenceTranscript",
authType: "apikey",
authHeader: "cookie",
passthroughModels: true,
models: NOTION_WEB_FALLBACK_MODELS.map((m) => ({ id: m.id, name: m.name })),
models: withFriendlyNotionAliases(NOTION_WEB_FALLBACK_MODELS).map((m) => ({
id: m.id,
name: m.name,
})),
};

View File

@@ -2,38 +2,42 @@
* 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).
* by the owner for that reason). This executor reverse-engineers the same
* cookie-authenticated internal endpoint used by open-source bridges
* (notion2api / Notion2API-go, cited in issue #6758): a `token_v2` session
* cookie posted to `POST /api/v3/runInferenceTranscript`.
*
* 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.
* Live capture (2026-07-19) against a Business workspace confirmed the
* contract that actually works:
* - createThread: true + a fresh threadId (createThread:false → ValidationError 400)
* - transcript starts with config + context, then user/assistant steps
* - x-notion-space-id + x-notion-active-user-header required
* - response is NDJSON patch-start / patch / record-map (not legacy rich-text
* tuples alone). Text is extracted from agent-inference / markdown-chat.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id])
* Streaming is still pseudo-streaming: read full body, parse, emit one SSE
* chunk — safer than assuming unverified incremental-delta semantics.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_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";
import {
extractNotionUserIdFromCookie,
resolveNotionCodename,
resolveNotionRuntimeWorkspace,
} from "../services/notionWebModels.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
const BASE_URL = "https://www.notion.so";
// Both app.notion.com and www.notion.so work; prefer the AI surface host.
const BASE_URL = "https://app.notion.com";
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";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
const NOTION_CLIENT_VERSION = "23.13.20260719.1125";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -101,6 +105,12 @@ export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"])
const tokenV2 = readProviderSpecificString(providerSpecificData, ["token_v2", "tokenV2"]);
const spaceId = readProviderSpecificString(providerSpecificData, ["space_id", "spaceId"]);
const userId = readProviderSpecificString(providerSpecificData, [
"notion_user_id",
"notionUserId",
"user_id",
"userId",
]);
const browserId = readProviderSpecificString(providerSpecificData, [
"notion_browser_id",
"notionBrowserId",
@@ -108,60 +118,148 @@ export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"])
return [
tokenV2 ? normalizeNotionCookieInput(tokenV2) : "",
spaceId ? `space_id=${spaceId}` : "",
userId ? `notion_user_id=${userId}` : "",
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. */
/** Pull `space_id` out of an assembled cookie header, if present. */
export function extractSpaceIdFromCookie(cookie: string): string {
const match = cookie.match(/(?:^|;\s*)space_id=([^;]+)/i);
return match ? match[1].trim() : "";
if (match) return match[1].trim();
const camel = cookie.match(/(?:^|;\s*)spaceId=([^;]+)/);
return camel ? camel[1].trim() : "";
}
function extractUserIdFromCookie(cookie: string): string {
return extractNotionUserIdFromCookie(cookie);
}
function isoNow(): string {
// Millisecond precision matches the browser client.
return new Date().toISOString().replace(/\.\d{3}Z$/, (m) => m); // keep ms + Z
}
// ─── Helpers — request/response translation ────────────────────────────────
/**
* Build a Notion `runInferenceTranscript` transcript array from OpenAI-style
* chat messages. When `notionModel` is set (and not the synthetic `notion-ai`
* default), a leading `config` entry carries `value.model` so Notion routes the
* request to the selected codename from getAvailableModels.
* chat messages.
*
* Live contract (verified 2026-07-19):
* - Leading `config` (workflow + optional model food-codename)
* - Leading `context` (spaceId / userId / surface / timezone)
* - User turns as `type: "user"` (legacy `human` also works with createThread,
* but `user` matches the current web client)
* - Assistant turns as `agent-inference` text parts
*/
function buildNotionConfigStep(model: string): Record<string, unknown> {
const configValue: Record<string, unknown> = {
type: "workflow",
useWebSearch: false,
searchScopes: [{ type: "everything" }],
modelFromUser: Boolean(model),
enableAgentAutomations: false,
enableAgentIntegrations: false,
enableCustomAgents: false,
enableDatabaseAgents: false,
enableUserSessionContext: false,
isCustomAgent: false,
};
if (model) configValue.model = model;
return { id: randomUUID(), type: "config", value: configValue };
}
function buildNotionContextValue(opts: {
spaceId?: string;
userId?: string;
now: string;
}): Record<string, unknown> {
const contextValue: Record<string, unknown> = {
timezone: "UTC",
surface: "ai_module",
currentDatetime: opts.now,
};
if (opts.spaceId) contextValue.spaceId = opts.spaceId;
if (opts.userId) contextValue.userId = opts.userId;
return contextValue;
}
/** Converts one OpenAI-style message into a transcript step, or `null` when it
* was folded into the context (system prompts). */
function buildNotionMessageStep(
m: NotionMessage,
contextValue: Record<string, unknown>,
opts: { userId?: string; now: string }
): Record<string, unknown> | null {
if (typeof m?.content !== "string" || m.content.length === 0) return null;
const role = (m.role || "").toLowerCase();
if (role === "system") {
// Fold system prompts into context instructions rather than a separate step.
const existing = typeof contextValue.instructions === "string" ? contextValue.instructions : "";
contextValue.instructions = existing ? `${existing}\n${m.content}` : m.content;
return null;
}
if (role === "assistant") {
return {
id: randomUUID(),
type: "agent-inference",
value: [{ type: "text", content: m.content }],
};
}
// user (and anything else treated as user)
const userStep: Record<string, unknown> = {
id: randomUUID(),
type: "user",
value: [[m.content]],
createdAt: opts.now,
};
if (opts.userId) userStep.userId = opts.userId;
return userStep;
}
export function buildNotionTranscript(
messages: NotionMessage[],
notionModel?: string
opts: {
notionModel?: string;
spaceId?: string;
userId?: string;
} = {}
): Array<Record<string, unknown>> {
const entries: Array<Record<string, unknown>> = [];
const trimmedModel = typeof notionModel === "string" ? notionModel.trim() : "";
const trimmedModel = typeof opts.notionModel === "string" ? opts.notionModel.trim() : "";
const model = trimmedModel && trimmedModel !== "notion-ai" ? trimmedModel : "";
if (model) {
entries.push({
id: randomUUID(),
type: "config",
value: {
type: "workflow",
model,
modelFromUser: true,
useWebSearch: false,
searchScopes: [{ type: "everything" }],
},
});
}
const now = isoNow();
const contextValue = buildNotionContextValue({ spaceId: opts.spaceId, userId: opts.userId, now });
const entries: Array<Record<string, unknown>> = [
buildNotionConfigStep(model),
{ id: randomUUID(), type: "context", value: contextValue },
];
for (const m of messages) {
if (typeof m?.content !== "string" || m.content.length === 0) continue;
entries.push({
id: randomUUID(),
type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human",
value: [[m.content]],
});
const step = buildNotionMessageStep(m, contextValue, { userId: opts.userId, now });
if (step) entries.push(step);
}
return entries;
}
/** Strip Notion's `<lang primary="…"/>` prefix and similar noise from answers. */
export function sanitizeNotionAssistantText(text: string): string {
if (!text) return "";
let clean = text.replace(/^\uFEFF/, "").trim();
// Self-closing or paired lang tags at the start (and anywhere).
clean = clean.replace(/<\/?lang\b[^>]*\/?>/gi, "");
clean = clean.replace(/<\/lang>/gi, "");
// Incomplete leading <lang… without close
if (/^<lang\b/i.test(clean) && !clean.includes(">")) return "";
return clean.trim();
}
/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
function extractRichText(value: unknown): string {
if (!Array.isArray(value)) return "";
@@ -170,34 +268,229 @@ function extractRichText(value: unknown): string {
.join("");
}
function extractAgentInferenceText(value: unknown): string {
if (!Array.isArray(value)) return "";
const parts: string[] = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const part = item as Record<string, unknown>;
const t = typeof part.type === "string" ? part.type.toLowerCase() : "";
if (t === "text" && typeof part.content === "string" && part.content) {
parts.push(part.content);
}
}
return parts.join("");
}
/** Unwraps `thread_message[key].value.value.step` from a Notion record-map entry. */
function extractThreadMessageStep(msg: unknown): Record<string, unknown> | null {
if (!msg || typeof msg !== "object") return null;
const valueWrapper = (msg as Record<string, unknown>).value;
if (!valueWrapper || typeof valueWrapper !== "object") return null;
const inner = (valueWrapper as Record<string, unknown>).value;
if (!inner || typeof inner !== "object") return null;
const step = (inner as Record<string, unknown>).step;
if (!step || typeof step !== "object") return null;
return step as Record<string, unknown>;
}
/** Extracts the text carried by a single thread-message step, or "" if none. */
function extractStepText(stepObj: Record<string, unknown>): string {
const stepType = typeof stepObj.type === "string" ? stepObj.type : "";
if (stepType === "agent-inference") {
return extractAgentInferenceText(stepObj.value);
}
if (stepType === "markdown-chat" && typeof stepObj.value === "string") {
return stepObj.value;
}
return "";
}
function extractFromRecordMap(recordMap: unknown): string {
if (!recordMap || typeof recordMap !== "object" || Array.isArray(recordMap)) return "";
const tm = (recordMap as Record<string, unknown>).thread_message;
if (!tm || typeof tm !== "object" || Array.isArray(tm)) return "";
let best = "";
for (const msg of Object.values(tm as Record<string, unknown>)) {
const stepObj = extractThreadMessageStep(msg);
if (!stepObj) continue;
const text = extractStepText(stepObj);
if (text && text.length >= best.length) best = text;
}
return best;
}
/**
* 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.
* Parse Notion's NDJSON `runInferenceTranscript` response body.
* Supports:
* 1. Legacy rich-text tuples on `value` (cumulative snapshots)
* 2. Modern patch-start / patch streams (text / markdown-chat ops)
* 3. Terminal record-map with agent-inference steps (authoritative final)
*/
/** Accumulator threaded through {@link parseNotionInferenceStream}'s line parsing. */
type NotionStreamState = {
lastLegacy: string;
lastPatchFinal: string;
lastIncremental: string;
lastRecordMap: string;
};
/** Applies one `patch` op (full text-part append / step append / incremental string) to state. */
/** Full agent-inference text-part append: `o:"a", p:".../value/-"`. */
function applyNotionValuePartAppend(v: unknown, state: NotionStreamState): void {
if (!v || typeof v !== "object" || Array.isArray(v)) return;
const part = v as Record<string, unknown>;
if (part.type === "text" && typeof part.content === "string" && part.content) {
state.lastPatchFinal = part.content;
}
if (part.type === "markdown-chat" && typeof part.value === "string" && part.value) {
state.lastPatchFinal = part.value;
}
}
/** Step append with markdown-chat / agent-inference: `o:"a", p:".../s/-"`. */
function applyNotionStepAppend(v: unknown, state: NotionStreamState): void {
if (!v || typeof v !== "object" || Array.isArray(v)) return;
const step = v as Record<string, unknown>;
if (step.type === "markdown-chat" && typeof step.value === "string" && step.value) {
state.lastPatchFinal = step.value;
}
if (step.type === "agent-inference") {
const text = extractAgentInferenceText(step.value);
if (text) state.lastPatchFinal = text;
}
}
function applyNotionPatchOp(rawOp: unknown, state: NotionStreamState): void {
if (!rawOp || typeof rawOp !== "object") return;
const op = rawOp as Record<string, unknown>;
const o = typeof op.o === "string" ? op.o : "";
const p = typeof op.p === "string" ? op.p : "";
const v = op.v;
if (o === "a" && p.endsWith("/value/-")) {
applyNotionValuePartAppend(v, state);
} else if (o === "a" && p.endsWith("/s/-")) {
applyNotionStepAppend(v, state);
} else if ((o === "x" || o === "p") && p.includes("/value") && typeof v === "string" && v) {
// Incremental string patches
state.lastIncremental += v;
}
}
/** Applies one parsed NDJSON record (markdown-chat / agent-inference / patch / record-map / legacy). */
function applyNotionStreamRecord(rec: Record<string, unknown>, state: NotionStreamState): void {
const type = typeof rec.type === "string" ? rec.type : "";
// 1) Direct markdown-chat event
if (type === "markdown-chat" && typeof rec.value === "string" && rec.value) {
state.lastPatchFinal = rec.value;
return;
}
// 2) Direct agent-inference event
if (type === "agent-inference") {
const text = extractAgentInferenceText(rec.value);
if (text) state.lastPatchFinal = text;
return;
}
// 3) Patch stream
if (type === "patch" && Array.isArray(rec.v)) {
for (const rawOp of rec.v) applyNotionPatchOp(rawOp, state);
return;
}
// 4) record-map terminal
if (type === "record-map" || rec.recordMap) {
const text = extractFromRecordMap(rec.recordMap || rec);
if (text) state.lastRecordMap = text;
return;
}
// 5) Legacy rich-text value (cumulative)
const rich = extractRichText(rec.value);
if (rich) state.lastLegacy = rich;
}
/** Parses one raw NDJSON line (trims / strips SSE `data:` prefix / JSON-parses) into state. */
function applyNotionStreamLine(rawLine: string, state: NotionStreamState): void {
const line = rawLine.trim();
if (!line || line === "[DONE]") return;
// Strip optional SSE "data:" prefix if a proxy rewrote it.
const payloadLine = line.startsWith("data:") ? line.slice(5).trim() : line;
if (!payloadLine) return;
let record: unknown;
try {
record = JSON.parse(payloadLine);
} catch {
return;
}
if (!record || typeof record !== "object" || Array.isArray(record)) return;
applyNotionStreamRecord(record as Record<string, unknown>, state);
}
/**
* Parse Notion's NDJSON `runInferenceTranscript` response body.
* Supports:
* 1. Legacy rich-text tuples on `value` (cumulative snapshots)
* 2. Modern patch-start / patch streams (text / markdown-chat ops)
* 3. Terminal record-map with agent-inference steps (authoritative final)
*/
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;
const state: NotionStreamState = {
lastLegacy: "",
lastPatchFinal: "",
lastIncremental: "",
lastRecordMap: "",
};
for (const rawLine of raw.split("\n")) {
applyNotionStreamLine(rawLine, state);
}
return lastText;
const candidates = [state.lastRecordMap, state.lastPatchFinal, state.lastIncremental, state.lastLegacy]
.map(sanitizeNotionAssistantText)
.filter(Boolean);
// Prefer the longest non-empty candidate; record-map usually wins.
return candidates.sort((a, b) => b.length - a.length)[0] || "";
}
function chatCompletionResponse(content: string, model: string) {
/**
* Notion's undocumented inference API does not return token usage.
* Emit a cheap char-based estimate so clients don't see a constant
* `USAGE_TOKEN_BUFFER` (default 2000) from buffering an all-zero stub.
* chatCore may still add the safety buffer on top of real estimates.
*/
export function estimateNotionUsage(
messages: NotionMessage[] | undefined,
content: string
): { prompt_tokens: number; completion_tokens: number; total_tokens: number; estimated: true } {
const promptText = (messages || [])
.map((m) => (typeof m?.content === "string" ? m.content : ""))
.join("\n");
// ~4 chars/token (English-ish); at least 1 when there is any text.
const prompt_tokens = promptText
? Math.max(1, Math.ceil(promptText.length / 4))
: 0;
const completion_tokens = content
? Math.max(1, Math.ceil(content.length / 4))
: 0;
return {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
estimated: true,
};
}
function chatCompletionResponse(
content: string,
model: string,
messages?: NotionMessage[]
) {
return new Response(
JSON.stringify({
id: `chatcmpl-notion-${Date.now()}`,
@@ -207,7 +500,7 @@ function chatCompletionResponse(content: string, model: string) {
choices: [
{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" },
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
usage: estimateNotionUsage(messages, content),
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
@@ -240,6 +533,139 @@ function pseudoStreamResponse(content: string, model: string) {
});
}
function clientFacingModelId(model: unknown): string {
let clientFacingModel = typeof model === "string" ? model.trim() : "";
if (clientFacingModel.startsWith("notion-web/")) {
clientFacingModel = clientFacingModel.slice("notion-web/".length);
} else if (clientFacingModel.startsWith("nw/")) {
clientFacingModel = clientFacingModel.slice(3);
}
return clientFacingModel;
}
/** Resolves workspace + user (cached). Required for createThread payloads. */
async function resolveExecuteWorkspace(
cookie: string,
signal: ExecuteInput["signal"]
): Promise<{ spaceId: string; userId: string }> {
let spaceId = extractSpaceIdFromCookie(cookie);
let userId = extractUserIdFromCookie(cookie);
try {
const resolved = await resolveNotionRuntimeWorkspace({ cookie, signal });
if (!spaceId) spaceId = resolved.spaceId;
if (!userId) userId = resolved.userId;
} catch {
// keep cookie-derived values
}
return { spaceId, userId };
}
/** Live-verified working shape (createThread:false without threadId → 400 ValidationError). */
function buildNotionCreateThreadRequestBody(opts: {
spaceId: string;
userId: string;
threadId: string;
transcript: unknown;
}): Record<string, unknown> {
const { spaceId, threadId, transcript } = opts;
return {
traceId: randomUUID(),
spaceId,
threadId,
createThread: true,
generateTitle: true,
asPatchResponse: true,
isPartialTranscript: false,
saveAllThreadOperations: true,
setUnreadState: true,
createdSource: "ai_module",
threadType: "workflow",
transcript,
threadParentPointer: {
table: "space",
id: spaceId,
spaceId,
},
debugOverrides: {
annotationInferences: {},
cachedInferences: {},
emitAgentSearchExtractedResults: true,
emitInferences: false,
},
};
}
function buildNotionExecuteHeaders(opts: {
cookie: string;
spaceId: string;
userId: string;
}): Record<string, string> {
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Accept: "application/x-ndjson",
Cookie: opts.cookie,
Origin: BASE_URL,
Referer: `${BASE_URL}/ai`,
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
"x-notion-space-id": opts.spaceId,
"Accept-Language": "en-US,en;q=0.9",
};
if (opts.userId) reqHeaders["x-notion-active-user-header"] = opts.userId;
return reqHeaders;
}
/**
* Sends the createThread request to Notion and returns either the raw
* inference text or an error result — callers just check `.errorResult`.
*/
async function sendNotionInferenceRequest(opts: {
reqBody: Record<string, unknown>;
reqHeaders: Record<string, string>;
signal: ExecuteInput["signal"];
}): Promise<{ rawText?: string; errorResult?: ReturnType<typeof makeErrorResult> }> {
const { reqBody, reqHeaders, signal } = opts;
let upstream: Response;
try {
upstream = await fetch(NOTION_URL, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
});
} catch (err) {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
if (upstream.status === 401 || upstream.status === 403) {
return {
errorResult: 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 {
errorResult: makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL),
};
}
return { rawText: await upstream.text() };
}
// ─── Executor ───────────────────────────────────────────────────────────────
export class NotionWebExecutor extends BaseExecutor {
@@ -266,67 +692,45 @@ export class NotionWebExecutor extends BaseExecutor {
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, modelId),
createThread: false,
asPatchResponse: true,
threadType: "workflow",
createdSource: "ai_module",
};
if (spaceId) reqBody.spaceId = spaceId;
const { spaceId, userId } = await resolveExecuteWorkspace(cookie, signal);
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) {
if (!spaceId) {
return makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
400,
"Could not resolve Notion spaceId — paste space_id from cookies or ensure token_v2 can call getSpaces",
body,
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
);
}
// Client may send notion-web/fable-5, nw/fable-5, fable-5, "Fable 5", or the
// legacy food codename (acai-budino-high). Notion only accepts the food codename
// on the wire; we echo the client-facing id in the OpenAI response.
const notionCodename = resolveNotionCodename(model);
const clientFacing = clientFacingModelId(model);
const modelId = clientFacing || notionCodename || "notion-ai";
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
return makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL);
}
const threadId = randomUUID();
const transcript = buildNotionTranscript(messages, {
notionModel: notionCodename || undefined,
spaceId,
userId: userId || undefined,
});
const rawText = await upstream.text();
const finalText = parseNotionInferenceStream(rawText);
const reqBody = buildNotionCreateThreadRequestBody({ spaceId, userId, threadId, transcript });
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId });
const { rawText, errorResult } = await sendNotionInferenceRequest({ reqBody, reqHeaders, signal });
if (errorResult) return errorResult;
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);
: chatCompletionResponse(finalText, modelId, messages);
return { response, url: NOTION_URL, headers: reqHeaders, transformedBody: reqBody };
}

View File

@@ -31,6 +31,35 @@ const DEFAULT_DEPS: ClientUsageBufferDeps = {
estimateUsage: defaultEstimateUsage,
};
/** True when a usage object is present but every token field is zero/absent.
* Web/unofficial providers often emit `{prompt_tokens:0,completion_tokens:0,total_tokens:0}`
* because the upstream has no metering. Treating that as "has usage" makes
* `addBufferToUsage` turn zeros into a constant `USAGE_TOKEN_BUFFER` (default 2000),
* so every request shows exactly 2000 tokens. Prefer estimating instead. */
function isEmptyUsage(usage: unknown): boolean {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return true;
const u = usage as Record<string, unknown>;
const fields = [
"prompt_tokens",
"completion_tokens",
"total_tokens",
"input_tokens",
"output_tokens",
"promptTokenCount",
"candidatesTokenCount",
"totalTokenCount",
];
let sawNumber = false;
for (const key of fields) {
const v = u[key];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
sawNumber = true;
if (v > 0) return false;
}
// No positive counts (or no numeric fields at all) → treat as empty.
return true;
}
export function applyClientUsageBuffer(
translatedResponse: ResponseLike,
body: unknown,
@@ -38,11 +67,12 @@ export function applyClientUsageBuffer(
deps: ClientUsageBufferDeps = DEFAULT_DEPS
): void {
// Add buffer and filter usage for client (to prevent CLI context errors)
if (translatedResponse?.usage) {
if (translatedResponse?.usage && !isEmptyUsage(translatedResponse.usage)) {
const buffered = deps.addBufferToUsage(translatedResponse.usage);
translatedResponse.usage = deps.filterUsageForFormat(buffered, clientResponseFormat);
} else {
// Fallback: estimate usage when provider returned no usage block
// (or an all-zero stub — common for cookie/web reverse-engineered providers).
const contentLength = JSON.stringify(
translatedResponse?.choices?.[0]?.message?.content || ""
).length;

View File

@@ -30,6 +30,9 @@ const ALLOWED_USAGE_FIELDS = new Set([
"cached_tokens",
"prompt_tokens_details",
"completion_tokens_details",
// Keep through sanitize → applyClientUsageBuffer so heuristic web usage is
// not inflated by the default USAGE_TOKEN_BUFFER (2000).
"estimated",
]);
const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([
"input_tokens",

View File

@@ -7,50 +7,79 @@
* build the cookie/headers/body the models-discovery route needs.
*/
const NOTION_APP_ORIGIN = "https://www.notion.so";
// Browser AI surface uses app.notion.com (live capture 2026-07-19). www.notion.so
// still works for many paths but can return a different space default / cookie
// domain behavior — prefer the same host the web picker uses.
const NOTION_APP_ORIGIN = "https://app.notion.com";
const NOTION_LEGACY_ORIGIN = "https://www.notion.so";
const NOTION_MODELS_URL = `${NOTION_APP_ORIGIN}/api/v3/getAvailableModels`;
const NOTION_SPACES_URL = `${NOTION_APP_ORIGIN}/api/v3/getSpaces`;
const NOTION_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
/** Recent Notion web client version — accepted loosely but required by some paths. */
const NOTION_CLIENT_VERSION = "23.13.20260718.1805";
const NOTION_CLIENT_VERSION = "23.13.20260719.1125";
/** Cap how many workspaces we probe for AI models when space_id is omitted. */
const NOTION_MAX_SPACE_PROBE = 8;
/** Cache auto-selected workspace per token so chat/inference reuses discovery. */
const NOTION_SPACE_CACHE = new Map<string, { spaceId: string; userId: string; expiresAt: number }>();
const NOTION_SPACE_CACHE_TTL_MS = 30 * 60 * 1000;
function notionTokenCacheKey(cookie: string): string {
// Prefer the token_v2 value only — ignore optional space/user parts.
return readCookieValue(cookie, "token_v2") || normalizeNotionWebCookie(cookie);
}
export type NotionDiscoveredModel = {
/**
* Catalog / OpenAI-compatible model id shown to clients.
* Prefer the web picker label slug (e.g. `fable-5`, `gpt-5.6-sol`) so users
* never have to choose Notion's internal food codenames.
*/
id: string;
/** Human label from Notion's AI picker (`modelMessage`), e.g. "Fable 5". */
name: string;
owned_by: string;
supportsReasoning?: boolean;
disabled?: boolean;
/**
* Internal Notion `model` codename for `runInferenceTranscript`
* (e.g. `acai-budino-high`). When omitted, `id` is the codename itself
* (rare; only when no display label was available).
*/
notionCodename?: string;
};
/** Offline fallback when getAvailableModels is unreachable (seeded from live picker). */
/**
* Offline fallback when getAvailableModels is unreachable (seeded from live picker).
* Catalog ids use real web-picker labels; `notionCodename` is what the API accepts.
*/
export const NOTION_WEB_FALLBACK_MODELS: NotionDiscoveredModel[] = [
{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" },
{ id: "orange-mousse", name: "GPT-5.6 Sol", owned_by: "openai" },
{ id: "orchid-muffin", name: "GPT-5.6 Terra", owned_by: "openai" },
{ id: "olive-jellyroll", name: "GPT-5.6 Luna", owned_by: "openai" },
{ id: "oatmeal-cookie", name: "GPT-5.2", owned_by: "openai" },
{ id: "oval-kumquat-medium", name: "GPT-5.4", owned_by: "openai" },
{ id: "opal-quince-medium", name: "GPT-5.5", owned_by: "openai" },
{ id: "oregon-grape-medium", name: "GPT-5.4 Mini", owned_by: "openai" },
{ id: "otaheite-apple-medium", name: "GPT-5.4 Nano", owned_by: "openai" },
{ id: "vertex-gemini-3.5-flash", name: "Gemini 3.5 Flash", owned_by: "gemini" },
{ id: "gingerbread", name: "Gemini 3 Flash", owned_by: "gemini" },
{ id: "galette-medium-thinking", name: "Gemini 3.1 Pro", owned_by: "gemini" },
{ id: "almond-croissant-low", name: "Sonnet 4.6", owned_by: "anthropic" },
{ id: "angel-cake-high", name: "Sonnet 5", owned_by: "anthropic" },
{ id: "avocado-froyo-medium", name: "Opus 4.6", owned_by: "anthropic" },
{ id: "apricot-sorbet-high", name: "Opus 4.7", owned_by: "anthropic" },
{ id: "ambrosia-tart-high", name: "Opus 4.8", owned_by: "anthropic" },
{ id: "anthropic-haiku-4.5", name: "Haiku 4.5", owned_by: "anthropic" },
{ id: "acai-budino-high", name: "Fable 5", owned_by: "anthropic" },
{ id: "fireworks-kimi-k2.6", name: "Kimi K2.6", owned_by: "mystery" },
{ id: "fireworks-kimi-k2.7", name: "Kimi K2.7 Code", owned_by: "mystery" },
{ id: "baseten-deepseek-v4-pro", name: "DeepSeek V4 Pro", owned_by: "mystery" },
{ id: "baseten-glm-5.2", name: "GLM 5.2", owned_by: "mystery" },
{ id: "xigua-mochi-medium", name: "Grok 4.3", owned_by: "xai" },
{ id: "strawberry-whoopiepie", name: "Grok 4.5", owned_by: "xai" },
{ id: "xinomavro-cake", name: "Grok Build 0.1", owned_by: "xai" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", owned_by: "openai", notionCodename: "orange-mousse" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", owned_by: "openai", notionCodename: "orchid-muffin" },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", owned_by: "openai", notionCodename: "olive-jellyroll" },
{ id: "gpt-5.2", name: "GPT-5.2", owned_by: "openai", notionCodename: "oatmeal-cookie" },
{ id: "gpt-5.4", name: "GPT-5.4", owned_by: "openai", notionCodename: "oval-kumquat-medium" },
{ id: "gpt-5.5", name: "GPT-5.5", owned_by: "openai", notionCodename: "opal-quince-medium" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", owned_by: "openai", notionCodename: "oregon-grape-medium" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", owned_by: "openai", notionCodename: "otaheite-apple-medium" },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", owned_by: "gemini", notionCodename: "vertex-gemini-3.5-flash" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", owned_by: "gemini", notionCodename: "gingerbread" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", owned_by: "gemini", notionCodename: "galette-medium-thinking" },
{ id: "sonnet-4.6", name: "Sonnet 4.6", owned_by: "anthropic", notionCodename: "almond-croissant-low" },
{ id: "sonnet-5", name: "Sonnet 5", owned_by: "anthropic", notionCodename: "angel-cake-high" },
{ id: "opus-4.6", name: "Opus 4.6", owned_by: "anthropic", notionCodename: "avocado-froyo-medium" },
{ id: "opus-4.7", name: "Opus 4.7", owned_by: "anthropic", notionCodename: "apricot-sorbet-high" },
{ id: "opus-4.8", name: "Opus 4.8", owned_by: "anthropic", notionCodename: "ambrosia-tart-high" },
{ id: "haiku-4.5", name: "Haiku 4.5", owned_by: "anthropic", notionCodename: "anthropic-haiku-4.5" },
{ id: "fable-5", name: "Fable 5", owned_by: "anthropic", notionCodename: "acai-budino-high" },
{ id: "kimi-k2.6", name: "Kimi K2.6", owned_by: "mystery", notionCodename: "fireworks-kimi-k2.6" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", owned_by: "mystery", notionCodename: "fireworks-kimi-k2.7" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", owned_by: "mystery", notionCodename: "baseten-deepseek-v4-pro" },
{ id: "glm-5.2", name: "GLM 5.2", owned_by: "mystery", notionCodename: "baseten-glm-5.2" },
{ id: "grok-4.3", name: "Grok 4.3", owned_by: "xai", notionCodename: "xigua-mochi-medium" },
{ id: "grok-4.5", name: "Grok 4.5", owned_by: "xai", notionCodename: "strawberry-whoopiepie" },
{ id: "grok-build-0.1", name: "Grok Build 0.1", owned_by: "xai", notionCodename: "xinomavro-cake" },
];
/** Normalize a pasted credential to a Cookie header string. */
@@ -104,9 +133,101 @@ function rowSupportsReasoning(row: Record<string, unknown>): boolean {
return Array.isArray(efforts) && efforts.length > 0;
}
/**
* Slugify Notion's human picker label ("GPT-5.6 Sol" → "gpt-5.6-sol") so
* OpenAI-compatible clients can request a readable id as well as the food
* codename the runInferenceTranscript API actually needs.
*/
export function slugifyNotionDisplayName(name: string): string {
// Keep dots so versioned labels stay readable ("GPT-5.6 Sol" → "gpt-5.6-sol",
// not "gpt-5-6-sol"). Collapse other punctuation/spaces to single hyphens.
return String(name || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9.]+/g, "-")
.replace(/\.{2,}/g, ".")
.replace(/^[-.]+|[-.]+$/g, "");
}
/**
* Resolve the catalog id for a Notion model: prefer the web-picker label slug
* (`fable-5`) over the internal food codename (`acai-budino-high`).
*/
export function catalogIdForNotionModel(codename: string, displayName: string): string {
const slug = slugifyNotionDisplayName(displayName);
if (slug && slug !== "notion-ai") return slug;
return codename;
}
/** Disabled / plan-locked row from getAvailableModels (e.g. Fable 5). */
export type NotionDisabledModelSummary = {
id: string;
name: string;
notionCodename: string;
reason: string;
};
/**
* Collect models Notion returned with `isDisabled: true` (not listed in the
* OpenAI catalog). Used for warnings — e.g. Fable 5 with
* `disabledReason: business_or_enterprise_plan_required`.
*/
export function listNotionDisabledModels(data: unknown): NotionDisabledModelSummary[] {
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
const list = (data as { models?: unknown }).models;
if (!Array.isArray(list)) return [];
const out: NotionDisabledModelSummary[] = [];
const seen = new Set<string>();
for (const entry of list) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const row = entry as Record<string, unknown>;
if (row.isDisabled !== true) continue;
const codename = typeof row.model === "string" ? row.model.trim() : "";
if (!codename || seen.has(codename)) continue;
seen.add(codename);
const name = trimmedOrFallback(row.modelMessage, codename);
const reason =
typeof row.disabledReason === "string" && row.disabledReason.trim()
? row.disabledReason.trim()
: "disabled";
out.push({
id: catalogIdForNotionModel(codename, name),
name,
notionCodename: codename,
reason,
});
}
return out;
}
/** Human-readable warning for disabled/plan-locked models (empty when none). */
export function formatNotionDisabledModelsWarning(
disabled: readonly NotionDisabledModelSummary[]
): string {
if (!disabled.length) return "";
const parts = disabled.map((d) => {
const reason = d.reason.replace(/_/g, " ");
return `${d.name} (${reason})`;
});
return (
`Notion hid ${disabled.length} model(s) as unavailable for this account/workspace: ` +
`${parts.join("; ")}. ` +
`They appear in the web picker only when your plan unlocks them ` +
`(e.g. Fable 5 requires a Notion Business or Enterprise plan).`
);
}
/**
* Parse one getAvailableModels list entry into a model, or `null` when the entry
* should be skipped (disabled, malformed, or a duplicate id already in `seen`).
* should be skipped (disabled, malformed, or a duplicate already in `seen`).
*
* Plan-locked models (e.g. Fable 5 with `isDisabled: true` +
* `disabledReason: business_or_enterprise_plan_required`) are skipped — they
* cannot be used for inference. See `listNotionDisabledModels` for diagnostics.
*
* Catalog `id` is the real picker label slug; `notionCodename` is what
* runInferenceTranscript requires.
*/
function parseNotionModelEntry(
entry: unknown,
@@ -114,20 +235,43 @@ function parseNotionModelEntry(
): NotionDiscoveredModel | null {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const row = entry as Record<string, unknown>;
// Notion still returns plan-locked models (Fable 5) with isDisabled=true.
// Listing them in /v1/models would invite failed chat requests.
if (row.isDisabled === true) return null;
const id = typeof row.model === "string" ? row.model.trim() : "";
if (!id || seen.has(id)) return null;
const codename = typeof row.model === "string" ? row.model.trim() : "";
if (!codename) return null;
const name = trimmedOrFallback(row.modelMessage, codename);
const catalogId = catalogIdForNotionModel(codename, name);
// Dedupe on both catalog id and codename so a second row with the same
// label or the same food codename is not listed twice.
if (seen.has(catalogId) || seen.has(codename)) return null;
seen.add(catalogId);
seen.add(codename);
seen.add(id);
return {
id,
name: trimmedOrFallback(row.modelMessage, id),
id: catalogId,
name,
owned_by: trimmedOrFallback(row.modelFamily, "notion"),
...(catalogId !== codename ? { notionCodename: codename } : {}),
...(rowSupportsReasoning(row) ? { supportsReasoning: true } : {}),
};
}
/**
* Identity helper kept for call-site stability. Catalog entries are already
* primary-friendly (real labels); food codenames are NOT dual-listed so
* `/v1/models` and the UI show "fable-5" / "Fable 5" instead of "acai-budino-high".
* Inference still accepts codenames via `resolveNotionCodename`.
*/
export function withFriendlyNotionAliases(
models: NotionDiscoveredModel[]
): NotionDiscoveredModel[] {
return models;
}
/** Ensure a stable default id always exists for clients that still request notion-ai. */
function withDefaultNotionModel(
out: NotionDiscoveredModel[],
@@ -139,8 +283,8 @@ function withDefaultNotionModel(
/**
* Parse getAvailableModels JSON into OpenAI-style model entries.
* Skips disabled models; prefers display `modelMessage` as name and internal
* `model` codename as id (what runInferenceTranscript expects).
* Skips disabled models. Catalog id = web picker label slug; name = modelMessage;
* notionCodename = internal food codename for runInferenceTranscript.
*/
export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel[] {
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
@@ -154,7 +298,7 @@ export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel
if (model) out.push(model);
}
return withDefaultNotionModel(out, seen);
return withFriendlyNotionAliases(withDefaultNotionModel(out, seen));
}
export function buildNotionModelsDiscoveryHeaders(token: string): Record<string, string> {
@@ -186,48 +330,61 @@ export function getNotionModelsDiscoveryUrl(): string {
return NOTION_MODELS_URL;
}
/** Workspace candidates extracted from getSpaces (user id + space ids). */
export type NotionWorkspaceCandidates = {
userId: string;
spaceIds: string[];
};
/**
* Try to resolve a workspace spaceId from getSpaces when the cookie has none.
* Returns "" on any failure (caller falls back to local catalog).
* Parse getSpaces JSON into a stable userId (top-level map key) and all space ids.
* Shape: `{ [userId]: { space: { [spaceId]: {...} } } }`.
*/
export async function resolveNotionSpaceIdFromGetSpaces(
cookie: string,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const normalized = normalizeNotionWebCookie(cookie);
if (!normalized) return "";
try {
const res = await fetchImpl(NOTION_SPACES_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: normalized,
origin: NOTION_APP_ORIGIN,
referer: `${NOTION_APP_ORIGIN}/`,
"user-agent": NOTION_USER_AGENT,
},
body: "{}",
});
if (!res.ok) return "";
const data = (await res.json()) as unknown;
return pickFirstSpaceId(data);
} catch {
return "";
/**
* Reads one `{ [userKey]: { space: { [spaceId]: ... } } }` entry, pushing new
* space ids into `spaceIds` and returning the userId it carries (if any).
*/
function collectUserSpaceEntry(key: string, value: unknown, spaceIds: string[]): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const spaceMap = (value as Record<string, unknown>).space;
if (!spaceMap || typeof spaceMap !== "object" || Array.isArray(spaceMap)) return "";
for (const id of Object.keys(spaceMap as Record<string, unknown>)) {
if (id && !spaceIds.includes(id)) spaceIds.push(id);
}
return key && !key.includes(" ") ? key : "";
}
/** Fallback spaceId extraction from the flat `{ spaces: [] }` / `{ spaceIds: [] }` shapes. */
function collectFallbackSpaceIds(root: Record<string, unknown>, spaceIds: string[]): void {
const fromArray = pickSpaceIdFromSpacesArray(root.spaces);
if (fromArray) spaceIds.push(fromArray);
const fromIds = pickSpaceIdFromSpaceIdsArray(root.spaceIds);
if (fromIds && !spaceIds.includes(fromIds)) spaceIds.push(fromIds);
}
export function parseNotionGetSpaces(data: unknown): NotionWorkspaceCandidates {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return { userId: "", spaceIds: [] };
}
const root = data as Record<string, unknown>;
const spaceIds: string[] = [];
let userId = "";
for (const [key, value] of Object.entries(root)) {
const entryUserId = collectUserSpaceEntry(key, value, spaceIds);
if (!userId && entryUserId) userId = entryUserId;
}
if (spaceIds.length === 0) {
collectFallbackSpaceIds(root, spaceIds);
}
return { userId, spaceIds };
}
/** Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } */
function pickSpaceIdFromUserMap(root: Record<string, unknown>): string {
for (const value of Object.values(root)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const spaceMap = (value as Record<string, unknown>).space;
if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) {
const ids = Object.keys(spaceMap as Record<string, unknown>);
if (ids.length > 0) return ids[0];
}
}
return "";
return parseNotionGetSpaces(root).spaceIds[0] || "";
}
/** Flat shape: { spaces: [{ id }] } */
@@ -248,14 +405,257 @@ function pickSpaceIdFromSpaceIdsArray(spaceIds: unknown): string {
/** Best-effort spaceId extraction from getSpaces response shapes. */
export function pickFirstSpaceId(data: unknown): string {
if (!data || typeof data !== "object") return "";
const root = data as Record<string, unknown>;
return parseNotionGetSpaces(data).spaceIds[0] || "";
}
return (
pickSpaceIdFromUserMap(root) ||
pickSpaceIdFromSpacesArray(root.spaces) ||
pickSpaceIdFromSpaceIdsArray(root.spaceIds)
);
function buildNotionBrowserHeaders(cookie: string, userId?: string): Record<string, string> {
const headers: Record<string, string> = {
accept: "*/*",
"content-type": "application/json",
"user-agent": NOTION_USER_AGENT,
origin: NOTION_APP_ORIGIN,
referer: `${NOTION_APP_ORIGIN}/ai`,
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
cookie,
};
if (userId) headers["x-notion-active-user-header"] = userId;
return headers;
}
/**
* Load workspace candidates from getSpaces using browser-like headers.
* Does not require space_id in the cookie — only token_v2.
*/
export async function fetchNotionWorkspaceCandidates(
cookie: string,
fetchImpl: typeof fetch = fetch
): Promise<NotionWorkspaceCandidates> {
const normalized = normalizeNotionWebCookie(cookie);
if (!normalized) return { userId: "", spaceIds: [] };
const userFromCookie = extractNotionUserIdFromCookie(normalized);
try {
const res = await fetchImpl(NOTION_SPACES_URL, {
method: "POST",
headers: buildNotionBrowserHeaders(normalized, userFromCookie || undefined),
body: "{}",
});
if (!res.ok) return { userId: userFromCookie, spaceIds: [] };
const data = (await res.json()) as unknown;
const parsed = parseNotionGetSpaces(data);
return {
userId: userFromCookie || parsed.userId,
spaceIds: parsed.spaceIds,
};
} catch {
return { userId: userFromCookie, spaceIds: [] };
}
}
/**
* Try to resolve a workspace spaceId from getSpaces when the cookie has none.
* Returns "" on any failure (caller falls back to local catalog).
*/
export async function resolveNotionSpaceIdFromGetSpaces(
cookie: string,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const { spaceIds } = await fetchNotionWorkspaceCandidates(cookie, fetchImpl);
return spaceIds[0] || "";
}
/**
* Probe getAvailableModels for each candidate space and pick the best catalog.
*
* IMPORTANT: do NOT early-exit on the first "good enough" workspace. Real accounts
* often have a personal space (many models, Fable plan-locked) and a Business/
* AI space (Fable enabled). Exiting early permanently hides Fable 5.
*/
export async function selectBestNotionSpaceId(opts: {
cookie: string;
spaceIds: string[];
userId?: string;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}): Promise<{ spaceId: string; models: NotionDiscoveredModel[]; raw: unknown } | null> {
const fetchImpl = opts.fetchImpl ?? fetch;
const cookie = normalizeNotionWebCookie(opts.cookie);
if (!cookie || opts.spaceIds.length === 0) return null;
let best: { spaceId: string; models: NotionDiscoveredModel[]; raw: unknown; score: number } | null =
null;
for (const spaceId of opts.spaceIds.slice(0, NOTION_MAX_SPACE_PROBE)) {
if (!spaceId) continue;
try {
const headers = buildNotionBrowserHeaders(cookie, opts.userId || undefined);
headers["x-notion-space-id"] = spaceId;
const res = await fetchImpl(NOTION_MODELS_URL, {
method: "POST",
headers,
body: JSON.stringify({ spaceId }),
signal: opts.signal ?? undefined,
});
if (!res.ok) continue;
const raw = await res.json();
const models = parseNotionAvailableModels(raw);
const enabled = models.filter((m) => m.id !== "notion-ai").length;
// Prefer workspaces where plan-locked models (e.g. Fable 5) are actually enabled.
const disabled = listNotionDisabledModels(raw);
const fableLocked = disabled.some(
(d) => d.id === "fable-5" || d.notionCodename === "acai-budino-high"
);
const fableEnabled = models.some(
(m) => m.id === "fable-5" || m.notionCodename === "acai-budino-high"
);
// Score: enabled models, plus a large bonus for unlocked Fable (or no Fable lock).
let score = enabled * 10;
if (fableEnabled) score += 1000;
else if (fableLocked) score -= 50;
if (!best || score > best.score) {
best = { spaceId, models, raw, score };
}
} catch {
// try next space
}
}
return best
? { spaceId: best.spaceId, models: best.models, raw: best.raw }
: null;
}
/**
* Resolve the best workspace for a cookie that has no space_id.
* Cached so inference and model discovery share the same selection.
*/
export async function resolveNotionRuntimeWorkspace(opts: {
cookie: string;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}): Promise<{ spaceId: string; userId: string; fromCache: boolean }> {
const cookie = normalizeNotionWebCookie(opts.cookie);
const explicit = extractSpaceIdFromNotionCookie(cookie);
const userId = extractNotionUserIdFromCookie(cookie);
if (explicit) {
return { spaceId: explicit, userId, fromCache: false };
}
const key = notionTokenCacheKey(cookie);
const cached = NOTION_SPACE_CACHE.get(key);
if (cached && cached.expiresAt > Date.now()) {
return { spaceId: cached.spaceId, userId: cached.userId || userId, fromCache: true };
}
const fetchImpl = opts.fetchImpl ?? fetch;
const candidates = await fetchNotionWorkspaceCandidates(cookie, fetchImpl);
const best = await selectBestNotionSpaceId({
cookie,
spaceIds: candidates.spaceIds,
userId: userId || candidates.userId || undefined,
fetchImpl,
signal: opts.signal,
});
if (!best?.spaceId) {
return { spaceId: candidates.spaceIds[0] || "", userId: userId || candidates.userId, fromCache: false };
}
const resolvedUser = userId || candidates.userId;
NOTION_SPACE_CACHE.set(key, {
spaceId: best.spaceId,
userId: resolvedUser,
expiresAt: Date.now() + NOTION_SPACE_CACHE_TTL_MS,
});
return { spaceId: best.spaceId, userId: resolvedUser, fromCache: false };
}
/** Discovery result shared by the explicit-space and getSpaces-fallback discovery paths. */
type NotionDiscoveryResult = {
spaceId: string;
models: NotionDiscoveredModel[];
data: unknown;
spaceIdFromGetSpaces: boolean;
};
/**
* Discovery when the cookie already carries an explicit space_id — a single
* targeted getAvailableModels call.
*/
async function discoverNotionModelsForExplicitSpace(opts: {
cookie: string;
spaceId: string;
userId: string;
fetchImpl: typeof fetch;
signal?: AbortSignal | null;
}): Promise<NotionDiscoveryResult> {
const headers = buildNotionModelsDiscoveryHeaders(opts.cookie);
headers["x-notion-space-id"] = opts.spaceId;
if (opts.userId) headers["x-notion-active-user-header"] = opts.userId;
const res = await opts.fetchImpl(NOTION_MODELS_URL, {
method: "POST",
headers,
body: JSON.stringify({ spaceId: opts.spaceId }),
signal: opts.signal ?? undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`getAvailableModels failed (${res.status}): ${text.slice(0, 200)}`);
}
const data = await res.json();
return {
spaceId: opts.spaceId,
models: parseNotionAvailableModels(data),
data,
spaceIdFromGetSpaces: false,
};
}
/**
* Discovery when the cookie has no space_id — resolve via getSpaces, then probe
* workspaces for the richest AI model list (so operators only need the raw
* token_v2 value). Prefer the Business/AI workspace where Fable is unlocked
* over a personal space where Fable is plan-locked (same model count otherwise).
* Caches the winning workspace so inference reuses the same selection as /models.
*/
async function discoverNotionModelsViaGetSpaces(opts: {
cookie: string;
userId: string;
fetchImpl: typeof fetch;
signal?: AbortSignal | null;
}): Promise<NotionDiscoveryResult> {
const candidates = await fetchNotionWorkspaceCandidates(opts.cookie, opts.fetchImpl);
const userId = opts.userId || candidates.userId;
if (candidates.spaceIds.length === 0) {
throw new Error(
"Could not resolve a Notion workspace from token_v2 alone. " +
"Re-copy a fresh token_v2 from app.notion.com (Application → Cookies), " +
"or optionally paste space_id from Network → getAvailableModels → x-notion-space-id."
);
}
const best = await selectBestNotionSpaceId({
cookie: opts.cookie,
spaceIds: candidates.spaceIds,
userId: userId || undefined,
fetchImpl: opts.fetchImpl,
signal: opts.signal,
});
if (!best || best.models.length === 0) {
throw new Error(
"getAvailableModels returned no enabled models for any workspace visible to this token"
);
}
const key = notionTokenCacheKey(opts.cookie);
NOTION_SPACE_CACHE.set(key, {
spaceId: best.spaceId,
userId: userId || "",
expiresAt: Date.now() + NOTION_SPACE_CACHE_TTL_MS,
});
return { spaceId: best.spaceId, models: best.models, data: best.raw, spaceIdFromGetSpaces: true };
}
/**
@@ -266,54 +666,119 @@ export async function discoverNotionWebModels(opts: {
token: string;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}): Promise<{ models: NotionDiscoveredModel[]; spaceId: string; source: "api" }> {
}): Promise<{
models: NotionDiscoveredModel[];
spaceId: string;
source: "api";
/** Populated when Notion returned plan-locked / disabled models (e.g. Fable 5). */
warning?: string;
disabledModels?: NotionDisabledModelSummary[];
/** True when spaceId came from getSpaces because cookie lacked space_id. */
spaceIdFromGetSpaces?: boolean;
}> {
const fetchImpl = opts.fetchImpl ?? fetch;
const cookie = normalizeNotionWebCookie(opts.token);
if (!cookie) {
throw new Error("Missing Notion token_v2 cookie");
}
let spaceId = extractSpaceIdFromNotionCookie(cookie);
if (!spaceId) {
spaceId = await resolveNotionSpaceIdFromGetSpaces(cookie, fetchImpl);
}
if (!spaceId) {
throw new Error(
"Missing Notion spaceId — include space_id=… in the cookie header or re-login so getSpaces can resolve a workspace"
);
}
const spaceIdFromCookie = extractSpaceIdFromNotionCookie(cookie);
const userId = extractNotionUserIdFromCookie(cookie);
// Prefer the canonical space id extractor (case-insensitive) so we do not
// append a second space_id= when the cookie used spaceId= or mixed case.
const cookieForHeaders = extractSpaceIdFromNotionCookie(cookie)
? cookie
: `${cookie}; space_id=${spaceId}`;
const headers = buildNotionModelsDiscoveryHeaders(cookieForHeaders);
headers["x-notion-space-id"] = spaceId;
const result = spaceIdFromCookie
? await discoverNotionModelsForExplicitSpace({
cookie,
spaceId: spaceIdFromCookie,
userId,
fetchImpl,
signal: opts.signal,
})
: await discoverNotionModelsViaGetSpaces({ cookie, userId, fetchImpl, signal: opts.signal });
const res = await fetchImpl(NOTION_MODELS_URL, {
method: "POST",
headers,
body: JSON.stringify({ spaceId }),
signal: opts.signal ?? undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`getAvailableModels failed (${res.status}): ${text.slice(0, 200)}`);
}
const data = await res.json();
const models = parseNotionAvailableModels(data);
if (models.length === 0) {
if (result.models.length === 0) {
throw new Error("getAvailableModels returned no enabled models");
}
return { models, spaceId, source: "api" };
const disabledModels = listNotionDisabledModels(result.data);
const warnings: string[] = [];
const disabledWarning = formatNotionDisabledModelsWarning(disabledModels);
if (disabledWarning) warnings.push(disabledWarning);
// Auto space selection is silent when it works — no scary "paste space_id" nags.
return {
models: result.models,
spaceId: result.spaceId,
source: "api",
disabledModels,
spaceIdFromGetSpaces: result.spaceIdFromGetSpaces,
...(warnings.length ? { warning: warnings.join(" ") } : {}),
};
}
/** Effective food codename for a catalog model entry. */
export function notionCodenameOf(model: NotionDiscoveredModel): string {
if (!model?.id || model.id === "notion-ai") return "";
return (model.notionCodename || model.id).trim();
}
/**
* Build a reverse map of friendly labels/slugs/food-codenames → Notion food codenames.
* Used by the executor so clients can request either id style.
*/
export function buildNotionFriendlyToCodenameMap(
models: readonly NotionDiscoveredModel[] = NOTION_WEB_FALLBACK_MODELS
): Map<string, string> {
const map = new Map<string, string>();
for (const m of models) {
if (!m?.id || m.id === "notion-ai") continue;
const codename = notionCodenameOf(m);
if (!codename) continue;
// Catalog id (friendly slug) + its lowercase form.
map.set(m.id, codename);
map.set(m.id.toLowerCase(), codename);
// Food codename itself (power users / cached clients).
map.set(codename, codename);
map.set(codename.toLowerCase(), codename);
// Display label + slug (e.g. "Fable 5" / "fable-5").
if (m.name) {
map.set(m.name.toLowerCase(), codename);
const slug = slugifyNotionDisplayName(m.name);
if (slug) map.set(slug, codename);
}
}
return map;
}
/**
* Normalize a client model id to the codename Notion's transcript API expects.
* Accepts provider prefixes (notion-web/, nw/), food codenames, display names,
* and slugified labels (fable-5, gpt-5.6-sol).
*/
export function resolveNotionCodename(
model: string | undefined | null,
extraModels: readonly NotionDiscoveredModel[] = []
): string {
let m = typeof model === "string" ? model.trim() : "";
if (!m || m === "notion-ai") return "";
// Strip provider prefixes added by /v1/models catalog.
if (m.startsWith("notion-web/")) m = m.slice("notion-web/".length);
else if (m.startsWith("nw/")) m = m.slice(3);
if (!m || m === "notion-ai") return "";
const map = buildNotionFriendlyToCodenameMap([
...NOTION_WEB_FALLBACK_MODELS,
...extraModels,
]);
// Unknown ids pass through as-is so a freshly discovered codename still works
// before the fallback table is updated.
return map.get(m) || map.get(m.toLowerCase()) || map.get(slugifyNotionDisplayName(m)) || m;
}
export {
NOTION_MODELS_URL,
NOTION_SPACES_URL,
NOTION_APP_ORIGIN,
NOTION_LEGACY_ORIGIN,
NOTION_CLIENT_VERSION,
};

View File

@@ -120,6 +120,13 @@ function getTimeString() {
export function addBufferToUsage(usage) {
if (!usage || typeof usage !== "object") return usage;
// Heuristic estimates (web/cookie providers with no upstream metering) should
// not get the safety buffer — otherwise a 6-token "hi"/"PONG" becomes a flat
// ~2000 for every request and looks like fake metering.
if ((usage as { estimated?: unknown }).estimated === true) {
return usage;
}
const buffer = getBufferTokens();
if (buffer === 0) return usage;

View File

@@ -568,7 +568,8 @@ export async function GET(
...init,
}),
});
return buildApiDiscoveryResponse(discovery.models);
// Pass through plan-lock warnings (e.g. Fable 5 requires Business/Enterprise).
return buildApiDiscoveryResponse(discovery.models, discovery.warning);
} catch (error) {
console.log("Error fetching models from notion-web", {
error: error instanceof Error ? error.message : String(error),

View File

@@ -385,9 +385,8 @@ export const WEB_COOKIE_PROVIDERS = {
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
authHint:
"Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). " +
"Include `; space_id=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " +
"Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.",
"Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). " +
"Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional.",
},
};

View File

@@ -40,6 +40,18 @@ test("usage present → buffer then filter, mutates in place", () => {
assert.equal((resp.usage as Record<string, unknown>)._filtered, true);
});
test("all-zero usage stub → estimate (not constant buffer-only 2000)", () => {
const { deps, calls } = makeDeps();
const resp: Record<string, unknown> = {
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
choices: [{ message: { content: "PONG" } }],
};
applyClientUsageBuffer(resp, { messages: [{ role: "user", content: "hi" }] }, "openai", deps);
assert.equal(calls.buffer.length, 0, "must not buffer zeros into USAGE_TOKEN_BUFFER");
assert.equal(calls.estimate.length, 1);
assert.equal((resp.usage as Record<string, unknown>)._estimated, true);
});
test("no usage but content present → estimate then filter", () => {
const { deps, calls } = makeDeps();
const resp: Record<string, unknown> = {

View File

@@ -25,8 +25,12 @@ describe("NotionWebExecutor — registry consistency", () => {
const models = getModelsByProviderId("notion-web");
assert.ok(models.length >= 1);
assert.ok(models.some((m) => m.id === "notion-ai"));
// Seed catalog includes real Notion codenames (live discovery still preferred).
assert.ok(models.some((m) => m.id === "ambrosia-tart-high" || m.id === "orange-mousse"));
// Seed catalog uses real web-picker labels (fable-5 / gpt-5.6-sol), not food codenames.
assert.ok(models.some((m) => m.id === "fable-5" || m.id === "gpt-5.6-sol" || m.id === "opus-4.8"));
assert.equal(
models.some((m) => m.id === "ambrosia-tart-high" || m.id === "orange-mousse" || m.id === "acai-budino-high"),
false
);
});
});
@@ -64,22 +68,46 @@ describe("NotionWebExecutor — instantiation & auth errors", () => {
});
});
/** Cookie with space_id so execute() does not need a live getSpaces call. */
const COOKIE_WITH_SPACE = "token_v2=xyz; space_id=space-1; notion_user_id=user-1";
describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
it("posts the transcript to runInferenceTranscript with the cookie header and returns a chat.completion", async () => {
it("posts createThread + config/context/user 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;
let capturedBody: {
createThread?: boolean;
threadId?: string;
spaceId?: string;
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));
// Modern record-map response (live shape 2026-07-19).
const ndjson = [
JSON.stringify({ value: [["Hel"]] }),
JSON.stringify({ value: [["Hello"]] }),
JSON.stringify({ value: [["Hello there!"]] }),
JSON.stringify({ type: "patch-start", data: { s: [] } }),
JSON.stringify({
type: "record-map",
recordMap: {
thread_message: {
m1: {
value: {
value: {
step: {
type: "agent-inference",
value: [{ type: "text", content: '<lang primary="en-US"/>Hello there!' }],
},
},
},
},
},
},
}),
].join("\n");
return new Response(ndjson, { status: 200 });
}) as typeof fetch;
@@ -88,16 +116,23 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "abc123" },
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(capturedUrl, "https://www.notion.so/api/v3/runInferenceTranscript");
assert.equal(capturedHeaders.Cookie, "token_v2=abc123");
assert.equal(capturedUrl, "https://app.notion.com/api/v3/runInferenceTranscript");
assert.equal(capturedHeaders.Cookie, COOKIE_WITH_SPACE);
assert.equal(capturedHeaders["x-notion-space-id"], "space-1");
assert.equal(capturedHeaders["x-notion-active-user-header"], "user-1");
assert.ok(capturedBody);
// notion-ai default does not inject a config entry (server-side default model).
assert.equal(capturedBody.transcript[0].type, "human");
assert.deepEqual(capturedBody.transcript[0].value, [["hi"]]);
assert.equal(capturedBody.createThread, true);
assert.ok(typeof capturedBody.threadId === "string" && capturedBody.threadId.length > 0);
assert.equal(capturedBody.spaceId, "space-1");
// Transcript: config + context + user (system would fold into context).
assert.equal(capturedBody.transcript[0].type, "config");
assert.equal(capturedBody.transcript[1].type, "context");
assert.equal(capturedBody.transcript[2].type, "user");
assert.deepEqual(capturedBody.transcript[2].value, [["hi"]]);
assert.equal(result.response.status, 200);
const json = (await result.response.json()) as {
@@ -105,8 +140,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
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).
// Lang tag stripped; final assistant text kept.
assert.equal(json.choices[0].message.content, "Hello there!");
} finally {
globalThis.fetch = originalFetch;
@@ -115,8 +149,9 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
it("injects a config transcript entry with the selected Notion model codename", async () => {
const executor = new mod.NotionWebExecutor();
let capturedBody: { transcript: Array<{ type: string; value?: { model?: string } }> } | null =
null;
let capturedBody: {
transcript: Array<{ type: string; value?: { model?: string } }>;
} | null = null;
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
@@ -124,18 +159,83 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 });
}) as typeof fetch;
// Legacy food codename still accepted for power users / cached clients.
await executor.execute({
model: "orange-mousse",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=xyz; space_id=space-1" },
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.ok(capturedBody);
assert.equal(capturedBody.transcript[0].type, "config");
assert.equal(capturedBody.transcript[0].value?.model, "orange-mousse");
assert.equal(capturedBody.transcript[1].type, "human");
assert.equal(capturedBody.transcript[1].type, "context");
assert.equal(capturedBody.transcript[2].type, "user");
} finally {
globalThis.fetch = originalFetch;
}
});
it("resolves friendly slug / provider-prefixed model ids to the Notion food codename", async () => {
const executor = new mod.NotionWebExecutor();
let capturedBody: {
transcript: Array<{ type: string; value?: { model?: string } }>;
} | null = null;
let responseModel = "";
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
capturedBody = JSON.parse(String(opts.body));
return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 });
}) as typeof fetch;
const result = await executor.execute({
model: "notion-web/gpt-5.6-sol",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.ok(capturedBody);
assert.equal(capturedBody.transcript[0].type, "config");
// Wire protocol still uses the food codename.
assert.equal(capturedBody.transcript[0].value?.model, "orange-mousse");
// Response echoes the client-facing real model name.
const json = (await result.response.json()) as { model?: string };
responseModel = json.model || "";
assert.equal(responseModel, "gpt-5.6-sol");
} finally {
globalThis.fetch = originalFetch;
}
});
it("resolves fable-5 to acai-budino-high for the transcript config entry", async () => {
const executor = new mod.NotionWebExecutor();
let capturedBody: {
transcript: Array<{ type: string; value?: { model?: string } }>;
} | null = null;
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => {
capturedBody = JSON.parse(String(opts.body));
return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 });
}) as typeof fetch;
const result = await executor.execute({
model: "fable-5",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.ok(capturedBody);
assert.equal(capturedBody.transcript[0].value?.model, "acai-budino-high");
const json = (await result.response.json()) as { model?: string };
assert.equal(json.model, "fable-5");
} finally {
globalThis.fetch = originalFetch;
}
@@ -178,7 +278,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { apiKey: "token_v2=xyz" },
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
@@ -201,7 +301,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=xyz" },
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(result.response.status, 502);
@@ -221,7 +321,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=expired" },
credentials: { apiKey: "token_v2=expired; space_id=s1" },
signal: null,
} as never);
assert.equal(result.response.status, 403);
@@ -247,7 +347,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=xyz" },
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(result.response.status, 502);
@@ -280,40 +380,97 @@ describe("parseNotionInferenceStream", () => {
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"
);
it("prefers record-map agent-inference over empty patches and strips lang tags", () => {
const ndjson = [
JSON.stringify({ type: "patch-start", data: { s: [] } }),
JSON.stringify({
type: "record-map",
recordMap: {
thread_message: {
m1: {
value: {
value: {
step: {
type: "agent-inference",
value: [{ type: "text", content: '<lang primary="en-US"/>final' }],
},
},
},
},
},
},
}),
].join("\n");
assert.equal(parseNotionInferenceStream(ndjson), "final");
});
it("extracts text from patch value/- append ops", () => {
const ndjson = JSON.stringify({
type: "patch",
v: [{ o: "a", p: "/s/2/value/-", v: { type: "text", content: "from patch" } }],
});
assert.equal(parseNotionInferenceStream(ndjson), "from patch");
});
});
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" },
]);
it("maps roles to Notion transcript entry types (config+context+user+agent)", () => {
const transcript = buildNotionTranscript(
[
{ role: "system", content: "be nice" },
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
],
{ spaceId: "s1", userId: "u1" }
);
assert.deepEqual(
transcript.map((t) => t.type),
["context", "human", "ai"]
);
assert.deepEqual(
transcript.map((t) => t.value),
[[["be nice"]], [["hi"]], [["hello"]]]
["config", "context", "user", "agent-inference"]
);
const ctx = transcript[1].value as { instructions?: string; spaceId?: string };
assert.equal(ctx.instructions, "be nice");
assert.equal(ctx.spaceId, "s1");
assert.deepEqual(transcript[2].value, [["hi"]]);
assert.deepEqual(transcript[3].value, [{ type: "text", content: "hello" }]);
assert.ok(transcript.every((t) => typeof t.id === "string" && (t.id as string).length > 0));
});
it("drops messages with empty/non-string content", () => {
it("drops messages with empty/non-string content but keeps config+context", () => {
const transcript = buildNotionTranscript([
{ role: "user", content: "" },
{ role: "user", content: "keep me" },
]);
assert.equal(transcript.length, 1);
assert.equal(transcript.length, 3); // config + context + user
assert.equal(transcript[2].type, "user");
});
it("puts model food-codename on config when provided", () => {
const transcript = buildNotionTranscript([{ role: "user", content: "hi" }], {
notionModel: "acai-budino-high",
});
assert.equal((transcript[0].value as { model?: string }).model, "acai-budino-high");
});
});
describe("estimateNotionUsage", () => {
const { estimateNotionUsage } = mod;
it("scales with prompt and completion length (not a constant 2000)", () => {
const short = estimateNotionUsage([{ role: "user", content: "hi" }], "PONG");
const long = estimateNotionUsage(
[{ role: "user", content: "a".repeat(400) }],
"b".repeat(400)
);
assert.equal(short.estimated, true);
assert.ok(short.prompt_tokens >= 1);
assert.ok(short.completion_tokens >= 1);
assert.equal(short.total_tokens, short.prompt_tokens + short.completion_tokens);
assert.ok(long.prompt_tokens > short.prompt_tokens);
assert.ok(long.completion_tokens > short.completion_tokens);
// Never hardcode the USAGE_TOKEN_BUFFER default.
assert.notEqual(short.total_tokens, 2000);
});
});

View File

@@ -50,15 +50,70 @@ const SAMPLE_RESPONSE = {
test("parseNotionAvailableModels maps enabled models and skips disabled", () => {
const models = notionModels.parseNotionAvailableModels(SAMPLE_RESPONSE);
assert.equal(
models.some((m) => m.id === "disabled-model"),
models.some((m) => m.id === "disabled-model" || m.id === "hidden"),
false
);
assert.ok(models.some((m) => m.id === "orange-mousse" && m.name === "GPT-5.6 Sol"));
assert.ok(models.some((m) => m.id === "ambrosia-tart-high" && m.name === "Opus 4.8"));
// Catalog ids are real web-picker labels — NOT food codenames.
assert.ok(models.some((m) => m.id === "gpt-5.6-sol" && m.name === "GPT-5.6 Sol"));
assert.ok(models.some((m) => m.id === "opus-4.8" && m.name === "Opus 4.8"));
assert.ok(models.some((m) => m.id === "notion-ai"));
const sol = models.find((m) => m.id === "orange-mousse");
// Food codenames must not be primary catalog ids.
assert.equal(models.some((m) => m.id === "orange-mousse"), false);
assert.equal(models.some((m) => m.id === "ambrosia-tart-high"), false);
const sol = models.find((m) => m.id === "gpt-5.6-sol");
assert.equal(sol?.notionCodename, "orange-mousse");
assert.equal(sol?.supportsReasoning, true);
assert.equal(sol?.owned_by, "openai");
const opus = models.find((m) => m.id === "opus-4.8");
assert.equal(opus?.notionCodename, "ambrosia-tart-high");
});
test("resolveNotionCodename maps prefixes, slugs, and display names to food codenames", () => {
assert.equal(notionModels.resolveNotionCodename("orange-mousse"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("notion-web/orange-mousse"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("nw/orange-mousse"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("gpt-5.6-sol"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("notion-web/gpt-5.6-sol"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("GPT-5.6 Sol"), "orange-mousse");
assert.equal(notionModels.resolveNotionCodename("fable-5"), "acai-budino-high");
assert.equal(notionModels.resolveNotionCodename("Fable 5"), "acai-budino-high");
assert.equal(notionModels.resolveNotionCodename("acai-budino-high"), "acai-budino-high");
assert.equal(notionModels.resolveNotionCodename("notion-ai"), "");
assert.equal(notionModels.resolveNotionCodename(""), "");
});
test("listNotionDisabledModels surfaces plan-locked Fable 5 without listing it as enabled", () => {
const payload = {
models: [
{
model: "acai-budino-high",
modelMessage: "Fable 5",
modelFamily: "anthropic",
isDisabled: true,
disabledReason: "business_or_enterprise_plan_required",
},
{
model: "orange-mousse",
modelMessage: "GPT-5.6 Sol",
modelFamily: "openai",
isDisabled: false,
},
],
};
const disabled = notionModels.listNotionDisabledModels(payload);
assert.equal(disabled.length, 1);
assert.equal(disabled[0].id, "fable-5");
assert.equal(disabled[0].name, "Fable 5");
assert.equal(disabled[0].notionCodename, "acai-budino-high");
assert.equal(disabled[0].reason, "business_or_enterprise_plan_required");
const enabled = notionModels.parseNotionAvailableModels(payload);
assert.equal(enabled.some((m) => m.id === "fable-5" || m.id === "acai-budino-high"), false);
assert.ok(enabled.some((m) => m.id === "gpt-5.6-sol"));
const warning = notionModels.formatNotionDisabledModelsWarning(disabled);
assert.match(warning, /Fable 5/i);
assert.match(warning, /business or enterprise/i);
});
test("parseNotionAvailableModels returns empty for invalid payloads", () => {
@@ -115,7 +170,7 @@ test("discoverNotionWebModels posts getAvailableModels with spaceId from cookie"
assert.equal(calls.length, 1);
assert.equal(calls[0].url, notionModels.NOTION_MODELS_URL);
assert.equal(JSON.parse(calls[0].body).spaceId, "space-from-cookie");
assert.ok(result.models.some((m) => m.id === "orange-mousse"));
assert.ok(result.models.some((m) => m.id === "gpt-5.6-sol"));
assert.equal(result.source, "api");
});
@@ -136,9 +191,106 @@ test("discoverNotionWebModels falls back to getSpaces when space_id missing", as
fetchImpl,
});
assert.deepEqual(calls, [notionModels.NOTION_SPACES_URL, notionModels.NOTION_MODELS_URL]);
assert.equal(calls[0], notionModels.NOTION_SPACES_URL);
assert.ok(calls.some((u) => u === notionModels.NOTION_MODELS_URL));
assert.equal(result.spaceId, "resolved-space");
assert.ok(result.models.length >= 2);
assert.equal(result.spaceIdFromGetSpaces, true);
});
test("parseNotionGetSpaces extracts userId and all space ids", () => {
const data = {
"user-aaa": {
space: {
"space-1": { name: "Work" },
"space-2": { name: "Personal" },
},
},
};
const parsed = notionModels.parseNotionGetSpaces(data);
assert.equal(parsed.userId, "user-aaa");
assert.deepEqual(parsed.spaceIds, ["space-1", "space-2"]);
});
test("selectBestNotionSpaceId prefers the workspace with more enabled models", async () => {
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
if (String(url).includes("getAvailableModels")) {
const body = JSON.parse(String(init?.body || "{}"));
if (body.spaceId === "rich-space") return Response.json(SAMPLE_RESPONSE);
return Response.json({
models: [
{
model: "only-one",
modelMessage: "Tiny",
modelFamily: "openai",
isDisabled: false,
},
],
});
}
return new Response("nope", { status: 500 });
}) as typeof fetch;
const best = await notionModels.selectBestNotionSpaceId({
cookie: "token_v2=abc",
spaceIds: ["poor-space", "rich-space"],
fetchImpl,
});
assert.ok(best);
assert.equal(best!.spaceId, "rich-space");
assert.ok(best!.models.some((m) => m.id === "gpt-5.6-sol"));
});
test("selectBestNotionSpaceId prefers workspace where Fable is enabled over locked", async () => {
const locked = {
models: [
{
model: "orange-mousse",
modelMessage: "GPT-5.6 Sol",
modelFamily: "openai",
isDisabled: false,
},
{
model: "acai-budino-high",
modelMessage: "Fable 5",
modelFamily: "anthropic",
isDisabled: true,
disabledReason: "business_or_enterprise_plan_required",
},
],
};
const unlocked = {
models: [
{
model: "orange-mousse",
modelMessage: "GPT-5.6 Sol",
modelFamily: "openai",
isDisabled: false,
},
{
model: "acai-budino-high",
modelMessage: "Fable 5",
modelFamily: "anthropic",
isDisabled: false,
},
],
};
const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body || "{}"));
// First space looks rich enough that a buggy early-exit would pick it.
if (body.spaceId === "personal-locked") return Response.json(locked);
if (body.spaceId === "business-unlocked") return Response.json(unlocked);
return new Response("nope", { status: 500 });
}) as typeof fetch;
const best = await notionModels.selectBestNotionSpaceId({
cookie: "token_v2=abc",
spaceIds: ["personal-locked", "business-unlocked"],
fetchImpl,
});
assert.ok(best);
assert.equal(best!.spaceId, "business-unlocked");
assert.ok(best!.models.some((m) => m.id === "fable-5"));
});
test("notion-web models route returns live getAvailableModels catalog", async () => {
@@ -173,8 +325,11 @@ test("notion-web models route returns live getAvailableModels catalog", async ()
const body = await response.json();
assert.equal(body.source, "api");
const ids = body.models.map((m: { id: string }) => m.id);
assert.ok(ids.includes("orange-mousse"));
assert.ok(ids.includes("ambrosia-tart-high"));
// Real picker labels, not food codenames.
assert.ok(ids.includes("gpt-5.6-sol"));
assert.ok(ids.includes("opus-4.8"));
assert.equal(ids.includes("orange-mousse"), false);
assert.equal(ids.includes("ambrosia-tart-high"), false);
assert.equal(ids.includes("disabled-model"), false);
} finally {
globalThis.fetch = originalFetch;

View File

@@ -86,6 +86,28 @@ test("addBufferToUsage — returns usage unchanged when buffer is 0 via env", ()
resetEnv(saved);
});
test("addBufferToUsage — skips safety buffer for estimated usage (web/heuristic)", () => {
const saved = process.env.USAGE_TOKEN_BUFFER;
delete process.env.USAGE_TOKEN_BUFFER;
invalidateBufferTokensCache();
const result = addBufferToUsage({
prompt_tokens: 6,
completion_tokens: 1,
total_tokens: 7,
estimated: true,
});
// Must NOT inflate heuristics to DEFAULT 2000 — that made every Notion
// request report a flat 2000 tokens.
assert.equal(result.prompt_tokens, 6);
assert.equal(result.completion_tokens, 1);
assert.equal(result.total_tokens, 7);
assert.equal(result.estimated, true);
resetEnv(saved);
});
// ─── setBufferTokensCache — the fix for the race condition ────────────────
//
// The race: invalidateBufferTokensCache() sets _cachedBuffer=null; the next