mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
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>
This commit is contained in:
@@ -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 || "");
|
||||
|
||||
134
open-sse/services/notionWebFallbackModels.ts
Normal file
134
open-sse/services/notionWebFallbackModels.ts
Normal file
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string, { spaceId: string; userId: string; expiresAt: number }>();
|
||||
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<string, string> = {
|
||||
"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<string>
|
||||
): NotionDiscoveredModel | null {
|
||||
function parseNotionModelEntry(entry: unknown, seen: Set<string>): NotionDiscoveredModel | null {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
||||
const row = entry as Record<string, unknown>;
|
||||
// Notion still returns plan-locked models (Fable 5) with isDisabled=true.
|
||||
@@ -314,6 +279,7 @@ export function buildNotionModelsDiscoveryHeaders(token: string): Record<string,
|
||||
"notion-client-version": NOTION_CLIENT_VERSION,
|
||||
"notion-audit-log-platform": "web",
|
||||
...(cookie ? { cookie } : {}),
|
||||
...BROWSER_HEADERS,
|
||||
};
|
||||
if (spaceId) headers["x-notion-space-id"] = spaceId;
|
||||
if (userId) headers["x-notion-active-user-header"] = userId;
|
||||
@@ -418,6 +384,7 @@ function buildNotionBrowserHeaders(cookie: string, userId?: string): Record<stri
|
||||
"notion-client-version": NOTION_CLIENT_VERSION,
|
||||
"notion-audit-log-platform": "web",
|
||||
cookie,
|
||||
...BROWSER_HEADERS,
|
||||
};
|
||||
if (userId) headers["x-notion-active-user-header"] = userId;
|
||||
return headers;
|
||||
@@ -482,8 +449,12 @@ export async function selectBestNotionSpaceId(opts: {
|
||||
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;
|
||||
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;
|
||||
@@ -521,9 +492,7 @@ export async function selectBestNotionSpaceId(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
? { spaceId: best.spaceId, models: best.models, raw: best.raw }
|
||||
: null;
|
||||
return best ? { spaceId: best.spaceId, models: best.models, raw: best.raw } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -558,7 +527,11 @@ export async function resolveNotionRuntimeWorkspace(opts: {
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!best?.spaceId) {
|
||||
return { spaceId: candidates.spaceIds[0] || "", userId: userId || candidates.userId, fromCache: false };
|
||||
return {
|
||||
spaceId: candidates.spaceIds[0] || "",
|
||||
userId: userId || candidates.userId,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedUser = userId || candidates.userId;
|
||||
@@ -766,10 +739,7 @@ export function resolveNotionCodename(
|
||||
else if (m.startsWith("nw/")) m = m.slice(3);
|
||||
if (!m || m === "notion-ai") return "";
|
||||
|
||||
const map = buildNotionFriendlyToCodenameMap([
|
||||
...NOTION_WEB_FALLBACK_MODELS,
|
||||
...extraModels,
|
||||
]);
|
||||
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;
|
||||
|
||||
@@ -26,9 +26,14 @@ describe("NotionWebExecutor — registry consistency", () => {
|
||||
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);
|
||||
|
||||
@@ -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<string, string>;
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user