From ef622834db4dda2df887d738e0b79c85e0e0519a Mon Sep 17 00:00:00 2001 From: payne Date: Thu, 30 Apr 2026 17:45:27 +0300 Subject: [PATCH] feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821) Integrated into release/v3.7.6 --- open-sse/executors/base.ts | 14 +- open-sse/executors/chatgpt-web.ts | 191 ++++++++++++++++++++++ tests/unit/chatgpt-web.test.ts | 263 ++++++++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 4 deletions(-) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index a1137eec01..93fe3a2f06 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -274,11 +274,17 @@ export class BaseExecutor { const cloned = { ...body } as Record; if (Array.isArray(cloned.tools)) { - cloned.tools = cloned.tools.map((tool: any) => { - if (tool?.function && typeof tool.function === "object") { - const func = { ...tool.function }; + cloned.tools = cloned.tools.map((tool: unknown) => { + if ( + tool && + typeof tool === "object" && + "function" in tool && + tool.function && + typeof tool.function === "object" + ) { + const func = { ...(tool.function as Record) }; if (func.description === "") delete func.description; - return { ...tool, function: func }; + return { ...(tool as Record), function: func }; } return tool; }); diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 3acc626d8c..d550a967e4 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -36,6 +36,7 @@ const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`; const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`; const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`; const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; +const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`; const CHATGPT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; @@ -92,6 +93,20 @@ const MODEL_MAP: Record = { o3: "o3", }; +/** 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 ────────────────────────────────────────── function browserHeaders(): Record { @@ -413,6 +428,158 @@ async function runSessionWarmup( } } +// ─── Thinking-effort preference (PATCH user_last_used_model_config) ──────── +// chatgpt.com has two thinking levels for its dedicated thinking-models: +// • standard — default, faster +// • extended — longer reasoning budget +// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config` +// once, then issues the conversation request — the conversation endpoint itself +// has no `thinking_effort` field; the server reads the user's stored preference +// at routing time. We mirror that handshake when an OpenAI-style request +// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort` +// override). +// +// Cached per (cookie, slug, effort): the preference persists server-side, so +// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or +// whenever the caller switches efforts. + +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); +} + +async function setUserThinkingEffort( + modelSlug: string, + effort: "standard" | "extended", + accessToken: string, + accountId: string | null, + sessionId: string, + deviceId: string, + cookie: string, + signal: AbortSignal | null | undefined, + log: + | { + debug?: (tag: string, msg: string) => void; + warn?: (tag: string, msg: string) => void; + } + | null + | undefined +): Promise { + const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`; + const now = Date.now(); + const last = thinkingEffortCache.get(cacheKey); + if (last && now - last < THINKING_EFFORT_TTL_MS) { + log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`); + return; + } + if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) { + const first = thinkingEffortCache.keys().next().value; + if (first) thinkingEffortCache.delete(first); + } + + const url = + `${USER_LAST_USED_MODEL_CONFIG_URL}` + + `?model_slug=${encodeURIComponent(modelSlug)}` + + `&thinking_effort=${encodeURIComponent(effort)}`; + const headers: Record = { + ...browserHeaders(), + ...oaiHeaders(sessionId, deviceId), + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + Cookie: buildSessionCookieHeader(cookie), + Priority: "u=4", + }; + if (accountId) headers["chatgpt-account-id"] = accountId; + + try { + const r = await tlsFetchChatGpt(url, { + method: "PATCH", + headers, + timeoutMs: 15_000, + signal, + }); + if (r.status >= 400) { + log?.warn?.( + "CGPT-WEB", + `thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)` + ); + return; + } + thinkingEffortCache.set(cacheKey, now); + log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`); + } catch (err) { + log?.warn?.( + "CGPT-WEB", + `thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + async function prepareChatRequirements( accessToken: string, accountId: string | null, @@ -2380,6 +2547,29 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); + // 2a''. Apply thinking_effort preference for thinking-capable models. + // Mirrors what chatgpt.com's web UI does when the user toggles the + // "Standard"/"Extended" thinking switch — PATCH the user-config endpoint + // before issuing the conversation. The conversation request itself has + // no `thinking_effort` field; the server reads the stored preference at + // routing time. Best-effort: a failed PATCH falls back to whatever the + // account's current preference is. + const earlyModelSlug = MODEL_MAP[model] ?? model; + const requestedEffort = resolveThinkingEffort(body, credentials.providerSpecificData); + if (requestedEffort && isThinkingCapableModel(model, earlyModelSlug)) { + await setUserThinkingEffort( + earlyModelSlug, + requestedEffort, + tokenEntry.accessToken, + tokenEntry.accountId, + sessionId, + deviceId, + cookie, + signal, + log + ); + } + // 2b. Sentinel chat-requirements let reqs: ChatRequirements; try { @@ -2656,6 +2846,7 @@ function stringToStream(text: string): ReadableStream { export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); warmupCache.clear(); + thinkingEffortCache.clear(); deviceIdCache.clear(); __resetChatGptImageCacheForTesting(); dplCache = null; diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index 585672f380..e899e2306d 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -64,11 +64,13 @@ function installMockFetch({ fileDownload, attachmentDownload, signedDownload, + userConfig, onSession, onSentinel, onConv, onFileDownload, onAttachmentDownload, + onUserConfig, } = {}) { const calls = { session: 0, @@ -78,6 +80,9 @@ function installMockFetch({ fileDownload: 0, attachmentDownload: 0, signedDownload: 0, + userConfig: 0, + userConfigUrls: [], + userConfigMethods: [], urls: [], headers: [], bodies: [], @@ -128,6 +133,22 @@ function installMockFetch({ }; } + // /backend-api/settings/user_last_used_model_config?model_slug=...&thinking_effort=... + // Match before sentinel since /settings/* is its own surface. + if (u.includes("/backend-api/settings/user_last_used_model_config")) { + calls.userConfig++; + calls.userConfigUrls.push(u); + calls.userConfigMethods.push((opts.method || "GET").toUpperCase()); + if (onUserConfig) onUserConfig(opts, u); + const cfg = userConfig ?? { status: 200, body: { is_disabled: false } }; + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "application/json" }), + text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}), + body: null, + }; + } + if (u.includes("/sentinel/chat-requirements")) { calls.sentinel++; if (onSentinel) onSentinel(opts); @@ -1120,6 +1141,248 @@ test("Executor MODEL_MAP: dot-form OmniRoute IDs translate to dash-form ChatGPT } }); +// ─── thinking_effort PATCH user_last_used_model_config ───────────────────── + +test("thinking_effort: high → PATCH user_last_used_model_config with extended", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.5-thinking", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: "cookie-1" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 1, "exactly one PATCH issued"); + assert.equal(m.calls.userConfigMethods[0], "PATCH"); + const u = m.calls.userConfigUrls[0]; + assert.match(u, /model_slug=gpt-5-5-thinking/); + assert.match(u, /thinking_effort=extended/); + } finally { + m.restore(); + } +}); + +test("thinking_effort: low/medium → PATCH with standard", async () => { + for (const effort of ["low", "medium", "minimal"]) { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.4-thinking", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: effort }, + stream: false, + credentials: { apiKey: `cookie-${effort}` }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 1, `effort=${effort} should issue exactly one PATCH`); + assert.match(m.calls.userConfigUrls[0], /thinking_effort=standard/, `${effort} → standard`); + assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-thinking/); + } finally { + m.restore(); + } + } +}); + +test("thinking_effort: instant model never triggers PATCH even with reasoning_effort", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.3-instant", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: "cookie-instant" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 0, "instant slug must not PATCH thinking_effort"); + } finally { + m.restore(); + } +}); + +test("thinking_effort: bare chatgpt.com slug (e.g. gpt-5-4-t-mini) passed as model still PATCHes", async () => { + // Regression: the abbreviated dash-form slug "gpt-5-4-t-mini" doesn't + // carry the literal "thinking" substring, and isn't a key in MODEL_MAP + // (only its dot-form alias is), so a substring-only check would silently + // skip the PATCH for callers that send the chatgpt.com slug directly. + for (const bareSlug of ["gpt-5-4-t-mini", "gpt-5-5-thinking", "o3"]) { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: bareSlug, + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: `cookie-bare-${bareSlug}` }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal( + m.calls.userConfig, + 1, + `bare slug ${bareSlug} must trigger thinking_effort PATCH` + ); + assert.match( + m.calls.userConfigUrls[0], + new RegExp(`model_slug=${bareSlug.replace(/\./g, "\\.")}`) + ); + } finally { + m.restore(); + } + } +}); + +test("thinking_effort: thinking model without reasoning_effort skips PATCH", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.5-thinking", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "cookie-noeffort" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 0, "no effort requested → no PATCH"); + } finally { + m.restore(); + } +}); + +test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides body", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.4-thinking-mini", + body: { + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "low", // would normally map to standard + }, + stream: false, + credentials: { + apiKey: "cookie-override", + providerSpecificData: { thinkingEffort: "extended" }, + }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 1); + assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-t-mini/); + assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); + } finally { + m.restore(); + } +}); + +test("thinking_effort: nested body.reasoning.effort=high → extended", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + await executor.execute({ + model: "gpt-5.2-thinking", + body: { + messages: [{ role: "user", content: "hi" }], + reasoning: { effort: "high" }, + }, + stream: false, + credentials: { apiKey: "cookie-nested" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 1); + assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-2-thinking/); + assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); + } finally { + m.restore(); + } +}); + +test("thinking_effort: cached per (cookie, slug, effort) — second identical call skips PATCH", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const opts = { + model: "gpt-5.5-thinking", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: "cookie-cache" }, + signal: AbortSignal.timeout(10_000), + log: null, + }; + await executor.execute(opts); + await executor.execute(opts); + assert.equal(m.calls.userConfig, 1, "second identical request hits cache"); + } finally { + m.restore(); + } +}); + +test("thinking_effort: switching effort within TTL triggers a fresh PATCH", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const base = { + model: "gpt-5.5-thinking", + stream: false, + credentials: { apiKey: "cookie-switch" }, + signal: AbortSignal.timeout(10_000), + log: null, + }; + await executor.execute({ + ...base, + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + }); + await executor.execute({ + ...base, + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "low" }, + }); + assert.equal(m.calls.userConfig, 2, "different effort key bypasses cache"); + assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); + assert.match(m.calls.userConfigUrls[1], /thinking_effort=standard/); + } finally { + m.restore(); + } +}); + +test("thinking_effort: PATCH failure is non-fatal — conversation request still fires", async () => { + reset(); + const m = installMockFetch({ + userConfig: { status: 500, body: { error: "boom" } }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5-thinking", + body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + stream: false, + credentials: { apiKey: "cookie-fail" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(m.calls.userConfig, 1); + assert.equal(m.calls.conv, 1, "conversation still issued despite settings PATCH 500"); + assert.equal(result.response.status, 200); + } finally { + m.restore(); + } +}); + test("Image registry: cgpt-web/gpt-5.3-instant routes to ChatGPT Web image handler", async () => { const { parseImageModel, getImageProvider } = await import("../../open-sse/config/imageRegistry.ts");