feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821)

Integrated into release/v3.7.6
This commit is contained in:
payne
2026-04-30 17:45:27 +03:00
committed by GitHub
parent bc11f634ff
commit ef622834db
3 changed files with 464 additions and 4 deletions

View File

@@ -274,11 +274,17 @@ export class BaseExecutor {
const cloned = { ...body } as Record<string, unknown>;
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<string, unknown>) };
if (func.description === "") delete func.description;
return { ...tool, function: func };
return { ...(tool as Record<string, unknown>), function: func };
}
return tool;
});

View File

@@ -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<string, string> = {
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<string> = new Set(
Object.entries(MODEL_MAP)
.filter(([k]) => k.includes("thinking") || k === "o3")
.map(([, v]) => v)
);
// ─── Browser-like default headers ──────────────────────────────────────────
function browserHeaders(): Record<string, string> {
@@ -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<string, number>();
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<string, unknown> | undefined
): "standard" | "extended" | null {
if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) {
return normalizeThinkingEffort(providerSpecificData.thinkingEffort);
}
const b = (body as Record<string, unknown> | null) ?? null;
if (!b) return null;
const top = normalizeThinkingEffort(b.reasoning_effort);
if (top) return top;
const nested = (b.reasoning as Record<string, unknown> | 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<void> {
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<string, string> = {
...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<Uint8Array> {
export function __resetChatGptWebCachesForTesting(): void {
tokenCache.clear();
warmupCache.clear();
thinkingEffortCache.clear();
deviceIdCache.clear();
__resetChatGptImageCacheForTesting();
dplCache = null;

View File

@@ -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");