diff --git a/changelog.d/features/notion-web-available-models.md b/changelog.d/features/notion-web-available-models.md new file mode 100644 index 0000000000..ed73e21a55 --- /dev/null +++ b/changelog.d/features/notion-web-available-models.md @@ -0,0 +1 @@ +- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie) diff --git a/open-sse/config/providers/registry/notion-web/index.ts b/open-sse/config/providers/registry/notion-web/index.ts index cca2160a4c..c043137cce 100644 --- a/open-sse/config/providers/registry/notion-web/index.ts +++ b/open-sse/config/providers/registry/notion-web/index.ts @@ -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 })), }; diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index b018b40302..c54f719504 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -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> { - return messages - .filter((m) => typeof m?.content === "string" && m.content.length > 0) - .map((m) => ({ + const entries: Array> = []; + 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 = { traceId: randomUUID(), - transcript: buildNotionTranscript(messages), + transcript: buildNotionTranscript(messages, modelId), createThread: false, + asPatchResponse: true, + threadType: "workflow", + createdSource: "ai_module", }; if (spaceId) reqBody.spaceId = spaceId; diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts new file mode 100644 index 0000000000..2e135a80c1 --- /dev/null +++ b/open-sse/services/notionWebModels.ts @@ -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): 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 +): NotionDiscoveredModel | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const row = entry as Record; + 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 +): 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(); + 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 { + const cookie = normalizeNotionWebCookie(token); + const spaceId = extractSpaceIdFromNotionCookie(cookie); + const userId = extractNotionUserIdFromCookie(cookie); + 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 ? { 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 { + 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 { + 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 ""; +} + +/** 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; + + 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, +}; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index bab48bb7db..8bff5e2b49 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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; diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 1c3c5ce1db..ba46837004 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -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=` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " + + "Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.", }, }; diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts index ab873f3167..bb71acc4c4 100644 --- a/tests/unit/executor-notion-web.test.ts +++ b/tests/unit/executor-notion-web.test.ts @@ -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 = {}; diff --git a/tests/unit/notion-web-models-discovery.test.ts b/tests/unit/notion-web-models-discovery.test.ts new file mode 100644 index 0000000000..7df79fe568 --- /dev/null +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -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; + 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; + } +});