Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/notion-web-available-models.md
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)
13 changes: 5 additions & 8 deletions open-sse/config/providers/registry/notion-web/index.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 })),
};
43 changes: 35 additions & 8 deletions open-sse/executors/notion-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,19 +127,43 @@ 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 model =
typeof notionModel === "string" &&
notionModel.trim() &&
notionModel.trim() !== "notion-ai"
? notionModel.trim()
: "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The notionModel string is trimmed multiple times redundantly. We can trim it once and reuse the result, which is cleaner and more efficient.

Suggested change
const model =
typeof notionModel === "string" &&
notionModel.trim() &&
notionModel.trim() !== "notion-ai"
? notionModel.trim()
: "";
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?]]`. */
Expand Down Expand Up @@ -250,8 +274,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;

Expand Down
286 changes: 286 additions & 0 deletions open-sse/services/notionWebModels.ts
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()) : "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pasted cookie values from users can sometimes contain malformed percent-encodings (e.g., a raw % character). Calling decodeURIComponent on such values will throw a URIError, which could crash the discovery process. Wrapping it in a try-catch block and falling back to the raw trimmed value is much safer.

  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}`
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check cookie.includes("space_id=") is case-sensitive and only checks for the exact string "space_id=". However, extractSpaceIdFromNotionCookie supports both space_id and spaceId case-insensitively. If the cookie contains spaceId=..., cookie.includes("space_id=") will evaluate to false, causing a duplicate space_id to be appended to the cookie string.

Using extractSpaceIdFromNotionCookie(cookie) as the condition is more robust and consistent with how the space ID is extracted.

Suggested change
const headers = buildNotionModelsDiscoveryHeaders(
cookie.includes("space_id=") ? cookie : `${cookie}; space_id=${spaceId}`
);
const headers = buildNotionModelsDiscoveryHeaders(
extractSpaceIdFromNotionCookie(cookie) ? cookie : cookie + "; space_id=" + spaceId
);

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,
};
Loading
Loading