diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 87a4bd3f29..27085cf3c6 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -32,6 +32,7 @@ import { __resetChatGptImageCacheForTesting, type ChatGptImageConversationContext, } from "../services/chatgptImageCache.ts"; +import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -84,52 +85,6 @@ function deviceIdFor(cookie: string): string { // ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug // catalog comes from /backend-api/models on a logged-in account; // "gpt-5-4-t-mini" is ChatGPT's abbreviated slug for "GPT-5.4 Thinking Mini". -const MODEL_MAP: Record = { - // ChatGPT backend slugs are also accepted directly for power users / tests. - "gpt-5-5-pro": "gpt-5-5-pro", - "gpt-5-5-pro-extended": "gpt-5-5-pro", - "gpt-5-5-thinking": "gpt-5-5-thinking", - "gpt-5-5": "gpt-5-5", - "gpt-5-4-pro": "gpt-5-4-pro", - "gpt-5-4-thinking": "gpt-5-4-thinking", - "gpt-5-4-t-mini": "gpt-5-4-t-mini", - "gpt-5-3": "gpt-5-3", - "gpt-5-3-mini": "gpt-5-3-mini", - - // Public OmniRoute dot-form ids exposed by the provider catalog. - "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-pro-extended": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", - "gpt-5.5": "gpt-5-5", - "gpt-5.4-pro": "gpt-5-4-pro", - "gpt-5.4-thinking": "gpt-5-4-thinking", - "gpt-5.4-thinking-mini": "gpt-5-4-t-mini", - "gpt-5.3-instant": "gpt-5-3-instant", - "gpt-5.3": "gpt-5-3", - "gpt-5.3-mini": "gpt-5-3-mini", - o3: "o3", -}; - -const MODEL_FORCED_EFFORT: Record = { - "gpt-5-5-pro": "standard", - "gpt-5-5-pro-extended": "extended", - "gpt-5.5-pro": "standard", - "gpt-5.5-pro-extended": "extended", -}; - -/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint - * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a - * new thinking entry there automatically extends this set. Includes the - * abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the - * reason this set exists at all rather than a substring match. - * - * Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or - * are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */ -const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( - Object.entries(MODEL_MAP) - .filter(([k]) => k.includes("thinking") || k === "o3") - .map(([, v]) => v) -); // ─── Browser-like default headers ────────────────────────────────────────── @@ -472,90 +427,6 @@ const thinkingEffortCache = new Map(); const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000; const THINKING_EFFORT_CACHE_MAX = 400; -/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking - * models and the o-series. PATCHing for a non-thinking surface is a no-op - * (the server accepts it but the routing-time read picks the wrong knob). - * - * Three branches because the input can arrive in three shapes: - * 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking - * variant carries the literal "thinking" substring here. - * 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`). - * 3. Resolved chatgpt.com slug that drops the substring under abbreviation - * (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is - * derived from MODEL_MAP itself so adding a new abbreviated thinking - * mapping automatically extends the check. - * - * Branch 3 also catches the case where a caller passes the chatgpt.com slug - * directly as the `model` field (no MODEL_MAP translation needed), which - * would otherwise silently bypass the PATCH. */ -function isThinkingCapableModel(modelId: string, slug: string): boolean { - return ( - modelId.includes("thinking") || - modelId === "o3" || - slug.includes("thinking") || - THINKING_CAPABLE_SLUGS.has(slug) || - THINKING_CAPABLE_SLUGS.has(modelId) - ); -} - -/** Map either a chatgpt.com-native value (`standard`/`extended`) or the - * OpenAI Chat Completions `reasoning_effort` field to the value the - * `user_last_used_model_config` endpoint expects. - * - * minimal | low | medium | standard → standard - * high | xhigh | extended → extended - * - * `medium` collapses to `standard` because chatgpt.com only has two levels — - * there is no separate medium tier on the web product. Returns null for - * absent/unknown inputs. */ -function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null { - if (typeof input !== "string") return null; - const v = input.trim().toLowerCase(); - if (v === "extended" || v === "high" || v === "xhigh") return "extended"; - if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { - return "standard"; - } - return null; -} - -/** Resolve the requested effort for this turn. - * Order: `providerSpecificData.thinkingEffort` (raw override, takes - * `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI - * Chat Completions field) > `body.reasoning.effort` (Responses-API nesting). - * Returns null when the caller did not request one. */ -function resolveThinkingEffort( - body: unknown, - providerSpecificData: Record | undefined -): "standard" | "extended" | null { - if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { - return normalizeThinkingEffort(providerSpecificData.thinkingEffort); - } - const b = (body as Record | null) ?? null; - if (!b) return null; - const top = normalizeThinkingEffort(b.reasoning_effort); - if (top) return top; - const nested = (b.reasoning as Record | undefined)?.effort; - return normalizeThinkingEffort(nested); -} - -interface ResolvedChatGptModel { - slug: string; - effort: "standard" | "extended" | null; - isPro: boolean; -} - -function resolveChatGptModel( - model: string, - body: unknown, - providerSpecificData: Record | undefined -): ResolvedChatGptModel { - const slug = MODEL_MAP[model] ?? model; - const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; - const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); - const isPro = slug === "gpt-5-5-pro"; - return { slug, effort, isPro }; -} - function configuredProPollTimeoutMs(): number { const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS; @@ -1399,8 +1270,7 @@ async function* extractContent( // on a tool-role message (handled below). if (event.type === "server_ste_metadata") { const meta = (event as Record).metadata as - | Record - | undefined; + Record | undefined; if (meta && meta.turn_use_case === "image gen") { imageGenAsync = true; } @@ -2780,8 +2650,7 @@ export class ChatGptWebExecutor extends BaseExecutor { clientHeaders, }: ExecuteInput) { const messages = (body as Record | null)?.messages as - | Array> - | undefined; + Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { return { response: errorResponse(400, "Missing or empty messages array"), diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts new file mode 100644 index 0000000000..72738f43b0 --- /dev/null +++ b/open-sse/executors/chatgpt-web/models.ts @@ -0,0 +1,133 @@ +// Pure model-mapping / thinking-effort resolution for the ChatGPT-web executor. +// Extracted verbatim from chatgpt-web.ts (static maps + pure resolvers, no state). + +export const MODEL_MAP: Record = { + // ChatGPT backend slugs are also accepted directly for power users / tests. + "gpt-5-5-pro": "gpt-5-5-pro", + "gpt-5-5-pro-extended": "gpt-5-5-pro", + "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-5": "gpt-5-5", + "gpt-5-4-pro": "gpt-5-4-pro", + "gpt-5-4-thinking": "gpt-5-4-thinking", + "gpt-5-4-t-mini": "gpt-5-4-t-mini", + "gpt-5-3": "gpt-5-3", + "gpt-5-3-mini": "gpt-5-3-mini", + + // Public OmniRoute dot-form ids exposed by the provider catalog. + "gpt-5.5-pro": "gpt-5-5-pro", + "gpt-5.5-pro-extended": "gpt-5-5-pro", + "gpt-5.5-thinking": "gpt-5-5-thinking", + "gpt-5.5": "gpt-5-5", + "gpt-5.4-pro": "gpt-5-4-pro", + "gpt-5.4-thinking": "gpt-5-4-thinking", + "gpt-5.4-thinking-mini": "gpt-5-4-t-mini", + "gpt-5.3-instant": "gpt-5-3-instant", + "gpt-5.3": "gpt-5-3", + "gpt-5.3-mini": "gpt-5-3-mini", + o3: "o3", +}; + +export const MODEL_FORCED_EFFORT: Record = { + "gpt-5-5-pro": "standard", + "gpt-5-5-pro-extended": "extended", + "gpt-5.5-pro": "standard", + "gpt-5.5-pro-extended": "extended", +}; + +/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint + * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a + * new thinking entry there automatically extends this set. Includes the + * abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the + * reason this set exists at all rather than a substring match. + * + * Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or + * are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */ +export const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( + Object.entries(MODEL_MAP) + .filter(([k]) => k.includes("thinking") || k === "o3") + .map(([, v]) => v) +); + +/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking + * models and the o-series. PATCHing for a non-thinking surface is a no-op + * (the server accepts it but the routing-time read picks the wrong knob). + * + * Three branches because the input can arrive in three shapes: + * 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking + * variant carries the literal "thinking" substring here. + * 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`). + * 3. Resolved chatgpt.com slug that drops the substring under abbreviation + * (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is + * derived from MODEL_MAP itself so adding a new abbreviated thinking + * mapping automatically extends the check. + * + * Branch 3 also catches the case where a caller passes the chatgpt.com slug + * directly as the `model` field (no MODEL_MAP translation needed), which + * would otherwise silently bypass the PATCH. */ +export function isThinkingCapableModel(modelId: string, slug: string): boolean { + return ( + modelId.includes("thinking") || + modelId === "o3" || + slug.includes("thinking") || + THINKING_CAPABLE_SLUGS.has(slug) || + THINKING_CAPABLE_SLUGS.has(modelId) + ); +} + +/** Map either a chatgpt.com-native value (`standard`/`extended`) or the + * OpenAI Chat Completions `reasoning_effort` field to the value the + * `user_last_used_model_config` endpoint expects. + * + * minimal | low | medium | standard → standard + * high | xhigh | extended → extended + * + * `medium` collapses to `standard` because chatgpt.com only has two levels — + * there is no separate medium tier on the web product. Returns null for + * absent/unknown inputs. */ +export function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null { + if (typeof input !== "string") return null; + const v = input.trim().toLowerCase(); + if (v === "extended" || v === "high" || v === "xhigh") return "extended"; + if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { + return "standard"; + } + return null; +} + +/** Resolve the requested effort for this turn. + * Order: `providerSpecificData.thinkingEffort` (raw override, takes + * `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI + * Chat Completions field) > `body.reasoning.effort` (Responses-API nesting). + * Returns null when the caller did not request one. */ +export function resolveThinkingEffort( + body: unknown, + providerSpecificData: Record | undefined +): "standard" | "extended" | null { + if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { + return normalizeThinkingEffort(providerSpecificData.thinkingEffort); + } + const b = (body as Record | null) ?? null; + if (!b) return null; + const top = normalizeThinkingEffort(b.reasoning_effort); + if (top) return top; + const nested = (b.reasoning as Record | undefined)?.effort; + return normalizeThinkingEffort(nested); +} + +export interface ResolvedChatGptModel { + slug: string; + effort: "standard" | "extended" | null; + isPro: boolean; +} + +export function resolveChatGptModel( + model: string, + body: unknown, + providerSpecificData: Record | undefined +): ResolvedChatGptModel { + const slug = MODEL_MAP[model] ?? model; + const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; + const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); + const isPro = slug === "gpt-5-5-pro"; + return { slug, effort, isPro }; +} diff --git a/tests/unit/chatgpt-web-models-split.test.ts b/tests/unit/chatgpt-web-models-split.test.ts new file mode 100644 index 0000000000..f25f78556d --- /dev/null +++ b/tests/unit/chatgpt-web-models-split.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the chatgpt-web model-mapping extraction. +// The static model maps + pure thinking-effort resolvers live in the pure leaf +// chatgpt-web/models.ts (no module state). Host imports the two it uses back. +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXE = join(HERE, "../../open-sse/executors"); +const HOST = join(EXE, "chatgpt-web.ts"); +const LEAF = join(EXE, "chatgpt-web/models.ts"); + +test("leaf hosts the model maps + resolvers and does not import the host", () => { + const src = readFileSync(LEAF, "utf8"); + for (const sym of ["MODEL_MAP", "resolveChatGptModel", "resolveThinkingEffort"]) { + assert.match(src, new RegExp(`export (const|function) ${sym}\\b`)); + } + assert.doesNotMatch(src, /from "\.\.\/chatgpt-web\.ts"/); +}); + +test("host imports the resolvers back from the leaf", () => { + const host = readFileSync(HOST, "utf8"); + assert.match(host, /from "\.\/chatgpt-web\/models\.ts"/); +}); + +test("resolveChatGptModel maps a dot-form model id to a chatgpt slug", async () => { + const { resolveChatGptModel, MODEL_MAP } = + await import("../../open-sse/executors/chatgpt-web/models.ts"); + const firstKey = Object.keys(MODEL_MAP)[0]; + const resolved = resolveChatGptModel(firstKey); + assert.equal(typeof resolved.slug, "string"); + assert.ok(resolved.slug.length > 0); +});