From fd0583de0fd89b4609c50aeffba678c55c295e80 Mon Sep 17 00:00:00 2001 From: artickc Date: Sun, 19 Jul 2026 01:37:06 +0300 Subject: [PATCH 1/3] 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. --- .../features/notion-web-available-models.md | 1 + .../providers/registry/notion-web/index.ts | 13 +- open-sse/executors/notion-web.ts | 43 ++- open-sse/services/notionWebModels.ts | 286 ++++++++++++++++++ src/app/api/providers/[id]/models/route.ts | 62 ++++ src/shared/constants/providers/web-cookie.ts | 3 +- tests/unit/executor-notion-web.test.ts | 31 ++ .../unit/notion-web-models-discovery.test.ts | 175 +++++++++++ 8 files changed, 597 insertions(+), 17 deletions(-) create mode 100644 changelog.d/features/notion-web-available-models.md create mode 100644 open-sse/services/notionWebModels.ts create mode 100644 tests/unit/notion-web-models-discovery.test.ts 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 000000000000..ed73e21a55bb --- /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 cca2160a4c8a..c043137cce7f 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 b018b40302e4..5d120c106086 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -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> { - return messages - .filter((m) => typeof m?.content === "string" && m.content.length > 0) - .map((m) => ({ + const entries: Array> = []; + const model = + typeof notionModel === "string" && + notionModel.trim() && + notionModel.trim() !== "notion-ai" + ? notionModel.trim() + : ""; + 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 +274,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 000000000000..91bf2045edbd --- /dev/null +++ b/open-sse/services/notionWebModels.ts @@ -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()) : ""; +} + +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(); + + for (const entry of list) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const row = entry as Record; + 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 { + 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 ""; + } +} + +/** 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; + + // 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).space; + if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) { + const ids = Object.keys(spaceMap as Record); + 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}` + ); + 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 62fe7471890a..08a37067d9c4 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -40,6 +40,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, @@ -492,6 +496,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 e17c288ada96..9cb54446fd02 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 ab873f31679f..bb71acc4c495 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 000000000000..cd9b941a65f1 --- /dev/null +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -0,0 +1,175 @@ +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"); +}); + +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; + } +}); From 53237627a0cb21fc05053109367ce83187a4ba8e Mon Sep 17 00:00:00 2001 From: artickc Date: Sun, 19 Jul 2026 02:25:47 +0300 Subject: [PATCH 2/3] 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) --- AGENTS.md | 4 ++-- CLAUDE.md | 2 +- README.md | 10 +++++----- open-sse/executors/notion-web.ts | 8 ++------ open-sse/services/notionWebModels.ts | 18 ++++++++++++++---- tests/unit/notion-web-models-discovery.test.ts | 7 +++++++ 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47fa7028ebe6..22343a866342 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,12 @@ ## Project Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support -with **264 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, +with **265 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) with **MCP Server** (94 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. -> **Live counts (v3.8.49)**: providers 264 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · +> **Live counts (v3.8.49)**: providers 265 · MCP tools 94 · MCP scopes 30 · A2A skills 6 · > open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 · > DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 · > i18n locales 42. **Refresh with `npm run check:docs-all`.** diff --git a/CLAUDE.md b/CLAUDE.md index 51f08a8c8d1c..edf6d63bc7e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 264 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 265 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/README.md b/README.md index 9559dd0a88a5..d1c4d7e834d3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # 🚀 OmniRoute — The Free AI Gateway -### Never stop coding. Connect every AI tool to **264 providers** — **90+ free** — through one endpoint. +### Never stop coding. Connect every AI tool to **265 providers** — **90+ free** — through one endpoint. **Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
@@ -149,11 +149,11 @@ -> One endpoint. **264 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. +> One endpoint. **265 providers.** Never stop building — and let OmniRoute pick the cheapest one that works. - + @@ -368,7 +368,7 @@ All **18** strategies — mix & match per combo step: -> The most complete catalog of any open-source router: **264 providers**, **90+ with a free tier**, **11 free forever**. +> The most complete catalog of any open-source router: **265 providers**, **90+ with a free tier**, **11 free forever**.
@@ -874,7 +874,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo **Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system. **Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0. **Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected. -**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 264 providers. +**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 265 providers. 📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md) diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index 5d120c106086..c54f71950439 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -136,12 +136,8 @@ export function buildNotionTranscript( notionModel?: string ): Array> { const entries: Array> = []; - 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(), diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts index 91bf2045edbd..a12621e8b5a7 100644 --- a/open-sse/services/notionWebModels.ts +++ b/open-sse/services/notionWebModels.ts @@ -65,7 +65,14 @@ 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()) : ""; + 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 { @@ -253,9 +260,12 @@ export async function discoverNotionWebModels(opts: { ); } - const headers = buildNotionModelsDiscoveryHeaders( - cookie.includes("space_id=") ? cookie : `${cookie}; space_id=${spaceId}` - ); + // 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, { diff --git a/tests/unit/notion-web-models-discovery.test.ts b/tests/unit/notion-web-models-discovery.test.ts index cd9b941a65f1..7df79fe56800 100644 --- a/tests/unit/notion-web-models-discovery.test.ts +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -79,6 +79,13 @@ test("cookie helpers extract space_id and user id", () => { "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", () => { From 3d68f18d5a8881bc7fecf0881df14e7ff9683d7d Mon Sep 17 00:00:00 2001 From: artickc Date: Sun, 19 Jul 2026 04:21:58 -0300 Subject: [PATCH 3/3] refactor(notion-web): extract helpers to keep parseNotionAvailableModels/pickFirstSpaceId under complexity cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/services/notionWebModels.ts | 129 ++++++++++++++++----------- 1 file changed, 76 insertions(+), 53 deletions(-) diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts index a12621e8b5a7..2e135a80c1ab 100644 --- a/open-sse/services/notionWebModels.ts +++ b/open-sse/services/notionWebModels.ts @@ -92,6 +92,51 @@ export function extractNotionUserIdFromCookie(cookie: string): string { ); } +/** 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 @@ -102,47 +147,14 @@ export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel const list = (data as { models?: unknown }).models; if (!Array.isArray(list)) return []; - const out: NotionDiscoveredModel[] = []; const seen = new Set(); - + const out: NotionDiscoveredModel[] = []; for (const entry of list) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; - const row = entry as Record; - 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", - }); + const model = parseNotionModelEntry(entry, seen); + if (model) out.push(model); } - return out; + return withDefaultNotionModel(out, seen); } export function buildNotionModelsDiscoveryHeaders(token: string): Record { @@ -205,12 +217,8 @@ export async function resolveNotionSpaceIdFromGetSpaces( } } -/** 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; - - // Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } +/** 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; @@ -219,22 +227,37 @@ export function pickFirstSpaceId(data: unknown): string { if (ids.length > 0) return ids[0]; } } + return ""; +} - // 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; - } +/** 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; } } - if (Array.isArray(root.spaceIds) && typeof root.spaceIds[0] === "string") { - return root.spaceIds[0]; - } 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.
🚫 Never hit limits
Auto-fallback across 264 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
🚫 Never hit limits
Auto-fallback across 265 providers in milliseconds. Quota out? Next provider takes over — zero downtime.
💸 Save up to 95% tokens
RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).
🆓 $0 to start
90+ providers with a free tier, 11 free forever (Kiro, Qoder, Pollinations, LongCat…). No card needed.