diff --git a/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md b/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md new file mode 100644 index 0000000000..bf603f72d5 --- /dev/null +++ b/changelog.d/fixes/10077-chatgpt-web-max-thinking-effort.md @@ -0,0 +1 @@ +- **fix(chatgpt-web):** Preserve native `max` thinking effort through ChatGPT Web routing ([#10077](https://github.com/diegosouzapw/OmniRoute/pull/10077)) — thanks @zannen7 diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 9930b84ec6..0168d07de2 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -441,7 +441,7 @@ function configuredProPollIntervalMs(): number { async function setUserThinkingEffort( modelSlug: string, - effort: "standard" | "extended", + effort: "standard" | "extended" | "max", accessToken: string, accountId: string | null, sessionId: string, @@ -984,7 +984,7 @@ function buildConversationBody( // chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a // durable image conversation (image generation/editing). persistConversation: boolean; - thinkingEffort: "standard" | "extended" | null; + thinkingEffort: "standard" | "extended" | "max" | null; continuation?: ChatGptImageConversationContext | null; } ): Record { diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts index b0f905d783..87abd04880 100644 --- a/open-sse/executors/chatgpt-web/models.ts +++ b/open-sse/executors/chatgpt-web/models.ts @@ -25,7 +25,9 @@ export const MODEL_MAP: Record = { o3: "o3", }; -export const MODEL_FORCED_EFFORT: Record = { +export type ChatGptThinkingEffort = "standard" | "extended" | "max"; + +export const MODEL_FORCED_EFFORT: Record = { "gpt-5-6-pro": "standard", "gpt-5.6-pro": "standard", "gpt-5-5-pro": "standard", @@ -62,20 +64,21 @@ export function isThinkingCapableModel(modelId: string, slug: string): boolean { ); } -/** Map either a chatgpt.com-native value (`standard`/`extended`) or the +/** Map either a chatgpt.com-native value (`standard`/`extended`/`max`) 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 + * high | extended → extended + * xhigh | max → max * - * `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 { + * `xhigh` remains a compatibility alias for the highest ChatGPT Web tier. + * Returns null for absent/unknown inputs. */ +export function normalizeThinkingEffort(input: unknown): ChatGptThinkingEffort | null { if (typeof input !== "string") return null; const v = input.trim().toLowerCase(); - if (v === "extended" || v === "high" || v === "xhigh") return "extended"; + if (v === "max" || v === "xhigh") return "max"; + if (v === "extended" || v === "high") return "extended"; if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { return "standard"; } @@ -83,14 +86,14 @@ export function normalizeThinkingEffort(input: unknown): "standard" | "extended" } /** 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. */ + * Order: `providerSpecificData.thinkingEffort` (raw override, takes native + * `standard`/`extended`/`max` values) > `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 { +): ChatGptThinkingEffort | null { if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { return normalizeThinkingEffort(providerSpecificData.thinkingEffort); } @@ -104,7 +107,7 @@ export function resolveThinkingEffort( export interface ResolvedChatGptModel { slug: string; - effort: "standard" | "extended" | null; + effort: ChatGptThinkingEffort | null; isPro: boolean; } diff --git a/tests/unit/chatgpt-web-max-thinking-effort.test.ts b/tests/unit/chatgpt-web-max-thinking-effort.test.ts new file mode 100644 index 0000000000..f245c8aad2 --- /dev/null +++ b/tests/unit/chatgpt-web-max-thinking-effort.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + normalizeThinkingEffort, + resolveChatGptModel, +} from "../../open-sse/executors/chatgpt-web/models.ts"; +import { + ChatGptWebExecutor, + __resetChatGptWebCachesForTesting, +} from "../../open-sse/executors/chatgpt-web.ts"; +import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/chatgptTlsClient.ts"; + +function mockResponse(status: number, body: unknown, contentType = "application/json") { + return { + status, + headers: new Headers({ "Content-Type": contentType }), + text: typeof body === "string" ? body : JSON.stringify(body), + body: null, + }; +} + +function installMockFetch() { + const calls: { + userConfigUrl: string | null; + userConfigMethod: string | null; + conversationBody: string | null; + } = { + userConfigUrl: null, + userConfigMethod: null, + conversationBody: null, + }; + + __setTlsFetchOverrideForTesting(async (url, opts = {}) => { + const u = String(url); + + if (u === "https://chatgpt.com/" || u === "https://chatgpt.com") { + return mockResponse( + 200, + '', + "text/html" + ); + } + + if (u.includes("/api/auth/session")) { + return mockResponse(200, { + accessToken: "jwt-test", + expires: new Date(Date.now() + 3_600_000).toISOString(), + user: { id: "user-test" }, + }); + } + + if (u.includes("/backend-api/settings/user_last_used_model_config")) { + calls.userConfigUrl = u; + calls.userConfigMethod = (opts.method || "GET").toUpperCase(); + return mockResponse(200, { is_disabled: false }); + } + + if (u.includes("/backend-api/sentinel/chat-requirements")) { + return mockResponse(200, { + token: "requirements-test", + proofofwork: { required: false }, + }); + } + + if (u.endsWith("/backend-api/f/conversation")) { + calls.conversationBody = opts.body ?? null; + return mockResponse( + 200, + [ + `data: ${JSON.stringify({ + conversation_id: "conv-test", + message: { + id: "msg-test", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["ok"] }, + status: "finished_successfully", + }, + })}`, + "", + "data: [DONE]", + "", + ].join("\r\n"), + "text/event-stream" + ); + } + + // Browser-like warmup endpoints are best-effort. Returning a normal 200 + // keeps this focused test independent from their response details. + return mockResponse(200, {}); + }); + + return { + calls, + restore() { + __setTlsFetchOverrideForTesting(null); + }, + }; +} + +test("ChatGPT Web thinking effort aliases map to the three native tiers", () => { + const cases = [ + ["minimal", "standard"], + ["low", "standard"], + ["medium", "standard"], + ["standard", "standard"], + ["high", "extended"], + ["extended", "extended"], + ["xhigh", "max"], + ["max", "max"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(normalizeThinkingEffort(input), expected, input); + } +}); + +test("providerSpecificData can request native max with highest precedence", () => { + const resolved = resolveChatGptModel( + "gpt-5.6-thinking", + { reasoning_effort: "low" }, + { thinkingEffort: "max" } + ); + assert.equal(resolved.effort, "max"); +}); + +for (const effort of ["xhigh", "max"] as const) { + test(`ChatGPT Web executor sends ${effort} as thinking_effort=max`, async () => { + __resetChatGptWebCachesForTesting(); + const mock = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.6-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(result.response.status, 200); + assert.equal(mock.calls.userConfigMethod, "PATCH"); + assert.ok(mock.calls.userConfigUrl); + const settingsUrl = new URL(mock.calls.userConfigUrl); + assert.equal(settingsUrl.searchParams.get("model_slug"), "gpt-5-6-thinking"); + assert.equal(settingsUrl.searchParams.get("thinking_effort"), "max"); + + assert.ok(mock.calls.conversationBody); + const conversationBody = JSON.parse(mock.calls.conversationBody) as Record; + assert.equal(conversationBody.thinking_effort, "max"); + } finally { + mock.restore(); + } + }); +}