feat(providers): notion-web live model discovery via getAvailableModels (#7696)

* feat(providers): notion-web live models via getAvailableModels

Cookie-auth discovery against POST /api/v3/getAvailableModels (spaceId from
cookie or getSpaces) so /api/providers/{id}/models and /v1/models can surface
the real Notion AI picker catalog instead of a single stub notion-ai id.

Also injects a config transcript entry with the selected model codename on
runInferenceTranscript, seeds an offline fallback catalog, and documents that
space_id is needed for reliable discovery.

* fix(providers): address notion-web review + docs provider count

- Safe decodeURIComponent for malformed cookie values
- Use extractSpaceIdFromNotionCookie instead of case-sensitive space_id= includes
- Single trim in buildNotionTranscript
- Sync STRICT docs counts to 265 providers (README/AGENTS/CLAUDE)

* refactor(notion-web): extract helpers to keep parseNotionAvailableModels/pickFirstSpaceId under complexity cap

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

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
NOXX - Commiter
2026-07-19 10:26:26 +03:00
committed by GitHub
parent 390e88ca1e
commit dffff5d656
8 changed files with 633 additions and 17 deletions

View File

@@ -0,0 +1 @@
- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie)

View File

@@ -1,12 +1,9 @@
import type { RegistryEntry } from "../../shared.ts";
import { NOTION_WEB_FALLBACK_MODELS } from "../../../../services/notionWebModels.ts";
// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts
// for the reverse-engineering rationale (issue #6758, closed native-provider
// request #3272). Notion AI does not expose a documented, selectable model
// catalog through its internal endpoint — the assistant response is server-side
// routed. `passthroughModels: true` lets an operator pass any model id the
// endpoint may honor in the future without a registry change; the single
// `notion-ai` entry is the default/safe fallback shown in the picker.
// 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.
export const notion_webProvider: RegistryEntry = {
id: "notion-web",
alias: "nw",
@@ -16,5 +13,5 @@ export const notion_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
passthroughModels: true,
models: [{ id: "notion-ai", name: "Notion AI (Unofficial/Experimental)" }],
models: NOTION_WEB_FALLBACK_MODELS.map((m) => ({ id: m.id, name: m.name })),
};

View File

@@ -127,19 +127,39 @@ export function extractSpaceIdFromCookie(cookie: string): string {
/**
* Build a Notion `runInferenceTranscript` transcript array from OpenAI-style
* chat messages. Each entry uses Notion's rich-text tuple value shape
* (`[[text]]`) and a role tag Notion's own web client sends.
* 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.
*/
export function buildNotionTranscript(
messages: NotionMessage[]
messages: NotionMessage[],
notionModel?: string
): Array<Record<string, unknown>> {
return messages
.filter((m) => typeof m?.content === "string" && m.content.length > 0)
.map((m) => ({
const entries: Array<Record<string, unknown>> = [];
const trimmedModel = typeof notionModel === "string" ? 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" }],
},
});
}
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]],
}));
});
}
return entries;
}
/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
@@ -250,8 +270,11 @@ export class NotionWebExecutor extends BaseExecutor {
const modelId = model || "notion-ai";
const reqBody: Record<string, unknown> = {
traceId: randomUUID(),
transcript: buildNotionTranscript(messages),
transcript: buildNotionTranscript(messages, modelId),
createThread: false,
asPatchResponse: true,
threadType: "workflow",
createdSource: "ai_module",
};
if (spaceId) reqBody.spaceId = spaceId;

View File

@@ -0,0 +1,319 @@
/**
* Notion AI Web model discovery helpers.
*
* Notion has no public model catalog API. The browser AI surface loads models via
* cookie-auth `POST /api/v3/getAvailableModels` with body `{ spaceId }` (see
* browser capture against app.notion.com). These helpers parse that response and
* build the cookie/headers/body the models-discovery route needs.
*/
const NOTION_APP_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";
/** Recent Notion web client version — accepted loosely but required by some paths. */
const NOTION_CLIENT_VERSION = "23.13.20260718.1805";
export type NotionDiscoveredModel = {
id: string;
name: string;
owned_by: string;
supportsReasoning?: boolean;
disabled?: boolean;
};
/** Offline fallback when getAvailableModels is unreachable (seeded from live picker). */
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" },
];
/** Normalize a pasted credential to a Cookie header string. */
export function normalizeNotionWebCookie(raw: string): string {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
return trimmed.includes("=") ? trimmed : `token_v2=${trimmed}`;
}
/** Read `name=value` from a cookie header (case-insensitive name). */
export function readCookieValue(cookie: string, name: string): string {
if (!cookie || !name) return "";
const re = new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`, "i");
const m = cookie.match(re);
if (!m) return "";
const raw = m[1].trim();
// Malformed % sequences in cookie values must not throw (Gemini review).
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function extractSpaceIdFromNotionCookie(cookie: string): string {
return (
readCookieValue(cookie, "space_id") ||
readCookieValue(cookie, "spaceId") ||
""
);
}
export function extractNotionUserIdFromCookie(cookie: string): string {
return (
readCookieValue(cookie, "notion_user_id") ||
readCookieValue(cookie, "notion_user_id_v2") ||
readCookieValue(cookie, "user_id") ||
""
);
}
/** Trim to a non-empty string, or fall back to `fallback`. */
function trimmedOrFallback(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
/** True when the row's `modelConfiguration.supportedReasoningEfforts` is a non-empty array. */
function rowSupportsReasoning(row: Record<string, unknown>): boolean {
const efforts = (row.modelConfiguration as { supportedReasoningEfforts?: unknown } | undefined)
?.supportedReasoningEfforts;
return Array.isArray(efforts) && efforts.length > 0;
}
/**
* 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`).
*/
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>;
if (row.isDisabled === true) return null;
const id = typeof row.model === "string" ? row.model.trim() : "";
if (!id || seen.has(id)) return null;
seen.add(id);
return {
id,
name: trimmedOrFallback(row.modelMessage, id),
owned_by: trimmedOrFallback(row.modelFamily, "notion"),
...(rowSupportsReasoning(row) ? { supportsReasoning: true } : {}),
};
}
/** Ensure a stable default id always exists for clients that still request notion-ai. */
function withDefaultNotionModel(
out: NotionDiscoveredModel[],
seen: Set<string>
): NotionDiscoveredModel[] {
if (out.length === 0 || seen.has("notion-ai")) return out;
return [{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" }, ...out];
}
/**
* 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).
*/
export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel[] {
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
const list = (data as { models?: unknown }).models;
if (!Array.isArray(list)) return [];
const seen = new Set<string>();
const out: NotionDiscoveredModel[] = [];
for (const entry of list) {
const model = parseNotionModelEntry(entry, seen);
if (model) out.push(model);
}
return withDefaultNotionModel(out, seen);
}
export function buildNotionModelsDiscoveryHeaders(token: string): Record<string, string> {
const cookie = normalizeNotionWebCookie(token);
const spaceId = extractSpaceIdFromNotionCookie(cookie);
const userId = extractNotionUserIdFromCookie(cookie);
const headers: Record<string, string> = {
accept: "*/*",
"content-type": "application/json",
"user-agent": NOTION_USER_AGENT,
origin: NOTION_APP_ORIGIN,
referer: `${NOTION_APP_ORIGIN}/ai`,
"notion-client-version": NOTION_CLIENT_VERSION,
"notion-audit-log-platform": "web",
...(cookie ? { cookie } : {}),
};
if (spaceId) headers["x-notion-space-id"] = spaceId;
if (userId) headers["x-notion-active-user-header"] = userId;
return headers;
}
export function buildNotionModelsDiscoveryBody(token: string): { spaceId?: string } {
const cookie = normalizeNotionWebCookie(token);
const spaceId = extractSpaceIdFromNotionCookie(cookie);
return spaceId ? { spaceId } : {};
}
export function getNotionModelsDiscoveryUrl(): string {
return NOTION_MODELS_URL;
}
/**
* Try to resolve a workspace spaceId from getSpaces when the cookie has none.
* Returns "" on any failure (caller falls back to local catalog).
*/
export async function resolveNotionSpaceIdFromGetSpaces(
cookie: string,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const 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 "";
}
}
/** Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } */
function pickSpaceIdFromUserMap(root: Record<string, unknown>): string {
for (const value of Object.values(root)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const spaceMap = (value as Record<string, unknown>).space;
if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) {
const ids = Object.keys(spaceMap as Record<string, unknown>);
if (ids.length > 0) return ids[0];
}
}
return "";
}
/** Flat shape: { spaces: [{ id }] } */
function pickSpaceIdFromSpacesArray(spaces: unknown): string {
if (!Array.isArray(spaces)) return "";
for (const s of spaces) {
if (s && typeof s === "object" && typeof (s as { id?: string }).id === "string") {
return (s as { id: string }).id;
}
}
return "";
}
/** Flat shape: { spaceIds: [] } */
function pickSpaceIdFromSpaceIdsArray(spaceIds: unknown): string {
return Array.isArray(spaceIds) && typeof spaceIds[0] === "string" ? spaceIds[0] : "";
}
/** Best-effort spaceId extraction from getSpaces response shapes. */
export function pickFirstSpaceId(data: unknown): string {
if (!data || typeof data !== "object") return "";
const root = data as Record<string, unknown>;
return (
pickSpaceIdFromUserMap(root) ||
pickSpaceIdFromSpacesArray(root.spaces) ||
pickSpaceIdFromSpaceIdsArray(root.spaceIds)
);
}
/**
* End-to-end discovery used by the models route special-case (and unit tests).
* Resolves spaceId from cookie or getSpaces, then calls getAvailableModels.
*/
export async function discoverNotionWebModels(opts: {
token: string;
fetchImpl?: typeof fetch;
signal?: AbortSignal | null;
}): Promise<{ models: NotionDiscoveredModel[]; spaceId: string; source: "api" }> {
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"
);
}
// 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 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) {
throw new Error("getAvailableModels returned no enabled models");
}
return { models, spaceId, source: "api" };
}
export {
NOTION_MODELS_URL,
NOTION_SPACES_URL,
NOTION_APP_ORIGIN,
NOTION_CLIENT_VERSION,
};

View File

@@ -41,6 +41,10 @@ import {
discoverBedrockNativeModels,
isBedrockNativeApiError,
} from "@omniroute/open-sse/services/bedrock.ts";
import {
discoverNotionWebModels,
NOTION_WEB_FALLBACK_MODELS,
} from "@omniroute/open-sse/services/notionWebModels.ts";
import {
AZURE_AI_DEFAULT_BASE_URL,
buildAzureAiModelsUrl,
@@ -527,6 +531,64 @@ export async function GET(
if (localCatalog) return localCatalog;
}
// #7600 follow-up: notion-web live catalog via cookie-auth getAvailableModels.
// Needs spaceId (from cookie or getSpaces); falls back to seeded local catalog.
if (provider === "notion-web") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
const token = apiKey || accessToken;
if (!token) {
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "No token configured — using cached catalog",
localWarning: "No token configured — using local catalog",
});
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: NOTION_WEB_FALLBACK_MODELS,
source: "local_catalog",
intentional: true,
warning: "No token_v2 cookie — using seed Notion AI model list",
});
}
try {
const discovery = await discoverNotionWebModels({
token,
fetchImpl: (url, init) =>
safeOutboundFetch(url, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
...init,
}),
});
return buildApiDiscoveryResponse(discovery.models);
} catch (error) {
console.log("Error fetching models from notion-web", {
error: error instanceof Error ? error.message : String(error),
});
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "Notion getAvailableModels failed — using cached catalog",
localWarning: "Notion getAvailableModels failed — using seed catalog",
});
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: NOTION_WEB_FALLBACK_MODELS,
source: "local_catalog",
intentional: true,
warning: "API unavailable — using seed Notion AI model list",
});
}
}
if (provider === "bedrock") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;

View File

@@ -372,7 +372,8 @@ export const WEB_COOKIE_PROVIDERS = {
riskNoticeVariant: "webCookie",
authHint:
"Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). " +
"Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them.",
"Include `; space_id=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " +
"Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.",
},
};

View File

@@ -25,6 +25,8 @@ 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"));
});
});
@@ -93,6 +95,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => {
assert.equal(capturedUrl, "https://www.notion.so/api/v3/runInferenceTranscript");
assert.equal(capturedHeaders.Cookie, "token_v2=abc123");
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"]]);
@@ -110,6 +113,34 @@ 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;
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;
await executor.execute({
model: "orange-mousse",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token_v2=xyz; space_id=space-1" },
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");
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts a full cookie header verbatim (already containing token_v2=)", async () => {
const executor = new mod.NotionWebExecutor();
let capturedHeaders: Record<string, string> = {};

View File

@@ -0,0 +1,182 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-notion-web-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const notionModels = await import("../../open-sse/services/notionWebModels.ts");
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
const SAMPLE_RESPONSE = {
models: [
{
model: "orange-mousse",
modelMessage: "GPT-5.6 Sol",
modelFamily: "openai",
isDisabled: false,
modelConfiguration: { supportedReasoningEfforts: ["medium", "high"] },
},
{
model: "ambrosia-tart-high",
modelMessage: "Opus 4.8",
modelFamily: "anthropic",
isDisabled: false,
},
{
model: "disabled-model",
modelMessage: "Hidden",
modelFamily: "openai",
isDisabled: true,
},
],
};
test("parseNotionAvailableModels maps enabled models and skips disabled", () => {
const models = notionModels.parseNotionAvailableModels(SAMPLE_RESPONSE);
assert.equal(
models.some((m) => m.id === "disabled-model"),
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"));
assert.ok(models.some((m) => m.id === "notion-ai"));
const sol = models.find((m) => m.id === "orange-mousse");
assert.equal(sol?.supportsReasoning, true);
assert.equal(sol?.owned_by, "openai");
});
test("parseNotionAvailableModels returns empty for invalid payloads", () => {
assert.deepEqual(notionModels.parseNotionAvailableModels(null), []);
assert.deepEqual(notionModels.parseNotionAvailableModels({}), []);
assert.deepEqual(notionModels.parseNotionAvailableModels({ models: "nope" }), []);
});
test("cookie helpers extract space_id and user id", () => {
const cookie =
"token_v2=abc; space_id=5e43fbd2-c09b-815a-8045-000311a1f620; notion_user_id=28bd872b-594c-81cb-9638-0002a411fd83";
assert.equal(
notionModels.extractSpaceIdFromNotionCookie(cookie),
"5e43fbd2-c09b-815a-8045-000311a1f620"
);
assert.equal(
notionModels.extractNotionUserIdFromCookie(cookie),
"28bd872b-594c-81cb-9638-0002a411fd83"
);
assert.equal(notionModels.normalizeNotionWebCookie("baretoken"), "token_v2=baretoken");
// CamelCase spaceId= must still resolve (case-insensitive name match).
assert.equal(
notionModels.extractSpaceIdFromNotionCookie("token_v2=x; spaceId=space-camel"),
"space-camel"
);
// Malformed % sequences must not throw.
assert.equal(notionModels.readCookieValue("token_v2=%E0%A4%A", "token_v2"), "%E0%A4%A");
});
test("pickFirstSpaceId reads nested getSpaces shape", () => {
const data = {
"user-1": {
space: {
"space-aaa": { name: "Work" },
"space-bbb": { name: "Personal" },
},
},
};
assert.equal(notionModels.pickFirstSpaceId(data), "space-aaa");
});
test("discoverNotionWebModels posts getAvailableModels with spaceId from cookie", async () => {
const calls: Array<{ url: string; body: string }> = [];
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
calls.push({ url: String(url), body: String(init?.body || "") });
return Response.json(SAMPLE_RESPONSE);
}) as typeof fetch;
const result = await notionModels.discoverNotionWebModels({
token: "token_v2=xyz; space_id=space-from-cookie",
fetchImpl,
});
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.equal(result.source, "api");
});
test("discoverNotionWebModels falls back to getSpaces when space_id missing", async () => {
const calls: string[] = [];
const fetchImpl = (async (url: string | URL) => {
calls.push(String(url));
if (String(url).includes("getSpaces")) {
return Response.json({
u1: { space: { "resolved-space": { name: "WS" } } },
});
}
return Response.json(SAMPLE_RESPONSE);
}) as typeof fetch;
const result = await notionModels.discoverNotionWebModels({
token: "token_v2=xyz",
fetchImpl,
});
assert.deepEqual(calls, [notionModels.NOTION_SPACES_URL, notionModels.NOTION_MODELS_URL]);
assert.equal(result.spaceId, "resolved-space");
assert.ok(result.models.length >= 2);
});
test("notion-web models route returns live getAvailableModels catalog", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "notion-web",
authType: "apikey",
name: "notion-web-discovery",
apiKey: "token_v2=sess; space_id=space-live-1",
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.includes("getAvailableModels")) {
assert.equal(init?.method, "POST");
const body = JSON.parse(String(init?.body || "{}"));
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/);
return Response.json(SAMPLE_RESPONSE);
}
return new Response("unexpected", { status: 500 });
}) as typeof globalThis.fetch;
try {
const response = await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
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"));
assert.equal(ids.includes("disabled-model"), false);
} finally {
globalThis.fetch = originalFetch;
}
});