From a352c23bad8f5ce3a0148b9016ce1ba649d94902 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 20 Aug 2026 23:31:48 +0900 Subject: [PATCH] fix(chatgpt-web): refresh current model catalog (#10637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (123/123 focused chatgpt-web tests, typecheck:core clean, file-size/changelog gates green). Thanks! --- open-sse/config/freeModelCatalog.data.ts | 4 +- .../providers/registry/chatgpt-web/index.ts | 85 ++- open-sse/config/providers/shared.ts | 6 + open-sse/executors/chatgpt-web.ts | 133 +---- open-sse/executors/chatgpt-web/models.ts | 138 ++--- src/lib/providers/modelListingCapability.ts | 2 +- src/sse/services/model.ts | 31 +- tests/unit/chatgpt-web-citations.test.ts | 20 +- tests/unit/chatgpt-web-handoff-resume.test.ts | 8 +- .../chatgpt-web-max-thinking-effort.test.ts | 165 +----- tests/unit/chatgpt-web-models-split.test.ts | 8 +- tests/unit/chatgpt-web-tools-5240.test.ts | 19 +- tests/unit/chatgpt-web-tools-7679.test.ts | 6 +- tests/unit/chatgpt-web.test.ts | 511 +++++------------- ...-model-catalog-reconciliation-8926.test.ts | 42 ++ .../model-listing-capability-5420.test.ts | 2 + 16 files changed, 420 insertions(+), 760 deletions(-) diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 3d1777ed65..fe30a37325 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -16,9 +16,11 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; * rewrites file timestamps on every deploy, which would report a months-old * catalog as "updated today". Bump this whenever the entries below change. */ -export const FREE_CATALOG_CURATED_AT = "2026-08-16"; +export const FREE_CATALOG_CURATED_AT = "2026-08-18"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free-thinking", displayName: "GPT-5.6 Luna (Free, Think)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, diff --git a/open-sse/config/providers/registry/chatgpt-web/index.ts b/open-sse/config/providers/registry/chatgpt-web/index.ts index bb57c7f31c..16c1522e2b 100644 --- a/open-sse/config/providers/registry/chatgpt-web/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web/index.ts @@ -9,12 +9,83 @@ export const chatgpt_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ - { id: "gpt-5.6-pro", name: "GPT-5.6 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended", toolCalling: false }, // pro tier only, extended effort - { id: "gpt-5.5-pro", name: "GPT-5.5 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5", name: "GPT-5.5 Instant", toolCalling: false }, // free, plus, pro tier - { id: "o3", name: "o3", toolCalling: false }, // plus ~ tier + { + id: "gpt-5.6-sol-pro", + name: "GPT-5.6 Sol (Pro)", + liveCatalogIds: ["gpt-5-6-pro"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-xhigh", + name: "GPT-5.6 Sol (Xhigh)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-high", + name: "GPT-5.6 Sol (High)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-medium", + name: "GPT-5.6 Sol (Medium)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-instant", + name: "GPT-5.6 Sol (Instant)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free-thinking", + name: "GPT-5.6 Luna (Free, Think)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free", + name: "GPT-5.6 Luna (Free)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro-extended", + name: "GPT-5.5 (Pro Extended)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro", + name: "GPT-5.5 (Pro)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-xhigh", + name: "GPT-5.5 (Xhigh)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-high", + name: "GPT-5.5 (High)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-medium", + name: "GPT-5.5 (Medium)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-instant", + name: "GPT-5.5 (Instant)", + liveCatalogIds: ["gpt-5-5"], + toolCalling: false, + }, ], }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 88fff0fef7..94e39b29a0 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -46,6 +46,12 @@ export interface RegistryModel { id: string; name: string; aliases?: readonly string[]; + /** + * Upstream model IDs that prove this static model is live when the provider + * has an authoritative synchronized catalog. Needed for curated IDs whose + * public name differs from the ID sent to the upstream service. + */ + liveCatalogIds?: readonly string[]; toolCalling?: boolean; supportsReasoning?: boolean; supportedThinkingEfforts?: readonly string[]; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 0168d07de2..438565b45c 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -32,7 +32,11 @@ import { __resetChatGptImageCacheForTesting, type ChatGptImageConversationContext, } from "../services/chatgptImageCache.ts"; -import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts"; +import { + resolveChatGptModel, + resolveChatGptSystemHints, + type ChatGptThinkingEffort, +} from "./chatgpt-web/models.ts"; import { cleanChatGptText } from "./chatgpt-web/citations.ts"; import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts"; @@ -43,7 +47,6 @@ 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 DEFAULT_PRO_POLL_TIMEOUT_MS = 20 * 60_000; const DEFAULT_PRO_POLL_INTERVAL_MS = 4_000; @@ -81,10 +84,8 @@ function deviceIdFor(cookie: string): string { return id; } -// OmniRoute model ID → ChatGPT internal slug. The public ChatGPT Web catalog -// keeps OmniRoute's historical dot-form IDs (e.g. "gpt-5.5-pro"), while -// 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. +// OmniRoute model IDs select a GPT-5.6 Sol performance lane. Captured browser +// requests use one of `gpt-5-6`, `gpt-5-6-thinking`, or `gpt-5-6-pro`. // ─── Browser-like default headers ────────────────────────────────────────── @@ -408,25 +409,6 @@ 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; - 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; @@ -439,73 +421,6 @@ function configuredProPollIntervalMs(): number { return Math.floor(raw); } -async function setUserThinkingEffort( - modelSlug: string, - effort: "standard" | "extended" | "max", - 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, @@ -889,6 +804,7 @@ interface ChatGptMessage { id: string; author: { role: string }; content: { content_type: "text"; parts: string[] }; + metadata?: Record; } /** @@ -984,7 +900,8 @@ function buildConversationBody( // chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a // durable image conversation (image generation/editing). persistConversation: boolean; - thinkingEffort: "standard" | "extended" | "max" | null; + thinkingEffort: ChatGptThinkingEffort | null; + systemHints: readonly string[]; continuation?: ChatGptImageConversationContext | null; } ): Record { @@ -1021,6 +938,8 @@ function buildConversationBody( }); } + const systemHints = options.systemHints; + const currentUserContent = hasOpenWebUIImageContext(parsed) ? "Briefly acknowledge the image result described in the system context. Do not generate, edit, or request another image." : parsed.currentMsg || ""; @@ -1029,6 +948,7 @@ function buildConversationBody( id: randomUUID(), author: { role: "user" }, content: { content_type: "text", parts: [currentUserContent] }, + ...(systemHints.length > 0 ? { metadata: { system_hints: [...systemHints] } } : {}), }); return { @@ -1051,6 +971,7 @@ function buildConversationBody( supports_buffering: true, force_parallel_switch: "auto", paragen_cot_summary_display_override: "allow", + ...(systemHints.length > 0 ? { system_hints: [...systemHints] } : {}), ...(options.thinkingEffort ? { thinking_effort: options.thinkingEffort } : {}), }; } @@ -2920,24 +2841,6 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); - // 2a''. Apply thinking-effort preference for thinking models. - // Dedicated thinking models mirror the browser's user-config PATCH; - // GPT-5.5 Pro effort is sent with the conversation body. - const requestedEffort = resolvedModel.effort; - if (requestedEffort && isThinkingCapableModel(model, modelSlug)) { - await setUserThinkingEffort( - modelSlug, - requestedEffort, - tokenEntry.accessToken, - tokenEntry.accountId, - sessionId, - deviceId, - cookie, - signal, - log - ); - } - // 2b. Sentinel chat-requirements let reqs: ChatRequirements; try { @@ -3019,7 +2922,7 @@ export class ChatGptWebExecutor extends BaseExecutor { } // Toggle Temporary Chat off only when ChatGPT needs a durable image - // conversation. Text requests, including GPT-5.5 Pro, stay temporary so + // conversation. Text requests, including GPT-5.6 Sol Pro, stay temporary so // they do not show up in the user's chatgpt.com sidebar/history. const imageEdit = looksLikeImageEditRequest(parsed); const continuation = imageEdit ? parsed.latestImageContext : null; @@ -3033,13 +2936,14 @@ export class ChatGptWebExecutor extends BaseExecutor { : "Image-gen intent detected — disabling Temporary Chat for this turn" ); } else if (resolvedModel.isPro) { - log?.debug?.("CGPT-WEB", "GPT-5.5 Pro text request — keeping Temporary Chat enabled"); + log?.debug?.("CGPT-WEB", "GPT-5.6 Sol Pro text request — keeping Temporary Chat enabled"); } const parentMessageId = continuation?.parentMessageId ?? randomUUID(); const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, { persistConversation, - thinkingEffort: requestedEffort, + thinkingEffort: resolvedModel.effort, + systemHints: resolveChatGptSystemHints(model), continuation, }); @@ -3230,7 +3134,6 @@ function stringToStream(text: string): ReadableStream { export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); warmupCache.clear(); - thinkingEffortCache.clear(); deviceIdCache.clear(); __resetChatGptImageCacheForTesting(); dplCache = null; diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts index 87abd04880..1917437baa 100644 --- a/open-sse/executors/chatgpt-web/models.ts +++ b/open-sse/executors/chatgpt-web/models.ts @@ -3,106 +3,60 @@ export const MODEL_MAP: Record = { // ChatGPT backend slugs are also accepted directly for power users / tests. - "gpt-5-6-pro": "gpt-5-6-pro", + "gpt-5-6": "gpt-5-6", "gpt-5-6-thinking": "gpt-5-6-thinking", - "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-6-pro": "gpt-5-6-pro", "gpt-5-5": "gpt-5-5", - "gpt-5-3": "gpt-5-3", - "gpt-5-3-mini": "gpt-5-3-mini", + "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-5-pro": "gpt-5-5-pro", - // Public OmniRoute dot-form ids exposed by the provider catalog. - "gpt-5.6-pro": "gpt-5-6-pro", - "gpt-5.6-thinking": "gpt-5-6-thinking", + // Free accounts leave Luna selection to ChatGPT's server-side auto router. + "gpt-5.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", + + // Captured from a real ChatGPT v2 picker conversation. The visible + // performance levels select distinct backend model/effort pairs. + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-thinking", "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", + // Compatibility alias for existing chatgpt-web image integrations. It is + // intentionally absent from the provider's visible curated model list. "gpt-5.5": "gpt-5-5", - "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 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", - "gpt-5-5-pro-extended": "extended", +export const MODEL_FORCED_EFFORT: Record = { + "gpt-5.6-sol-instant": null, + "gpt-5.6-sol-medium": "standard", + "gpt-5.6-sol-high": "extended", + "gpt-5.6-sol-xhigh": "max", + "gpt-5.6-sol-pro": "standard", + "gpt-5.5-instant": null, + "gpt-5.5-medium": "standard", + "gpt-5.5-high": "extended", + "gpt-5.5-xhigh": "max", "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. - * - * 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) -); +const MODEL_SYSTEM_HINTS: Record = { + // Captured from the Free-account Think toggle. ChatGPT sends this both at + // the request root and on the user message metadata. + "gpt-5.6-luna-free-thinking": ["reason"], +}; -/** 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). - * - * The lookup also catches callers that pass a chatgpt.com slug directly as - * the `model` field without MODEL_MAP translation. */ -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`/`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 | extended → extended - * xhigh | max → max - * - * `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 === "max" || v === "xhigh") return "max"; - if (v === "extended" || v === "high") 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 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 -): ChatGptThinkingEffort | 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 function resolveChatGptSystemHints(model: string): string[] { + return [...(MODEL_SYSTEM_HINTS[model] ?? [])]; } export interface ResolvedChatGptModel { @@ -113,12 +67,16 @@ export interface ResolvedChatGptModel { export function resolveChatGptModel( model: string, - body: unknown, - providerSpecificData: Record | undefined + _body?: unknown, + _providerSpecificData?: Record ): 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-6-pro" || slug === "gpt-5-5-pro"; + const effort = MODEL_FORCED_EFFORT[model] ?? null; + const isPro = + model === "gpt-5.6-sol-pro" || + model === "gpt-5.5-pro" || + model === "gpt-5.5-pro-extended" || + slug === "gpt-5-6-pro" || + slug === "gpt-5-5-pro"; return { slug, effort, isPro }; } diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index 070520dc6c..8887a1efc7 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -11,7 +11,7 @@ const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); /** Providers whose registry catalog is the complete, intentional model list. */ -const CURATED_MODEL_ONLY_PROVIDERS = new Set(["kimi-web", "zai-web"]); +const CURATED_MODEL_ONLY_PROVIDERS = new Set(["chatgpt-web", "kimi-web", "zai-web"]); export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 53e065d8ef..7a84606cd1 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -206,6 +206,24 @@ function findSyncedModelMeta(models: unknown, modelId: string): any { return Array.isArray(models) ? models.find((model: any) => model.id === modelId) : undefined; } +function findLiveCatalogModelMeta( + providerId: string, + requestedModelId: string, + resolvedModelId: string, + syncedModels: unknown +): any { + const directMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + if (directMatch || !Array.isArray(syncedModels)) return directMatch; + + const registryModel = findRegistryModel(providerId, requestedModelId); + const liveCatalogIds = registryModel?.liveCatalogIds; + if (!Array.isArray(liveCatalogIds) || liveCatalogIds.length === 0) return undefined; + + return syncedModels.find( + (model: any) => typeof model?.id === "string" && liveCatalogIds.includes(model.id) + ); +} + function resolveRuntimeFormats(customMatch: any, syncedMatch: any): RuntimeModelMeta { const apiFormat = customMatch?.apiFormat === "responses" || syncedMatch?.apiFormat === "responses" @@ -228,8 +246,10 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any // Only let a non-empty synced effort list override the static registry fallback; // an empty array from an incomplete synced discovery must not erase registry-declared // tiers (#9485 review). - if (Array.isArray(syncedMatch?.supportedThinkingEfforts) && - syncedMatch.supportedThinkingEfforts.length > 0) { + if ( + Array.isArray(syncedMatch?.supportedThinkingEfforts) && + syncedMatch.supportedThinkingEfforts.length > 0 + ) { metadata.supportedThinkingEfforts = syncedMatch.supportedThinkingEfforts; } if (typeof syncedMatch?.defaultThinkingEffort === "string") { @@ -300,7 +320,12 @@ async function lookupModelMeta( // Custom models remain explicit operator overrides even when live discovery // is authoritative for the provider. const customMatch = findCustomModelMeta(customModels, resolvedModelId); - const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const syncedMatch = findLiveCatalogModelMeta( + providerId, + modelId, + resolvedModelId, + syncedModels + ); const registryMatch = findRegistryModel(providerId, resolvedModelId); const effortBaseModelId = getRegisteredProviderEffortBaseModelId(providerId, modelId); diff --git a/tests/unit/chatgpt-web-citations.test.ts b/tests/unit/chatgpt-web-citations.test.ts index 9141d2755c..736e613814 100644 --- a/tests/unit/chatgpt-web-citations.test.ts +++ b/tests/unit/chatgpt-web-citations.test.ts @@ -10,12 +10,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/chatgptTlsClient.ts" -); +const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = + await import("../../open-sse/executors/chatgpt-web.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/chatgptTlsClient.ts"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── // Tailored to the citation flow: root/DPL, session→accessToken, sentinel→token @@ -81,7 +79,7 @@ function installMockFetch({ if (u.includes("/sentinel/chat-requirements")) { return json({ token: "req-token", proofofwork: { required: false } }); } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. if (conversationDetail) { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -171,7 +169,7 @@ test("Non-streaming: resolves ChatGPT web citation markers into markdown links", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }] }, stream: false, credentials: { apiKey: "test" }, @@ -260,7 +258,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }], stream: true, @@ -297,7 +295,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link } }); -test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { +test("GPT-5.6 Sol Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { __resetChatGptWebCachesForTesting(); const citationMarker = "citeturn0search0"; const m = installMockFetch({ @@ -369,7 +367,7 @@ test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for fi try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-pro-poll" }, diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts index 29b549c628..bb309e9952 100644 --- a/tests/unit/chatgpt-web-handoff-resume.test.ts +++ b/tests/unit/chatgpt-web-handoff-resume.test.ts @@ -164,8 +164,8 @@ function installHandoffMock( }; } -test("ChatGPT Web Pro models resume Temporary Chat handoffs through native SSE", async (t) => { - for (const model of ["gpt-5.6-pro", "gpt-5.5-pro", "gpt-5.5-pro-extended"]) { +test("ChatGPT Web GPT-5.6 Sol Pro resumes Temporary Chat handoffs through native SSE", async (t) => { + for (const model of ["gpt-5.6-sol-pro"]) { await t.test(model, async () => { __resetChatGptWebCachesForTesting(); const expected = `RESUMED_${model}`; @@ -204,7 +204,7 @@ test("ChatGPT Web handoff retries the next resume offset after a 404", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.6-pro", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-offset" }, @@ -231,7 +231,7 @@ test("ChatGPT Web streaming appends the native resumed Pro answer", async () => try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-stream" }, diff --git a/tests/unit/chatgpt-web-max-thinking-effort.test.ts b/tests/unit/chatgpt-web-max-thinking-effort.test.ts index f245c8aad2..8d42972b38 100644 --- a/tests/unit/chatgpt-web-max-thinking-effort.test.ts +++ b/tests/unit/chatgpt-web-max-thinking-effort.test.ts @@ -2,158 +2,33 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - normalizeThinkingEffort, resolveChatGptModel, + resolveChatGptSystemHints, } 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", () => { +test("ChatGPT Web performance lanes use their native model and effort pairs", () => { const cases = [ - ["minimal", "standard"], - ["low", "standard"], - ["medium", "standard"], - ["standard", "standard"], - ["high", "extended"], - ["extended", "extended"], - ["xhigh", "max"], - ["max", "max"], + ["gpt-5.6-luna-free", "auto", null, false], + ["gpt-5.6-luna-free-thinking", "auto", null, false], + ["gpt-5.6-sol-instant", "gpt-5-6", null, false], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking", "standard", false], + ["gpt-5.6-sol-high", "gpt-5-6-thinking", "extended", false], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking", "max", false], + ["gpt-5.6-sol-pro", "gpt-5-6-pro", "standard", true], + ["gpt-5.5-instant", "gpt-5-5", null, false], + ["gpt-5.5-medium", "gpt-5-5-thinking", "standard", false], + ["gpt-5.5-high", "gpt-5-5-thinking", "extended", false], + ["gpt-5.5-xhigh", "gpt-5-5-thinking", "max", false], + ["gpt-5.5-pro", "gpt-5-5-pro", "standard", true], + ["gpt-5.5-pro-extended", "gpt-5-5-pro", "extended", true], ] as const; - for (const [input, expected] of cases) { - assert.equal(normalizeThinkingEffort(input), expected, input); + for (const [model, slug, effort, isPro] of cases) { + assert.deepEqual(resolveChatGptModel(model), { slug, effort, isPro }, model); } }); -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"); +test("ChatGPT Web Free Luna Think uses the captured reason system hint", () => { + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free"), []); + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free-thinking"), ["reason"]); }); - -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(); - } - }); -} diff --git a/tests/unit/chatgpt-web-models-split.test.ts b/tests/unit/chatgpt-web-models-split.test.ts index f25f78556d..ef9ff79a77 100644 --- a/tests/unit/chatgpt-web-models-split.test.ts +++ b/tests/unit/chatgpt-web-models-split.test.ts @@ -5,16 +5,16 @@ 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. +// The static model maps + pure model resolver live in the pure leaf +// chatgpt-web/models.ts (no module state). Host imports it 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", () => { +test("leaf hosts the model maps + resolver and does not import the host", () => { const src = readFileSync(LEAF, "utf8"); - for (const sym of ["MODEL_MAP", "resolveChatGptModel", "resolveThinkingEffort"]) { + for (const sym of ["MODEL_MAP", "MODEL_FORCED_EFFORT", "resolveChatGptModel"]) { assert.match(src, new RegExp(`export (const|function) ${sym}\\b`)); } assert.doesNotMatch(src, /from "\.\.\/chatgpt-web\.ts"/); diff --git a/tests/unit/chatgpt-web-tools-5240.test.ts b/tests/unit/chatgpt-web-tools-5240.test.ts index 5fc9ec06c3..ac6bc9046a 100644 --- a/tests/unit/chatgpt-web-tools-5240.test.ts +++ b/tests/unit/chatgpt-web-tools-5240.test.ts @@ -8,15 +8,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/chatgptTlsClient.ts" -); +const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = + await import("../../open-sse/executors/chatgpt-web.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/chatgptTlsClient.ts"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── -// Tailored to the tool-call flow (gpt-5.3-instant, non-thinking): root/DPL, +// Tailored to the tool-call flow (gpt-5.5, non-thinking): root/DPL, // session→accessToken, sentinel→token (no PoW), conv→SSE. Warmup GETs fall // through to 404, which the executor tolerates. @@ -68,7 +66,10 @@ function installMockFetch(convEvents: unknown[]) { body: null, }); - if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && (opts.method || "GET") === "GET") { + if ( + (u === "https://chatgpt.com/" || u === "https://chatgpt.com") && + (opts.method || "GET") === "GET" + ) { return { status: 200, headers: makeHeaders({ "Content-Type": "text/html" }), @@ -127,7 +128,7 @@ const TOOL_CALL_TEXT = '{"name":"get_weather","arguments":{"location":"Tok function baseOpts(extra: Record) { return { - model: "gpt-5.3-instant", + model: "gpt-5.5", credentials: { apiKey: "test" }, signal: AbortSignal.timeout(10_000), log: null, diff --git a/tests/unit/chatgpt-web-tools-7679.test.ts b/tests/unit/chatgpt-web-tools-7679.test.ts index f042f9ab7d..0ce60ba21f 100644 --- a/tests/unit/chatgpt-web-tools-7679.test.ts +++ b/tests/unit/chatgpt-web-tools-7679.test.ts @@ -1,6 +1,6 @@ -// Tool contract serialization for chatgpt-web thinking models (#7679). +// Tool contract serialization for ChatGPT Web performance models (#7679). // -// GPT-5.6 Thinking via chatgpt-web ignores the injected `` pseudo-contract +// GPT-5.6 Sol via chatgpt-web ignores the injected `` pseudo-contract // and replies in prose claiming tools are unavailable. This test covers the // nonce-bound serialization that clearly describes client-side tools and places // the full contract at the tail of the effective message list. @@ -161,7 +161,7 @@ test("parseToolCallsFromText returns null when hardened text has no tool blocks }); test("parseToolCallsFromText handles blocks line-boundary crossing in hardened text (#7679)", () => { - // Some thinking models may emit the tool block adjacent to explanatory text + // Some high-performance lanes may emit the tool block adjacent to explanatory text // with no preceding newline const text = [ 'I will use the weather tool. {"name":"get_weather","arguments":{"location":"Paris"}}', diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index cc97ecd69d..267c0eb6dc 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -81,13 +81,11 @@ type MockFetchOptions = { attachmentDownload?: MockTlsConfig; conversationDetail?: MockTlsConfig | MockTlsConfig[]; signedDownload?: MockTlsConfig; - userConfig?: MockTlsConfig; onSession?: (opts: TlsFetchOptions) => void; onSentinel?: (opts: TlsFetchOptions) => void; onConv?: (opts: TlsFetchOptions) => void; onFileDownload?: (opts: TlsFetchOptions, fileId: string) => void; onAttachmentDownload?: (opts: TlsFetchOptions, fileId: string) => void; - onUserConfig?: (opts: TlsFetchOptions, url: string) => void; }; type MockFetchCalls = { @@ -99,9 +97,6 @@ type MockFetchCalls = { attachmentDownload: number; conversationDetail: number; signedDownload: number; - userConfig: number; - userConfigUrls: string[]; - userConfigMethods: string[]; urls: string[]; headers: Array | undefined>; bodies: Array; @@ -118,13 +113,11 @@ function installMockFetch({ attachmentDownload, conversationDetail, signedDownload, - userConfig, onSession, onSentinel, onConv, onFileDownload, onAttachmentDownload, - onUserConfig, }: MockFetchOptions = {}) { const calls: MockFetchCalls = { session: 0, @@ -135,9 +128,6 @@ function installMockFetch({ attachmentDownload: 0, conversationDetail: 0, signedDownload: 0, - userConfig: 0, - userConfigUrls: [], - userConfigMethods: [], urls: [], headers: [], bodies: [], @@ -188,22 +178,6 @@ 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); @@ -288,7 +262,7 @@ function installMockFetch({ }; } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -480,7 +454,7 @@ test("Token exchange: cookie sent to /api/auth/session, accessToken used as Bear try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "my-cookie-value" }, @@ -518,7 +492,7 @@ test("Token cache: two calls within TTL only hit /api/auth/session once", async try { const executor = new ChatGptWebExecutor(); const opts = { - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -552,7 +526,7 @@ test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "old-cookie" }, @@ -585,7 +559,7 @@ test("Sentinel: chat-requirements is hit before /backend-api/conversation", asyn try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -606,7 +580,7 @@ test("Sentinel: chat-requirements token forwarded on conv request", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -635,7 +609,7 @@ test("PoW: when required, proof token is sent with valid prefix", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -670,7 +644,7 @@ test("Turnstile: required flag does NOT block — conv endpoint accepts requests try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -692,7 +666,7 @@ test("Non-streaming: returns OpenAI chat.completion JSON", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -743,7 +717,7 @@ test("Streaming: produces valid SSE chunks ending with [DONE]", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -807,7 +781,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -835,7 +809,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async } }); -test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polled answer", async () => { +test("GPT-5.6 Sol Pro streaming: preserves interim reasoning and appends final polled answer", async () => { reset(); const m = installMockFetch({ conv: { @@ -878,7 +852,7 @@ test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polle try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-pro-stream" }, @@ -907,7 +881,7 @@ test("Error: 401 on /api/auth/session returns 401 with re-paste hint", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "expired-cookie" }, @@ -928,7 +902,7 @@ test("Error: 200 with no accessToken returns 401", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "stale-cookie" }, @@ -948,7 +922,7 @@ test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -970,7 +944,7 @@ test("Error: 429 from conversation returns 429 with rate-limit message", async ( try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -991,7 +965,7 @@ test("Error: empty messages returns 400 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [] }, stream: false, credentials: { apiKey: "test" }, @@ -1011,7 +985,7 @@ test("Error: missing apiKey returns 401 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: {}, @@ -1033,7 +1007,7 @@ test("Cookie: bare value gets prepended with cookie name", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "rawValue" }, @@ -1052,7 +1026,7 @@ test("Cookie: unchunked cookie line is passed through verbatim", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "__Secure-next-auth.session-token=actualvalue" }, @@ -1071,7 +1045,7 @@ test("Cookie: chunked .0/.1 cookies are passed through verbatim (NextAuth reasse try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1096,7 +1070,7 @@ test("Cookie: 'Cookie: ' DevTools prefix is stripped", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1127,7 +1101,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "First question" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1135,7 +1109,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat log: null, }); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "First question" }, @@ -1178,7 +1152,7 @@ test("Request: conversation POST has correct browser-like headers", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1204,7 +1178,7 @@ test("Request: payload has correct ChatGPT shape", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: "Be concise" }, @@ -1219,7 +1193,7 @@ test("Request: payload has correct ChatGPT shape", async () => { const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); assert.equal(body.action, "next"); - assert.equal(body.model, "gpt-5-3-instant"); + assert.equal(body.model, "gpt-5-5"); // Plain text request → Temporary Chat stays ON. We disable it only for // image-gen prompts (see "Image gen: image-intent prompts" tests below). assert.equal(body.history_and_training_disabled, true); @@ -1245,26 +1219,23 @@ test("Provider registry: chatgpt-web exposes the current ChatGPT Web model catal assert.equal(entry.authHeader, "cookie"); const ids = (entry.models || []).map((m) => m.id); - // Retired GPT-5.4 and older entries stay out of the advertised catalog. + // Free accounts expose Luna with an optional Think toggle; paid accounts + // expose five GPT-5.6 Sol performance lanes plus GPT-5.5. assert.deepEqual(ids, [ - "gpt-5.6-pro", - "gpt-5.6-thinking", + "gpt-5.6-sol-pro", + "gpt-5.6-sol-xhigh", + "gpt-5.6-sol-high", + "gpt-5.6-sol-medium", + "gpt-5.6-sol-instant", + "gpt-5.6-luna-free-thinking", + "gpt-5.6-luna-free", "gpt-5.5-pro-extended", "gpt-5.5-pro", - "gpt-5.5-thinking", - "gpt-5.5", - "o3", + "gpt-5.5-xhigh", + "gpt-5.5-high", + "gpt-5.5-medium", + "gpt-5.5-instant", ]); - assert.equal( - ids.some((id) => id.startsWith("gpt-5.4")), - false - ); - - const { MODEL_MAP } = await import("../../open-sse/executors/chatgpt-web/models.ts"); - assert.equal( - Object.keys(MODEL_MAP).some((id) => id.startsWith("gpt-5.4") || id.startsWith("gpt-5-4")), - false - ); }); test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", async () => { @@ -1273,19 +1244,22 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy try { const cases: Array<[string, string]> = [ // Public catalog ids. - ["gpt-5.6-pro", "gpt-5-6-pro"], - ["gpt-5.6-thinking", "gpt-5-6-thinking"], - ["gpt-5.5-thinking", "gpt-5-5-thinking"], - ["gpt-5.5", "gpt-5-5"], + ["gpt-5.6-luna-free", "auto"], + ["gpt-5.6-luna-free-thinking", "auto"], + ["gpt-5.6-sol-instant", "gpt-5-6"], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking"], + ["gpt-5.6-sol-high", "gpt-5-6-thinking"], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking"], + ["gpt-5.6-sol-pro", "gpt-5-6-pro"], + ["gpt-5.5-instant", "gpt-5-5"], + ["gpt-5.5-medium", "gpt-5-5-thinking"], + ["gpt-5.5-high", "gpt-5-5-thinking"], + ["gpt-5.5-xhigh", "gpt-5-5-thinking"], ["gpt-5.5-pro", "gpt-5-5-pro"], ["gpt-5.5-pro-extended", "gpt-5-5-pro"], - ["o3", "o3"], // Backend dash-form slugs are still accepted for direct provider/model callers. - ["gpt-5-3", "gpt-5-3"], - ["gpt-5-5-thinking", "gpt-5-5-thinking"], - ["gpt-5-6-pro", "gpt-5-6-pro"], - ["gpt-5-5-pro", "gpt-5-5-pro"], - ["gpt-5-5-pro-extended", "gpt-5-5-pro"], + ["gpt-5-6", "gpt-5-6"], + ["gpt-5-5", "gpt-5-5"], ]; for (const [omniId, expectedSlug] of cases) { m.calls.urls.length = 0; @@ -1308,18 +1282,58 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy } }); +test("GPT-5.6 Luna Free Think sends the captured auto-router reason hints", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + for (const [model, expectedHints] of [ + ["gpt-5.6-luna-free", undefined], + ["gpt-5.6-luna-free-thinking", ["reason"]], + ] as const) { + m.calls.urls.length = 0; + m.calls.bodies.length = 0; + await executor.execute({ + model, + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "cookie-free-luna" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const body = JSON.parse(m.calls.bodies[convIdx]); + const userMessage = body.messages.find( + (message: { author?: { role?: string } }) => message.author?.role === "user" + ); + + assert.equal(body.model, "auto"); + assert.deepEqual(body.system_hints, expectedHints); + assert.deepEqual(userMessage?.metadata?.system_hints, expectedHints); + } + } finally { + m.restore(); + } +}); + test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a backend slug", async () => { reset(); const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); const ids = (getRegistryEntry("chatgpt-web")?.models || []).map((m) => m.id); const expectedSlugById: Record = { - "gpt-5.6-pro": "gpt-5-6-pro", - "gpt-5.6-thinking": "gpt-5-6-thinking", - "gpt-5.5-pro-extended": "gpt-5-5-pro", + "gpt-5.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-thinking", "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", - "gpt-5.5": "gpt-5-5", - o3: "o3", + "gpt-5.5-pro-extended": "gpt-5-5-pro", }; const m = installMockFetch(); try { @@ -1348,15 +1362,15 @@ test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a ba } }); -// ─── thinking_effort PATCH user_last_used_model_config ───────────────────── +// ─── GPT-5.6 Sol picker request contract ────────────────────────────────── -test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Chat", async () => { +test("GPT-5.6 Sol XHigh sends the captured thinking-model/max pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-extended" }, @@ -1366,26 +1380,25 @@ test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Ch assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); - assert.equal( - m.calls.userConfig, - 0, - "Pro effort is sent with the turn, not PATCHed as a thinking-model preference" + assert.ok( + !m.calls.urls.some((url) => url.includes("/settings/user_last_used_model_config")), + "the captured browser request uses no settings PATCH" ); } finally { m.restore(); } }); -test("GPT-5.5 Pro standard sends standard effort", async () => { +test("GPT-5.6 Sol High sends the captured thinking-model/extended pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.5-pro", + model: "gpt-5.6-sol-high", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-standard" }, @@ -1394,21 +1407,21 @@ test("GPT-5.5 Pro standard sends standard effort", async () => { }); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "standard"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "extended"); assert.equal(body.history_and_training_disabled, true); } finally { m.restore(); } }); -test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utility calls", async () => { +test("GPT-5.6 Sol XHigh store:false keeps Temporary Chat enabled", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { store: false, messages: [ @@ -1424,8 +1437,8 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); assert.equal( m.calls.conversationDetail, @@ -1437,242 +1450,6 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit } }); -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.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(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-6-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 thinking slugs still PATCH", async () => { - for (const bareSlug of ["gpt-5-6-thinking", "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.ok( - m.calls.userConfigUrls[0].includes(`model_slug=${bareSlug}`), - `URL should contain model_slug=${bareSlug}` - ); - } 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.6-thinking", - 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-6-thinking/); - 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.5-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-5-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.5 routes to ChatGPT Web image handler", async () => { const { parseImageModel, getImageProvider } = await import("../../open-sse/config/imageRegistry.ts"); @@ -1709,7 +1486,7 @@ test("Cookie rotation: full DevTools blob keeps cf_clearance/__cf_bm/_cfuvid", a let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1764,7 +1541,7 @@ test("Cookie rotation: unchunked → chunked drops stale unchunked variant", asy let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1811,7 +1588,7 @@ test("Cookie rotation: chunked → unchunked drops stale chunks", async () => { let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1855,7 +1632,7 @@ test("Cookie rotation: returns null when Set-Cookie has no session-token", async let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -1919,7 +1696,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (streaming)", asy try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -1979,7 +1756,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (non-streaming)", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2017,7 +1794,7 @@ test("Stream parser: instant single-event reply still surfaces via fallback", as try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2083,7 +1860,7 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2211,7 +1988,7 @@ test("Image gen: file-service:// pointer resolves to download URL and is appende try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2245,7 +2022,7 @@ test("Image gen: file-service:// pointer is appended in streaming SSE", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: true, credentials: { apiKey: "test" }, @@ -2279,7 +2056,7 @@ test("Image gen: sediment:// pointer prefers /files//download over /attachme try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "make a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2324,7 +2101,7 @@ test("Image gen: failed download URL is dropped silently — no broken markdown" try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2348,7 +2125,7 @@ test("Image gen: image-intent prompt disables Temporary Chat", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2369,7 +2146,7 @@ test("Image gen: text-only prompt keeps Temporary Chat ON", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "what is the capital of France?" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2396,7 +2173,7 @@ test("Image gen: Open WebUI follow-up/title/tag tool prompts do NOT trigger imag try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: prompt }] }, stream: false, credentials: { apiKey: "test" }, @@ -2429,7 +2206,7 @@ test("Image gen: Open WebUI image-generation context suppresses duplicate chat i try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: context }, @@ -2477,7 +2254,7 @@ test("Image gen: heuristic catches common phrasings", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: phrase }] }, stream: false, credentials: { apiKey: "test" }, @@ -2598,7 +2375,7 @@ test("Image gen: signed URL bytes are cached and exposed via /v1/chatgpt-web/ima try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2648,7 +2425,7 @@ test("Image gen: prior data: image URIs are stripped from history before upstrea const assistantMsg = `Sure, here you go:\n\n![image](data:image/png;base64,${huge})\n`; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2686,7 +2463,7 @@ test("Image edit: cached OmniRoute image URL continues the saved ChatGPT convers try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2726,7 +2503,7 @@ test("Image edit: Open WebUI image context suppresses duplicate edit continuatio try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { @@ -2798,7 +2575,7 @@ test("Image gen: dedupes the same pointer across in-progress + finished events", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2829,7 +2606,7 @@ test("Image gen: bytes-fetch failure drops markdown (no signed-URL fallback)", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2903,7 +2680,7 @@ test("Image edit: file_0000XXXX (chatgpt-web edit result) falls back to /convers try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "now make it nighttime" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2965,7 +2742,7 @@ test("Image gen: ChatGPT-internal tool_invoked metadata does NOT spuriously trig try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "limitations of gpt-4o-mini?" }] }, stream: true, credentials: { apiKey: "test" }, @@ -3015,7 +2792,7 @@ test("Image edit handler: bytes-hash match drives executor with cached conversat const { handleImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: sourceBytes, credentials: { apiKey: "test" }, @@ -3053,7 +2830,7 @@ test("Image edit handler: no cached match returns 400 (does not silently generat const foreignBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xde, 0xad, 0xbe, 0xef]); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: foreignBytes, credentials: { apiKey: "test" }, @@ -3082,7 +2859,7 @@ test("Image gen handler: n>4 is rejected before any upstream call", async () => try { const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageGeneration({ - body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.3-instant" }, + body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.5" }, credentials: { apiKey: "test" }, log: null, }); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 2796071e43..5294c830fc 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -202,3 +202,45 @@ test("#8926: partial passthrough discovery remains non-authoritative", async () ["gpt-5.6-luna"] ); }); + +test("ChatGPT Web curated variants require their mapped upstream live slug", async () => { + const variants = new Map([ + ["gpt-5.6-sol-pro", "gpt-5-6-pro"], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking"], + ["gpt-5.6-sol-high", "gpt-5-6-thinking"], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking"], + ["gpt-5.6-sol-instant", "gpt-5-6"], + ["gpt-5.6-luna-free-thinking", "gpt-5-6"], + ["gpt-5.6-luna-free", "gpt-5-6"], + ["gpt-5.5-pro-extended", "gpt-5-5-pro"], + ["gpt-5.5-pro", "gpt-5-5-pro"], + ["gpt-5.5-xhigh", "gpt-5-5-thinking"], + ["gpt-5.5-high", "gpt-5-5-thinking"], + ["gpt-5.5-medium", "gpt-5-5-thinking"], + ["gpt-5.5-instant", "gpt-5-5"], + ]); + + await seedProviderCatalog( + "chatgpt-web", + "chatgpt-web-live-8926", + Array.from(new Set(variants.values())) + ); + + const catalog = await getActiveSyncedCatalog("chatgpt-web"); + assert.equal(catalog.authoritative, true); + + for (const modelId of variants.keys()) { + const resolved = await getModelInfo(`chatgpt-web/${modelId}`); + assert.equal(resolved.provider, "chatgpt-web", modelId); + assert.equal(resolved.model, modelId, modelId); + } + + await seedProviderCatalog("chatgpt-web", "chatgpt-web-live-8926", ["gpt-5-6"]); + + const available = await getModelInfo("chatgpt-web/gpt-5.6-sol-instant"); + assert.equal(available.provider, "chatgpt-web"); + + const unavailable = await getModelInfo("chatgpt-web/gpt-5.6-sol-pro"); + assert.equal(unavailable.provider, null); + assert.equal(unavailable.errorType, "model_not_found"); +}); diff --git a/tests/unit/model-listing-capability-5420.test.ts b/tests/unit/model-listing-capability-5420.test.ts index 0c7a6777e6..581a07e8cb 100644 --- a/tests/unit/model-listing-capability-5420.test.ts +++ b/tests/unit/model-listing-capability-5420.test.ts @@ -33,8 +33,10 @@ describe("providerLacksModelListing (#5420)", () => { it("keeps curated web providers visible while disabling remote model import", () => { assert.equal(providerLacksModelListing("kimi-web", ["llm"]), false); assert.equal(providerLacksModelListing("zai-web", ["llm"]), false); + assert.equal(providerLacksModelListing("chatgpt-web", ["llm"]), false); assert.equal(providerUsesCuratedModelsOnly("kimi-web"), true); assert.equal(providerUsesCuratedModelsOnly("zai-web"), true); + assert.equal(providerUsesCuratedModelsOnly("chatgpt-web"), true); assert.equal(providerUsesCuratedModelsOnly("qwen-cloud"), false); assert.equal(providerUsesCuratedModelsOnly("kimi-coding"), false); });