fix(chatgpt-web): refresh current model catalog (#10637)

Merged — locally validated (123/123 focused chatgpt-web tests, typecheck:core clean, file-size/changelog gates green). Thanks!
This commit is contained in:
backryun
2026-08-20 23:31:48 +09:00
committed by GitHub
parent 4821f9ffdb
commit a352c23bad
16 changed files with 420 additions and 760 deletions

View File

@@ -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" },

View File

@@ -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,
},
],
};

View File

@@ -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[];

View File

@@ -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<string, number>();
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<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,
@@ -889,6 +804,7 @@ interface ChatGptMessage {
id: string;
author: { role: string };
content: { content_type: "text"; parts: string[] };
metadata?: Record<string, unknown>;
}
/**
@@ -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<string, unknown> {
@@ -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<Uint8Array> {
export function __resetChatGptWebCachesForTesting(): void {
tokenCache.clear();
warmupCache.clear();
thinkingEffortCache.clear();
deviceIdCache.clear();
__resetChatGptImageCacheForTesting();
dplCache = null;

View File

@@ -3,106 +3,60 @@
export const MODEL_MAP: Record<string, string> = {
// 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<string, ChatGptThinkingEffort> = {
"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<string, ChatGptThinkingEffort | null> = {
"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<string> = new Set(
Object.entries(MODEL_MAP)
.filter(([k]) => k.includes("thinking") || k === "o3")
.map(([, v]) => v)
);
const MODEL_SYSTEM_HINTS: Record<string, readonly string[]> = {
// 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<string, unknown> | undefined
): ChatGptThinkingEffort | 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);
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<string, unknown> | undefined
_body?: unknown,
_providerSpecificData?: Record<string, unknown>
): 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 };
}

View File

@@ -11,7 +11,7 @@
const TOOL_ONLY_SERVICE_KINDS = new Set<string>(["webSearch", "webFetch"]);
/** Providers whose registry catalog is the complete, intentional model list. */
const CURATED_MODEL_ONLY_PROVIDERS = new Set<string>(["kimi-web", "zai-web"]);
const CURATED_MODEL_ONLY_PROVIDERS = new Set<string>(["chatgpt-web", "kimi-web", "zai-web"]);
export function providerUsesCuratedModelsOnly(providerId: string): boolean {
return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase());

View File

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

View File

@@ -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/<id> — detail poll used by GPT-5.5 Pro handoff.
// /backend-api/conversation/<id> — 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" },

View File

@@ -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" },

View File

@@ -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,
'<html data-build="prod-test"><script src="https://cdn.oaistatic.com/test.js"></script></html>',
"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<string, unknown>;
assert.equal(conversationBody.thinking_effort, "max");
} finally {
mock.restore();
}
});
}

View File

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

View File

@@ -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 = '<tool>{"name":"get_weather","arguments":{"location":"Tok
function baseOpts(extra: Record<string, unknown>) {
return {
model: "gpt-5.3-instant",
model: "gpt-5.5",
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,

View File

@@ -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 `<tool>` pseudo-contract
// GPT-5.6 Sol via chatgpt-web ignores the injected `<tool>` 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 <tool> 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. <tool>{"name":"get_weather","arguments":{"location":"Paris"}}</tool>',

View File

@@ -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<Record<string, string> | undefined>;
bodies: Array<string | undefined>;
@@ -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/<id> — detail poll used by GPT-5.5 Pro handoff.
// /backend-api/conversation/<id> — 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<string, string> = {
"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/<id>/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,
});

View File

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

View File

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