Repository navigation
feat(providers): notion-web live model discovery via getAvailableModels #7696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
fd0583d
5323762
93e5a01
3d68f18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie) |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,286 @@ | ||||||||||||||
| /** | ||||||||||||||
| * 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); | ||||||||||||||
| return m ? decodeURIComponent(m[1].trim()) : ""; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pasted cookie values from users can sometimes contain malformed percent-encodings (e.g., a raw const m = cookie.match(re);
if (!m) return "";
try {
return decodeURIComponent(m[1].trim());
} catch {
return m[1].trim();
} |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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") || | ||||||||||||||
| "" | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 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 out: NotionDiscoveredModel[] = []; | ||||||||||||||
| const seen = new Set<string>(); | ||||||||||||||
|
|
||||||||||||||
| for (const entry of list) { | ||||||||||||||
| if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; | ||||||||||||||
| const row = entry as Record<string, unknown>; | ||||||||||||||
| if (row.isDisabled === true) continue; | ||||||||||||||
| const id = typeof row.model === "string" ? row.model.trim() : ""; | ||||||||||||||
| if (!id || seen.has(id)) continue; | ||||||||||||||
| const name = | ||||||||||||||
| typeof row.modelMessage === "string" && row.modelMessage.trim() | ||||||||||||||
| ? row.modelMessage.trim() | ||||||||||||||
| : id; | ||||||||||||||
| const family = | ||||||||||||||
| typeof row.modelFamily === "string" && row.modelFamily.trim() | ||||||||||||||
| ? row.modelFamily.trim() | ||||||||||||||
| : "notion"; | ||||||||||||||
| const efforts = ( | ||||||||||||||
| row.modelConfiguration as { supportedReasoningEfforts?: unknown } | undefined | ||||||||||||||
| )?.supportedReasoningEfforts; | ||||||||||||||
| const supportsReasoning = Array.isArray(efforts) && efforts.length > 0; | ||||||||||||||
|
|
||||||||||||||
| seen.add(id); | ||||||||||||||
| out.push({ | ||||||||||||||
| id, | ||||||||||||||
| name, | ||||||||||||||
| owned_by: family, | ||||||||||||||
| ...(supportsReasoning ? { supportsReasoning: true } : {}), | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Ensure a stable default id always exists for clients that still request notion-ai. | ||||||||||||||
| if (out.length > 0 && !seen.has("notion-ai")) { | ||||||||||||||
| out.unshift({ | ||||||||||||||
| id: "notion-ai", | ||||||||||||||
| name: "Notion AI (default)", | ||||||||||||||
| owned_by: "notion", | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return out; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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 ""; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** 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>; | ||||||||||||||
|
|
||||||||||||||
| // Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } | ||||||||||||||
| 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]; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Flat: { spaces: [{ id }] } / { spaceIds: [] } | ||||||||||||||
| const spaces = root.spaces; | ||||||||||||||
| if (Array.isArray(spaces)) { | ||||||||||||||
| for (const s of spaces) { | ||||||||||||||
| if (s && typeof s === "object" && typeof (s as { id?: string }).id === "string") { | ||||||||||||||
| return (s as { id: string }).id; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| if (Array.isArray(root.spaceIds) && typeof root.spaceIds[0] === "string") { | ||||||||||||||
| return root.spaceIds[0]; | ||||||||||||||
| } | ||||||||||||||
| return ""; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 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" | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const headers = buildNotionModelsDiscoveryHeaders( | ||||||||||||||
| cookie.includes("space_id=") ? cookie : `${cookie}; space_id=${spaceId}` | ||||||||||||||
| ); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The check Using
Suggested change
|
||||||||||||||
| 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, | ||||||||||||||
| }; | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
notionModelstring is trimmed multiple times redundantly. We can trim it once and reuse the result, which is cleaner and more efficient.