From fd6a583a95ded5e101ec50ad77a920d44522678c Mon Sep 17 00:00:00 2001 From: Hassiy Date: Mon, 20 Jul 2026 21:57:15 +0300 Subject: [PATCH] fix(notion-web): add browser fingerprint headers to reduce Cloudflare challenges (#7864) * fix(notion-web): add browser fingerprint headers to reduce Cloudflare challenges Adds sec-ch-ua, sec-fetch-*, cache-control, pragma, and priority headers that real Chromium browsers send. Without these, Cloudflare may challenge or block requests that look like non-browser clients. Applied to: - buildNotionExecuteHeaders (inference requests) - buildNotionBrowserHeaders (workspace discovery) - buildNotionModelsDiscoveryHeaders (model discovery) Headers match the real browser capture from Chrome 149 on Linux. Addresses gemini-code-assist review: - Fixed platform mismatch: sec-ch-ua-platform now matches USER_AGENT (Windows) - Deduplicated headers via shared BROWSER_HEADERS constant in notionWebModels.ts - Both executor and model discovery use the same constant * fix(notion-web): align Chrome version to 149 and add browser header tests Addresses maintainer review feedback on #7864: - Align User-Agent and NOTION_USER_AGENT to Chrome/149 (was 145 and 150) matching sec-ch-ua already declaring v="149" - Add test assertions that browser fingerprint headers (sec-ch-ua, sec-fetch-mode, cache-control, pragma) are sent on both executor and models-discovery requests * refactor(providers): extract notion-web fallback catalog to its own module notionWebModels.ts crossed the 800-line new-file cap (875) once the browser header tests landed; move the NOTION_WEB_FALLBACK_MODELS catalog + its type to notionWebFallbackModels.ts (pure data, re-exported for existing consumers). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/executors/notion-web.ts | 42 +++--- open-sse/services/notionWebFallbackModels.ts | 134 ++++++++++++++++++ open-sse/services/notionWebModels.ts | 118 ++++++--------- tests/unit/executor-notion-web.test.ts | 32 +++-- .../unit/notion-web-models-discovery.test.ts | 20 ++- 5 files changed, 241 insertions(+), 105 deletions(-) create mode 100644 open-sse/services/notionWebFallbackModels.ts diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index c9feace15e..00b9fbb7f3 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -25,6 +25,7 @@ import { randomUUID } from "node:crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; import { + BROWSER_HEADERS, extractNotionUserIdFromCookie, resolveNotionCodename, resolveNotionRuntimeWorkspace, @@ -36,7 +37,7 @@ import { 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/145.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; const NOTION_CLIENT_VERSION = "23.13.20260719.1125"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -451,7 +452,12 @@ export function parseNotionInferenceStream(raw: string): string { applyNotionStreamLine(rawLine, state); } - const candidates = [state.lastRecordMap, state.lastPatchFinal, state.lastIncremental, state.lastLegacy] + const candidates = [ + state.lastRecordMap, + state.lastPatchFinal, + state.lastIncremental, + state.lastLegacy, + ] .map(sanitizeNotionAssistantText) .filter(Boolean); // Prefer the longest non-empty candidate; record-map usually wins. @@ -472,12 +478,8 @@ export function estimateNotionUsage( .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; + 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, @@ -486,20 +488,14 @@ export function estimateNotionUsage( }; } -function chatCompletionResponse( - content: string, - model: string, - messages?: NotionMessage[] -) { +function chatCompletionResponse(content: string, model: string, messages?: NotionMessage[]) { return new Response( JSON.stringify({ id: `chatcmpl-notion-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, - choices: [ - { index: 0, message: { role: "assistant", content }, finish_reason: "stop" }, - ], + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], usage: estimateNotionUsage(messages, content), }), { status: 200, headers: { "Content-Type": "application/json" } } @@ -611,6 +607,7 @@ function buildNotionExecuteHeaders(opts: { "notion-audit-log-platform": "web", "x-notion-space-id": opts.spaceId, "Accept-Language": "en-US,en;q=0.9", + ...BROWSER_HEADERS, }; if (opts.userId) reqHeaders["x-notion-active-user-header"] = opts.userId; return reqHeaders; @@ -659,7 +656,12 @@ async function sendNotionInferenceRequest(opts: { if (!upstream.ok) { const errText = await upstream.text().catch(() => ""); return { - errorResult: makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL), + errorResult: makeErrorResult( + upstream.status, + `Notion error: ${errText}`, + reqBody, + NOTION_URL + ), }; } @@ -720,7 +722,11 @@ export class NotionWebExecutor extends BaseExecutor { const reqBody = buildNotionCreateThreadRequestBody({ spaceId, userId, threadId, transcript }); const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId }); - const { rawText, errorResult } = await sendNotionInferenceRequest({ reqBody, reqHeaders, signal }); + const { rawText, errorResult } = await sendNotionInferenceRequest({ + reqBody, + reqHeaders, + signal, + }); if (errorResult) return errorResult; const finalText = parseNotionInferenceStream(rawText || ""); diff --git a/open-sse/services/notionWebFallbackModels.ts b/open-sse/services/notionWebFallbackModels.ts new file mode 100644 index 0000000000..89f3097d5d --- /dev/null +++ b/open-sse/services/notionWebFallbackModels.ts @@ -0,0 +1,134 @@ +/** + * Notion Web fallback model catalog (seeded from the live AI picker). + * Extracted from notionWebModels.ts to keep that module under the 800-line + * file-size cap; notionWebModels.ts re-exports both symbols for consumers. + */ + +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). + * 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: "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", + }, +]; + diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts index 0b6b3abedf..a525eccbe2 100644 --- a/open-sse/services/notionWebModels.ts +++ b/open-sse/services/notionWebModels.ts @@ -7,6 +7,14 @@ * build the cookie/headers/body the models-discovery route needs. */ +import { + NOTION_WEB_FALLBACK_MODELS, + type NotionDiscoveredModel, +} from "./notionWebFallbackModels.ts"; + +export { NOTION_WEB_FALLBACK_MODELS }; +export type { NotionDiscoveredModel }; + // 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. @@ -15,73 +23,37 @@ 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/150.0.0.0 Safari/537.36"; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; /** Recent Notion web client version — accepted loosely but required by some paths. */ 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 = new Map< + string, + { spaceId: string; userId: string; expiresAt: number } +>(); const NOTION_SPACE_CACHE_TTL_MS = 30 * 60 * 1000; +// Browser fingerprint headers — make requests look like real Chromium +// to reduce Cloudflare bot-detection challenges. +export const BROWSER_HEADERS: Record = { + "sec-ch-ua": '"Chromium";v="149", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + priority: "u=1, i", + "cache-control": "no-cache", + pragma: "no-cache", +}; + 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). - * 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: "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. */ export function normalizeNotionWebCookie(raw: string): string { const trimmed = String(raw || "").trim(); @@ -105,11 +77,7 @@ export function readCookieValue(cookie: string, name: string): string { } export function extractSpaceIdFromNotionCookie(cookie: string): string { - return ( - readCookieValue(cookie, "space_id") || - readCookieValue(cookie, "spaceId") || - "" - ); + return readCookieValue(cookie, "space_id") || readCookieValue(cookie, "spaceId") || ""; } export function extractNotionUserIdFromCookie(cookie: string): string { @@ -229,10 +197,7 @@ export function formatNotionDisabledModelsWarning( * Catalog `id` is the real picker label slug; `notionCodename` is what * runInferenceTranscript requires. */ -function parseNotionModelEntry( - entry: unknown, - seen: Set -): NotionDiscoveredModel | null { +function parseNotionModelEntry(entry: unknown, seen: Set): 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. @@ -314,6 +279,7 @@ export function buildNotionModelsDiscoveryHeaders(token: string): Record { assert.ok(models.length >= 1); assert.ok(models.some((m) => m.id === "notion-ai")); // 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.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"), + models.some( + (m) => + m.id === "ambrosia-tart-high" || m.id === "orange-mousse" || m.id === "acai-budino-high" + ), false ); }); @@ -124,6 +129,14 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { 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"); + // Browser fingerprint headers to reduce Cloudflare challenges. + assert.ok(capturedHeaders["sec-ch-ua"], "sec-ch-ua should be present"); + assert.ok(capturedHeaders["sec-fetch-dest"], "sec-fetch-dest should be present"); + assert.ok(capturedHeaders["sec-fetch-mode"], "sec-fetch-mode should be present"); + assert.equal(capturedHeaders["sec-fetch-mode"], "cors"); + assert.ok(capturedHeaders["sec-ch-ua-platform"], "sec-ch-ua-platform should be present"); + assert.equal(capturedHeaders["cache-control"], "no-cache"); + assert.equal(capturedHeaders["pragma"], "no-cache"); assert.ok(capturedBody); assert.equal(capturedBody.createThread, true); assert.ok(typeof capturedBody.threadId === "string" && capturedBody.threadId.length > 0); @@ -295,7 +308,8 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { const executor = new mod.NotionWebExecutor(); const originalFetch = globalThis.fetch; try { - globalThis.fetch = (async () => new Response("not-json\n{}", { status: 200 })) as typeof fetch; + globalThis.fetch = (async () => + new Response("not-json\n{}", { status: 200 })) as typeof fetch; const result = await executor.execute({ model: "notion-ai", @@ -314,8 +328,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { const executor = new mod.NotionWebExecutor(); const originalFetch = globalThis.fetch; try { - globalThis.fetch = (async () => - new Response("Forbidden", { status: 403 })) as typeof fetch; + globalThis.fetch = (async () => new Response("Forbidden", { status: 403 })) as typeof fetch; const result = await executor.execute({ model: "notion-ai", @@ -325,7 +338,9 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { signal: null, } as never); assert.equal(result.response.status, 403); - const errBody = (await result.response.json()) as { error: { message: string; code: string } }; + const errBody = (await result.response.json()) as { + error: { message: string; code: string }; + }; assert.match(errBody.error.message, /session expired|invalid/i); assert.equal(errBody.error.code, "HTTP_403"); // No stack trace / file path leakage (Hard Rule #12). @@ -459,10 +474,7 @@ describe("estimateNotionUsage", () => { 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) - ); + 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); diff --git a/tests/unit/notion-web-models-discovery.test.ts b/tests/unit/notion-web-models-discovery.test.ts index 514e521c9c..9c3b1c3d63 100644 --- a/tests/unit/notion-web-models-discovery.test.ts +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -58,8 +58,14 @@ test("parseNotionAvailableModels maps enabled models and skips disabled", () => assert.ok(models.some((m) => m.id === "opus-4.8" && m.name === "Opus 4.8")); assert.ok(models.some((m) => m.id === "notion-ai")); // 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); + 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); @@ -108,7 +114,10 @@ test("listNotionDisabledModels surfaces plan-locked Fable 5 without listing it a 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.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); @@ -311,6 +320,11 @@ test("notion-web models route returns live getAvailableModels catalog", async () assert.equal(body.spaceId, "space-live-1"); const headers = init?.headers as Record; assert.match(String(headers.cookie || headers.Cookie || ""), /token_v2=sess/); + // Browser fingerprint headers present on models-discovery requests. + assert.ok(headers["sec-ch-ua"], "sec-ch-ua should be present"); + assert.ok(headers["sec-fetch-mode"], "sec-fetch-mode should be present"); + assert.equal(headers["sec-fetch-mode"], "cors"); + assert.equal(headers["cache-control"], "no-cache"); return Response.json(SAMPLE_RESPONSE); } return new Response("unexpected", { status: 500 });