mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
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:
1
changelog.d/features/notion-web-available-models.md
Normal file
1
changelog.d/features/notion-web-available-models.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie)
|
||||
@@ -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 })),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
319
open-sse/services/notionWebModels.ts
Normal file
319
open-sse/services/notionWebModels.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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=...`.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
182
tests/unit/notion-web-models-discovery.test.ts
Normal file
182
tests/unit/notion-web-models-discovery.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user