From 7a68a7961c2041b4560e522428529e94ee8f251f Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Sun, 19 Jul 2026 22:21:18 +0300 Subject: [PATCH] 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 Co-authored-by: artickc Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../providers/registry/notion-web/index.ts | 14 +- open-sse/executors/notion-web.ts | 642 +++++++++++++--- .../handlers/chatCore/clientUsageBuffer.ts | 32 +- open-sse/handlers/responseSanitizer.ts | 3 + open-sse/services/notionWebModels.ts | 691 +++++++++++++++--- open-sse/utils/usageTracking.ts | 7 + src/app/api/providers/[id]/models/route.ts | 3 +- src/shared/constants/providers/web-cookie.ts | 5 +- .../unit/chatcore-client-usage-buffer.test.ts | 12 + tests/unit/executor-notion-web.test.ts | 237 +++++- .../unit/notion-web-models-discovery.test.ts | 171 ++++- tests/unit/usage-token-buffer.test.ts | 22 + 12 files changed, 1551 insertions(+), 288 deletions(-) diff --git a/open-sse/config/providers/registry/notion-web/index.ts b/open-sse/config/providers/registry/notion-web/index.ts index c043137cce..bfe4ead073 100644 --- a/open-sse/config/providers/registry/notion-web/index.ts +++ b/open-sse/config/providers/registry/notion-web/index.ts @@ -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, + })), }; diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index c54f719504..c9feace15e 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -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 { + const configValue: Record = { + 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 { + const contextValue: Record = { + 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, + opts: { userId?: string; now: string } +): Record | 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 = { + 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> { - const entries: Array> = []; - 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> = [ + 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 `` 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 ")) 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; + 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 | null { + if (!msg || typeof msg !== "object") return null; + const valueWrapper = (msg as Record).value; + if (!valueWrapper || typeof valueWrapper !== "object") return null; + const inner = (valueWrapper as Record).value; + if (!inner || typeof inner !== "object") return null; + const step = (inner as Record).step; + if (!step || typeof step !== "object") return null; + return step as Record; +} + +/** Extracts the text carried by a single thread-message step, or "" if none. */ +function extractStepText(stepObj: Record): 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).thread_message; + if (!tm || typeof tm !== "object" || Array.isArray(tm)) return ""; + let best = ""; + for (const msg of Object.values(tm as Record)) { + 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; + 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; + 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; + 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, 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, 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).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 { + 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 { + const reqHeaders: Record = { + "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; + reqHeaders: Record; + signal: ExecuteInput["signal"]; +}): Promise<{ rawText?: string; errorResult?: ReturnType }> { + 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 = { - 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 = { - "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 }; } diff --git a/open-sse/handlers/chatCore/clientUsageBuffer.ts b/open-sse/handlers/chatCore/clientUsageBuffer.ts index 12101b73d0..0f25a830cc 100644 --- a/open-sse/handlers/chatCore/clientUsageBuffer.ts +++ b/open-sse/handlers/chatCore/clientUsageBuffer.ts @@ -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; + 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; diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index a411da5ebe..146e105c75 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -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", diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts index 2e135a80c1..0b6b3abedf 100644 --- a/open-sse/services/notionWebModels.ts +++ b/open-sse/services/notionWebModels.ts @@ -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(); +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): 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(); + for (const entry of list) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const row = entry as Record; + 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; + // 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 { @@ -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 { - 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).space; + if (!spaceMap || typeof spaceMap !== "object" || Array.isArray(spaceMap)) return ""; + for (const id of Object.keys(spaceMap as Record)) { + 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, 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; + 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 { - for (const value of Object.values(root)) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const spaceMap = (value as Record).space; - if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) { - const ids = Object.keys(spaceMap as Record); - 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; + return parseNotionGetSpaces(data).spaceIds[0] || ""; +} - return ( - pickSpaceIdFromUserMap(root) || - pickSpaceIdFromSpacesArray(root.spaces) || - pickSpaceIdFromSpaceIdsArray(root.spaceIds) - ); +function buildNotionBrowserHeaders(cookie: string, userId?: string): Record { + const headers: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + const map = new Map(); + 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, }; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 116bdfe575..caef2c8f1b 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -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; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 728269a8fa..1eae0438ac 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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), diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index aa3da130f6..8a6cb8a49e 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -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=` 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.", }, }; diff --git a/tests/unit/chatcore-client-usage-buffer.test.ts b/tests/unit/chatcore-client-usage-buffer.test.ts index ab58ab4fd2..4cda023536 100644 --- a/tests/unit/chatcore-client-usage-buffer.test.ts +++ b/tests/unit/chatcore-client-usage-buffer.test.ts @@ -40,6 +40,18 @@ test("usage present → buffer then filter, mutates in place", () => { assert.equal((resp.usage as Record)._filtered, true); }); +test("all-zero usage stub → estimate (not constant buffer-only 2000)", () => { + const { deps, calls } = makeDeps(); + const resp: Record = { + 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)._estimated, true); +}); + test("no usage but content present → estimate then filter", () => { const { deps, calls } = makeDeps(); const resp: Record = { diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts index bb71acc4c4..2183a139e1 100644 --- a/tests/unit/executor-notion-web.test.ts +++ b/tests/unit/executor-notion-web.test.ts @@ -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 = {}; - 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; 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: '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: '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); }); }); diff --git a/tests/unit/notion-web-models-discovery.test.ts b/tests/unit/notion-web-models-discovery.test.ts index 7df79fe568..514e521c9c 100644 --- a/tests/unit/notion-web-models-discovery.test.ts +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -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; diff --git a/tests/unit/usage-token-buffer.test.ts b/tests/unit/usage-token-buffer.test.ts index 8872b28675..ca06ac6882 100644 --- a/tests/unit/usage-token-buffer.test.ts +++ b/tests/unit/usage-token-buffer.test.ts @@ -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