fix(providers): retire common ChatGPT Web provider

This commit is contained in:
diegosouzapw
2026-08-27 03:13:19 -03:00
committed by Markus Hartung
parent 825f8fe425
commit 9c5dd6760c
172 changed files with 3193 additions and 11318 deletions

View File

@@ -90,16 +90,6 @@
"configSource": "<custom-config>",
"provider": "chatgpt-web-codex"
},
"cgpt-web": {
"className": "ChatGptWebExecutor",
"configSource": "<custom-config>",
"provider": "chatgpt-web"
},
"chatgpt-web": {
"className": "ChatGptWebExecutor",
"configSource": "<custom-config>",
"provider": "chatgpt-web"
},
"chatgpt-web-codex": {
"className": "ChatGptWebCodexExecutor",
"configSource": "<custom-config>",
@@ -721,6 +711,6 @@
"provider": "zai-web"
}
},
"keyCount": 144,
"keyCount": 142,
"sharedInstances": []
}

View File

@@ -842,29 +842,6 @@
"stream": "https://api.chatanywhere.org/v1/chat/completions"
}
},
"chatgpt-web": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://chatgpt.com/backend-api/conversation",
"stream": "https://chatgpt.com/backend-api/conversation"
}
},
"chatgpt-web-codex": {
"format": "openai-responses",
"headers": {

View File

@@ -1,13 +1,11 @@
// Issue #8200: perplexity-web must persist Set-Cookie session-token rotations
// via onCredentialsRefreshed — chatgpt-web parity (mergeRefreshedCookie +
// via onCredentialsRefreshed using the shared mergeRefreshedCookie +
// buildSessionCookieHeader in open-sse/utils/nextAuthCookie.ts).
import test from "node:test";
import assert from "node:assert/strict";
const {
mergeRefreshedCookie,
buildSessionCookieHeader,
} = await import("../../open-sse/utils/nextAuthCookie.ts");
const { mergeRefreshedCookie, buildSessionCookieHeader } =
await import("../../open-sse/utils/nextAuthCookie.ts");
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/perplexityTlsClient.ts");
@@ -37,10 +35,7 @@ test("nextAuthCookie: buildSessionCookieHeader passes full DevTools cookie blob
const blob =
"__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB; cf_clearance=CF";
assert.equal(buildSessionCookieHeader(blob), blob);
assert.equal(
buildSessionCookieHeader(`Cookie: ${blob}`),
blob
);
assert.equal(buildSessionCookieHeader(`Cookie: ${blob}`), blob);
});
test("nextAuthCookie: buildSessionCookieHeader wraps bare session-token value", () => {
@@ -72,8 +67,7 @@ test("#8200: PerplexityWebExecutor persists rotated session-token via onCredenti
captured.headers = opts.headers as Record<string, string>;
const headers = new Headers({
"Content-Type": "text/event-stream",
"set-cookie":
"__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure",
"set-cookie": "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure",
});
return {
status: 200,
@@ -103,7 +97,10 @@ test("#8200: PerplexityWebExecutor persists rotated session-token via onCredenti
"__Secure-next-auth.session-token=old-cookie-value",
"bare apiKey must be normalized for the upstream Cookie header"
);
assert.ok(persisted, "onCredentialsRefreshed must fire when Set-Cookie rotates the session token");
assert.ok(
persisted,
"onCredentialsRefreshed must fire when Set-Cookie rotates the session token"
);
assert.equal(
persisted.apiKey,
"__Secure-next-auth.session-token=ROTATED-VALUE",
@@ -139,8 +136,7 @@ test("#8200: PerplexityWebExecutor preserves cf_clearance when session-token rot
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
credentials: {
apiKey:
"__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR",
apiKey: "__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR",
},
signal: AbortSignal.timeout(10_000),
log: null,

View File

@@ -121,16 +121,19 @@ test("#8488 filter: zero tool-capable targets → empty (fail closed)", () => {
assert.ok(exhaustion!.excluded.some((e) => e.reason.includes("tools")));
});
test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#5240)", () => {
// Registry honestly tags chatgpt-web models toolCalling:false; the prompt
test("#8488 filter: Gemini Web emulation stays eligible for tools (#5240)", () => {
// Registry honestly tags Gemini Web models toolCalling:false; the prompt
// shim is what makes tools work. Fail-closed must not hard-reject them.
assert.equal(providerSupportsEmulatedToolCalling("chatgpt-web"), true);
assert.equal(providerSupportsEmulatedToolCalling("cgpt-web"), true);
assert.equal(providerSupportsEmulatedToolCalling("gemini-web"), true);
assert.equal(providerSupportsEmulatedToolCalling("gweb"), true);
assert.equal(providerSupportsEmulatedToolCalling("claude-web"), false); // toolCalling:"none"
assert.equal(providerSupportsEmulatedToolCalling("openai"), false);
const kept = filterTargetsByRequestCompatibility(
[target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")],
[
target("gemini-web", "gemini-web/gemini-3.1-pro"),
target("gemini-web", "gemini-web/gemini-3.7-flash"),
],
{
messages: [{ role: "user", content: "Use a tool." }],
tools: [{ type: "function", function: { name: "lookup", parameters: {} } }],
@@ -140,11 +143,14 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52
assert.equal(kept.length, 2);
assert.deepEqual(
kept.map((t) => t.modelStr),
["chatgpt-web/gpt-5.5", "chatgpt-web/o3"]
["gemini-web/gemini-3.1-pro", "gemini-web/gemini-3.7-flash"]
);
const exhaustion = describeCapabilityFilterExhaustion(
[target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")],
[
target("gemini-web", "gemini-web/gemini-3.1-pro"),
target("gemini-web", "gemini-web/gemini-3.7-flash"),
],
{
messages: [{ role: "user", content: "Use a tool." }],
tools: [{ type: "function", function: { name: "lookup", parameters: {} } }],
@@ -154,9 +160,9 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52
assert.equal(exhaustion, null, "emulation-capable pool must not report capability_mismatch");
});
test("#8488 auto: chatgpt-web emulation survives tool pre-filter (#5240)", async () => {
test("#8488 auto: Gemini Web emulation survives tool pre-filter (#5240)", async () => {
const result = await resolveAutoStrategyOrder({
orderedTargets: [target("chatgpt-web", "chatgpt-web/gpt-5.5")] as never,
orderedTargets: [target("gemini-web", "gemini-web/gemini-3.1-pro")] as never,
body: {
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "lookup", parameters: {} } }],
@@ -176,7 +182,7 @@ test("#8488 auto: chatgpt-web emulation survives tool pre-filter (#5240)", async
);
if ("orderedTargets" in result) {
assert.equal(result.orderedTargets.length, 1);
assert.equal(result.orderedTargets[0].modelStr, "chatgpt-web/gpt-5.5");
assert.equal(result.orderedTargets[0].modelStr, "gemini-web/gemini-3.1-pro");
}
});

View File

@@ -18,7 +18,7 @@ import {
describe("bulkWebSessionImportSchema", () => {
it("accepts valid input with single entry", () => {
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [{ name: "Account 1", credential: "__Secure-next-auth.session-token=abc123" }],
});
assert.equal(result.success, true);
@@ -48,7 +48,7 @@ describe("bulkWebSessionImportSchema", () => {
it("rejects empty entries array", () => {
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [],
});
assert.equal(result.success, false);
@@ -60,7 +60,7 @@ describe("bulkWebSessionImportSchema", () => {
credential: "cookie=value",
}));
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries,
});
assert.equal(result.success, false);
@@ -68,7 +68,7 @@ describe("bulkWebSessionImportSchema", () => {
it("rejects entry with empty credential", () => {
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [{ name: "Account 1", credential: "" }],
});
assert.equal(result.success, false);
@@ -76,7 +76,7 @@ describe("bulkWebSessionImportSchema", () => {
it("rejects entry with empty name", () => {
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [{ name: "", credential: "cookie=value" }],
});
assert.equal(result.success, false);
@@ -91,14 +91,14 @@ describe("bulkWebSessionImportSchema", () => {
it("rejects priority out of range", () => {
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [{ name: "A1", credential: "cookie=value" }],
priority: 0,
});
assert.equal(result.success, false);
const result2 = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries: [{ name: "A1", credential: "cookie=value" }],
priority: 101,
});
@@ -111,7 +111,7 @@ describe("bulkWebSessionImportSchema", () => {
credential: "cookie=value",
}));
const result = bulkWebSessionImportSchema.safeParse({
provider: "chatgpt-web",
provider: "perplexity-web",
entries,
});
assert.equal(result.success, true);
@@ -120,7 +120,7 @@ describe("bulkWebSessionImportSchema", () => {
describe("web-session credential helpers", () => {
it("requiresWebSessionCredential returns true for web-cookie providers", () => {
assert.equal(requiresWebSessionCredential("chatgpt-web"), true);
assert.equal(requiresWebSessionCredential("perplexity-web"), true);
assert.equal(requiresWebSessionCredential("grok-web"), true);
assert.equal(requiresWebSessionCredential("claude-web"), true);
assert.equal(requiresWebSessionCredential("deepseek-web"), true);
@@ -133,7 +133,7 @@ describe("web-session credential helpers", () => {
});
it("getWebSessionCredentialRequirement returns correct kind for cookie providers", () => {
const req = getWebSessionCredentialRequirement("chatgpt-web");
const req = getWebSessionCredentialRequirement("perplexity-web");
assert.ok(req);
assert.equal(req.kind, "cookie");
});
@@ -146,13 +146,13 @@ describe("web-session credential helpers", () => {
it("hasUsableWebSessionCredential validates cookie data correctly", () => {
assert.equal(
hasUsableWebSessionCredential("chatgpt-web", {
hasUsableWebSessionCredential("perplexity-web", {
cookie: "__Secure-next-auth.session-token=abc",
}),
true
);
assert.equal(hasUsableWebSessionCredential("chatgpt-web", { cookie: "" }), false);
assert.equal(hasUsableWebSessionCredential("chatgpt-web", {}), false);
assert.equal(hasUsableWebSessionCredential("perplexity-web", { cookie: "" }), false);
assert.equal(hasUsableWebSessionCredential("perplexity-web", {}), false);
});
it("hasUsableWebSessionCredential validates token data correctly", () => {
@@ -172,7 +172,7 @@ describe("canUpdateProviderApiKey", () => {
});
it("does not allow cookie-kind web sessions to update apiKey", () => {
assert.equal(canUpdateProviderApiKey("cookie", "chatgpt-web"), false);
assert.equal(canUpdateProviderApiKey("cookie", "perplexity-web"), false);
assert.equal(canUpdateProviderApiKey("cookie", "claude-web"), false);
});
@@ -214,7 +214,7 @@ describe("resolveWebSessionImportApiKey (token-kind imports must populate apiKey
);
assert.equal(
resolveWebSessionImportApiKey(
getWebSessionCredentialRequirement("chatgpt-web"),
getWebSessionCredentialRequirement("perplexity-web"),
"__Secure-next-auth.session-token=abc"
),
null

View File

@@ -23,15 +23,17 @@ import {
// ── Helpers ───────────────────────────────────────────────────────────────
/** Minimal capabilities shape for filter testing. */
function caps(overrides: Partial<{
supportsTools: boolean | null;
toolCalling: boolean;
supportsVision: boolean | null;
structuredOutput: boolean | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
}> = {}) {
function caps(
overrides: Partial<{
supportsTools: boolean | null;
toolCalling: boolean;
supportsVision: boolean | null;
structuredOutput: boolean | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
}> = {}
) {
return {
supportsTools: overrides.supportsTools ?? null,
toolCalling: overrides.toolCalling ?? true,
@@ -43,7 +45,9 @@ function caps(overrides: Partial<{
};
}
function req(overrides: Partial<RequestCapabilityRequirements> = {}): RequestCapabilityRequirements {
function req(
overrides: Partial<RequestCapabilityRequirements> = {}
): RequestCapabilityRequirements {
return {
requiresTools: false,
requiresVision: false,
@@ -113,12 +117,12 @@ test("checkRequestCapabilityFit: tools OK when model supports tools", () => {
});
test("checkRequestCapabilityFit: tools bypassed for emulated-tool provider", () => {
// chatgpt-web has toolCalling: "emulated" in the provider registry,
// gemini-web has toolCalling: "emulated" in the provider registry,
// so the filter must not reject it even when capabilities report false.
const result = checkRequestCapabilityFit(
caps({ supportsTools: false, toolCalling: false }),
req({ requiresTools: true }),
"chatgpt-web"
"gemini-web"
);
assert.equal(result.compatible, true);
assert.deepEqual(result.failures, []);
@@ -205,7 +209,10 @@ test("deriveRequestCapabilityRequirements: detects tools from body", () => {
test("deriveRequestCapabilityRequirements: detects vision from image_url", () => {
const requirements = deriveRequestCapabilityRequirements({
messages: [
{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }] },
{
role: "user",
content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }],
},
],
});
assert.equal(requirements.requiresVision, true);
@@ -230,9 +237,7 @@ test("feature flag CAPABILITY_FILTER_ENABLED defaults to false", () => {
// opt-in. The default value must be "false" per the plan.
import("../../src/shared/constants/featureFlagDefinitions.ts").then(
({ FEATURE_FLAG_DEFINITIONS }) => {
const flag = FEATURE_FLAG_DEFINITIONS.find(
(d) => d.key === "CAPABILITY_FILTER_ENABLED"
);
const flag = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "CAPABILITY_FILTER_ENABLED");
assert.ok(flag, "CAPABILITY_FILTER_ENABLED flag must be defined");
assert.equal(flag.defaultValue, "false");
assert.equal(flag.type, "boolean");
@@ -266,4 +271,4 @@ test("error responses use buildErrorBody and do not leak stack traces", () => {
assert.equal(body.error.type, "invalid_request_error");
});
});
});
});

View File

@@ -17,13 +17,11 @@ process.env.DATA_DIR = testDataDir;
// Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path.
const coreDb = await import("../../src/lib/db/core.ts");
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
const { resolveExecutorWithProxy } = await import(
"../../open-sse/handlers/chatCore/executorProxy.ts"
);
const { resolveExecutorWithProxy } =
await import("../../open-sse/handlers/chatCore/executorProxy.ts");
const { getExecutor } = await import("../../open-sse/executors/index.ts");
const { clearUpstreamProxyConfigCache } = await import(
"../../open-sse/handlers/chatCore/comboContextCache.ts"
);
const { clearUpstreamProxyConfigCache } =
await import("../../open-sse/handlers/chatCore/comboContextCache.ts");
before(async () => {
await coreDb.ensureDbInitialized();
@@ -137,3 +135,20 @@ test("connection override wins over provider mode 'fallback'", async () => {
// Connection override short-circuits to the passthrough executor, not the fallback wrapper.
assert.equal(exec, await getExecutor("cliproxyapi"));
});
test("retired common ChatGPT Web cannot bypass retirement through proxy overrides", async () => {
for (const providerId of ["chatgpt-web", "cgpt-web"]) {
await assert.rejects(
() =>
resolveExecutorWithProxy(providerId, undefined, {
cliproxyapiMode: "claude-native",
}),
(error: unknown) => {
const typed = error as Error & { code?: string; status?: number };
assert.equal(typed.code, "PROVIDER_RETIRED");
assert.equal(typed.status, 410);
return true;
}
);
}
});

View File

@@ -1,87 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../open-sse/services/chatgptImageCache.ts");
const {
storeChatGptImage,
getChatGptImage,
__resetChatGptImageCacheForTesting,
__getChatGptImageCacheBytesForTesting,
} = mod;
// ── Constants ──
test("MAX_ENTRIES is 25", async () => {
// We verify indirectly: store 25 entries, then store a 26th and confirm
// the first entry was evicted. This also proves the constant is 25.
__resetChatGptImageCacheForTesting();
const ids: string[] = [];
for (let i = 0; i < 25; i++) {
ids.push(storeChatGptImage(Buffer.from(`img-${i}`), "image/png", 60_000));
}
// All 25 should be retrievable
for (const id of ids) {
assert.ok(getChatGptImage(id), "entry within MAX_ENTRIES should survive");
}
__resetChatGptImageCacheForTesting();
});
test("DEFAULT_MAX_BYTES is 10 MB (10 * 1024 * 1024)", async () => {
__resetChatGptImageCacheForTesting();
// Store an entry that is just under 10 MB — should succeed
const big = Buffer.alloc(10 * 1024 * 1024 - 1, 0x42);
const id = storeChatGptImage(big, "image/png", 60_000);
assert.ok(getChatGptImage(id), "entry under 10 MB should be cached");
assert.equal(__getChatGptImageCacheBytesForTesting(), big.length);
__resetChatGptImageCacheForTesting();
});
// ── Eviction ──
test("storing 26 entries evicts the oldest", async () => {
__resetChatGptImageCacheForTesting();
const ids: string[] = [];
for (let i = 0; i < 26; i++) {
ids.push(storeChatGptImage(Buffer.from(`img-${i}`), "image/png", 60_000));
}
// The first entry (index 0) should have been evicted
assert.equal(getChatGptImage(ids[0]), null, "oldest entry should be evicted");
// The second entry should still be present
assert.ok(getChatGptImage(ids[1]), "second entry should survive");
// The newest entry should be present
assert.ok(getChatGptImage(ids[25]), "newest entry should survive");
__resetChatGptImageCacheForTesting();
});
// ── Store & Retrieve (hit) ──
test("store then retrieve returns the cached entry (cache hit)", async () => {
__resetChatGptImageCacheForTesting();
const payload = Buffer.from("hello-image-data");
const id = storeChatGptImage(payload, "image/jpeg", 60_000);
const entry = getChatGptImage(id);
assert.ok(entry, "entry should exist");
assert.deepEqual(entry!.bytes, payload);
assert.equal(entry!.mime, "image/jpeg");
assert.ok(typeof entry!.bytesSha256 === "string" && entry!.bytesSha256.length === 64);
__resetChatGptImageCacheForTesting();
});
// ── TTL expiry ──
test("entry expires after TTL (mocked Date.now)", async () => {
__resetChatGptImageCacheForTesting();
const originalNow = Date.now;
let fakeNow = 1_000_000;
Date.now = () => fakeNow;
const id = storeChatGptImage(Buffer.from("ttl-test"), "image/png", 5000);
assert.ok(getChatGptImage(id), "should hit before TTL");
// Advance past TTL
fakeNow += 5001;
assert.equal(getChatGptImage(id), null, "should miss after TTL expires");
Date.now = originalNow;
__resetChatGptImageCacheForTesting();
});

View File

@@ -1,445 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import(
"../../open-sse/executors/chatgpt-web.ts"
);
const { __setTlsFetchOverrideForTesting } = await import(
"../../open-sse/services/chatgptTlsClient.ts"
);
function makeHeaders(map: Record<string, string> = {}) {
const h = new Headers();
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
return h;
}
const CONVERSATION_ID = "conv-async-7357";
const FINAL_POINTER = "file-service://file-final-7357";
// SSE stream: assistant starts, tool kicks off image_gen (the "Processing
// image..." card via metadata.image_gen_task_id), stream ends WITHOUT any
// resolved image_asset_pointer — the real async case where the image only
// shows up later, over the celsius WebSocket.
function asyncImageGenSseText(): string {
const events = [
{
conversation_id: CONVERSATION_ID,
message: {
id: "msg-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Generating your image..."] },
status: "in_progress",
},
},
{
conversation_id: CONVERSATION_ID,
message: {
id: "tool-1",
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
metadata: { image_gen_task_id: "task-7357" },
content: { content_type: "text", parts: [] },
},
},
];
const chunks = events.map((e) => `data: ${JSON.stringify(e)}\r\n\r\n`);
chunks.push("data: [DONE]\r\n\r\n");
return chunks.join("");
}
// Fake global WebSocket: opens, then emits ONE frame shaped like chatgpt.com's
// celsius wire format for the PLURAL case — payload.update_content.messages[]
// — carrying the completed tool-role image_asset_pointer message. This is the
// shape issue #7357 reports chatgpt.com sends and the current parser does not
// recognize (it only reads update_content.message, singular).
class FakeWebSocket extends EventEmitter {
url: string;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;
onclose: (() => void) | null = null;
static instances: FakeWebSocket[] = [];
constructor(url: string) {
super();
this.url = url;
FakeWebSocket.instances.push(this);
setTimeout(() => {
this.onopen?.();
setTimeout(() => {
const frame = {
type: "conversation-update",
payload: {
conversation_id: CONVERSATION_ID,
update_content: {
messages: [
{
message: {
id: "img-msg-final",
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
content: {
content_type: "multimodal_text",
parts: [
{
content_type: "image_asset_pointer",
asset_pointer: FINAL_POINTER,
width: 1024,
height: 1024,
},
],
},
status: "finished_successfully",
},
},
],
},
},
};
this.onmessage?.({ data: JSON.stringify(frame) });
}, 5);
}, 5);
}
close() {}
}
test("#7357: async image_gen pointer delivered via update_content.messages[] should resolve to markdown (currently lost → 502)", async () => {
__resetChatGptWebCachesForTesting();
const previousWebSocket = (globalThis as Record<string, unknown>).WebSocket;
const previousTimeout = process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = "300"; // keep the probe fast
(globalThis as Record<string, unknown>).WebSocket = FakeWebSocket;
__setTlsFetchOverrideForTesting(async (url, opts = {}) => {
const u = String(url);
const method = opts.method || "GET";
if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && method === "GET") {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"><script src="https://cdn.oaistatic.com/main.js"></script></html>',
body: null,
};
}
if (u.includes("/api/auth/session")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
accessToken: "jwt-7357",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "u-7357" },
}),
body: null,
};
}
if (u.includes("/backend-api/sentinel/chat-requirements")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ token: "t", proofofwork: { required: false } }),
body: null,
};
}
if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: asyncImageGenSseText(),
body: null,
};
}
if (u.includes("/backend-api/celsius/ws/user")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ websocket_url: "wss://chatgpt.com/fake-celsius-socket" }),
body: null,
};
}
// Resolution path for FINAL_POINTER, exercised ONLY if the WS listener
// actually extracts the pointer from the update_content.messages[] frame.
if (u.match(/\/backend-api\/files\/[^/]+\/download/)) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
download_url: "https://chatgpt.com/backend-api/estuary/content?id=file-final-7357",
}),
body: null,
};
}
if (u.startsWith("https://chatgpt.com/backend-api/estuary/content")) {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return {
status: 200,
headers: makeHeaders({ "Content-Type": "image/png" }),
text: `data:image/png;base64,${pngBytes.toString("base64")}`,
body: null,
};
}
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.5",
body: { messages: [{ role: "user", content: "generate an image of a kitten" }] },
stream: false,
credentials: { apiKey: "test-session-cookie" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200, "executor itself does not error");
const json = await result.response.json();
const content = String(json?.choices?.[0]?.message?.content || "");
assert.ok(FakeWebSocket.instances.length >= 1, "a WebSocket connection was opened");
// Expected/correct behavior: the celsius WebSocket delivered a complete,
// well-formed tool-role image_asset_pointer message via chatgpt.com's
// update_content.messages[] (plural) shape. OmniRoute should extract it,
// resolve it, and append image markdown — just like the already-covered
// update_content.message (singular) case in tests/unit/chatgpt-web.test.ts.
assert.match(
content,
/!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/,
"BUG #7357: image pointer delivered via update_content.messages[] (plural) was not " +
"resolved into markdown — waitForImageViaWebSocket() only recognizes the singular " +
"update_content.message / payload.message / data.message shapes and silently drops " +
"this frame, losing an already-completed upstream image."
);
assert.equal(
json.x_image_resolution_failed,
undefined,
"resolution succeeded — no unresolved-pointer flag expected"
);
} finally {
__setTlsFetchOverrideForTesting(null);
if (previousWebSocket === undefined) delete (globalThis as Record<string, unknown>).WebSocket;
else (globalThis as Record<string, unknown>).WebSocket = previousWebSocket;
if (previousTimeout === undefined) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
else process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = previousTimeout;
}
});
// Fake WebSocket that opens cleanly then closes without ever delivering a
// frame — the case where register-websocket succeeds but the celsius socket
// itself yields nothing (Cloudflare edge drops it, or the browser TLS
// fingerprint mismatch upstream expects breaks the exchange silently). This
// is a *clean* close (no error event), so waitForImageViaWebSocket()
// resolves with `errored: false` and pollForAsyncImage() does not retry the
// socket — it falls through to the conversation-poll fallback.
class EmptyCloseWebSocket extends EventEmitter {
url: string;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;
onclose: (() => void) | null = null;
static instances: EmptyCloseWebSocket[] = [];
constructor(url: string) {
super();
this.url = url;
EmptyCloseWebSocket.instances.push(this);
setTimeout(() => {
this.onopen?.();
setTimeout(() => this.onclose?.(), 5);
}, 5);
}
close() {}
}
const STALE_POINTER = "file-service://file-stale-7357";
const NEWEST_POINTER = "file-service://file-newest-7357";
test(
"#7357: conversation-poll fallback recovers the image and prefers the newest " +
"message when the websocket path closes without delivering a frame",
async () => {
__resetChatGptWebCachesForTesting();
const previousWebSocket = (globalThis as Record<string, unknown>).WebSocket;
const previousTimeout = process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
// Keep the websocket wait short so the test doesn't burn real time
// waiting for the (intentionally empty) socket to time out.
process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = "300";
(globalThis as Record<string, unknown>).WebSocket = EmptyCloseWebSocket;
let conversationPollCalls = 0;
__setTlsFetchOverrideForTesting(async (url, opts = {}) => {
const u = String(url);
const method = opts.method || "GET";
if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && method === "GET") {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"><script src="https://cdn.oaistatic.com/main.js"></script></html>',
body: null,
};
}
if (u.includes("/api/auth/session")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
accessToken: "jwt-7357-poll",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "u-7357-poll" },
}),
body: null,
};
}
if (u.includes("/backend-api/sentinel/chat-requirements")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ token: "t", proofofwork: { required: false } }),
body: null,
};
}
if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: asyncImageGenSseText(),
body: null,
};
}
if (u.includes("/backend-api/celsius/ws/user")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ websocket_url: "wss://chatgpt.com/fake-celsius-socket" }),
body: null,
};
}
// GET /backend-api/conversation/<id> — the conversation-poll fallback
// fetchConversationDetail() hits once the websocket yields nothing.
// The mapping carries TWO tool messages with image pointers at
// different create_time — the fallback must pick the newer one.
if (
u === `https://chatgpt.com/backend-api/conversation/${CONVERSATION_ID}` &&
method === "GET"
) {
conversationPollCalls++;
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
mapping: {
"node-stale": {
message: {
id: "img-msg-stale",
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
content: {
content_type: "multimodal_text",
parts: [
{
content_type: "image_asset_pointer",
asset_pointer: STALE_POINTER,
width: 1024,
height: 1024,
},
],
},
status: "finished_successfully",
create_time: 1000,
},
},
"node-newest": {
message: {
id: "img-msg-newest",
author: { role: "tool", name: "t2uay3k.sj1i4kz" },
content: {
content_type: "multimodal_text",
parts: [
{
content_type: "image_asset_pointer",
asset_pointer: NEWEST_POINTER,
width: 1024,
height: 1024,
},
],
},
status: "finished_successfully",
create_time: 2000,
},
},
},
}),
body: null,
};
}
if (u.match(/\/backend-api\/files\/[^/]+\/download/)) {
const fileId = u.match(/\/backend-api\/files\/([^/]+)\/download/)?.[1] ?? "unknown";
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
download_url: `https://chatgpt.com/backend-api/estuary/content?id=${fileId}`,
}),
body: null,
};
}
if (u.startsWith("https://chatgpt.com/backend-api/estuary/content")) {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return {
status: 200,
headers: makeHeaders({ "Content-Type": "image/png" }),
text: `data:image/png;base64,${pngBytes.toString("base64")}`,
body: null,
};
}
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.5",
body: {
messages: [{ role: "user", content: "generate an image of a kitten" }],
},
stream: false,
credentials: { apiKey: "test-session-cookie" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200, "executor itself does not error");
const json = await result.response.json();
const content = String(json?.choices?.[0]?.message?.content || "");
assert.ok(
EmptyCloseWebSocket.instances.length >= 1,
"a WebSocket connection was opened and closed without a frame"
);
assert.ok(
conversationPollCalls >= 1,
"the conversation-poll fallback was invoked after the websocket yielded nothing"
);
assert.match(
content,
/!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/,
"conversation-poll fallback should have recovered the image the websocket lost"
);
assert.equal(
json.x_image_resolution_failed,
undefined,
"resolution succeeded — no unresolved-pointer flag expected"
);
} finally {
__setTlsFetchOverrideForTesting(null);
if (previousWebSocket === undefined) delete (globalThis as Record<string, unknown>).WebSocket;
else (globalThis as Record<string, unknown>).WebSocket = previousWebSocket;
if (previousTimeout === undefined) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS;
else process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = previousTimeout;
}
}
);

View File

@@ -1,50 +0,0 @@
// ChatGPT-web citation link-text escaping (CodeQL js/incomplete-sanitization,
// PR #6569 release-blocker).
//
// `markdownLinkText()` builds the `[text]` half of a Markdown link from an
// untrusted citation label. It escaped `[` and `]` but NOT the backslash
// itself, so a label ending in (or containing) a backslash produced a broken
// link: e.g. `[Path C:\](url)` — the trailing `\` escapes the closing `]`,
// consuming the link's bracket. The escape character must be escaped first.
import test from "node:test";
import assert from "node:assert/strict";
const { cleanChatGptText } = await import(
"../../open-sse/executors/chatgpt-web/citations.ts"
);
const S = ""; // marker start
const SEP = ""; // marker separator
const E = ""; // marker end
// Build a raw `url` citation marker:  url  <label>  <url> 
const urlMarker = (label: string, url: string) => `${S}url${SEP}${label}${SEP}${url}${E}`;
test("markdownLinkText escapes a trailing backslash in the citation label", () => {
// Label ends in a backslash — without escaping, `[Path C:\](url)` breaks the link.
const text = `See ${urlMarker("Path C:\\", "https://example.com/docs")} here`;
const out = cleanChatGptText(text);
assert.equal(out, "See [Path C:\\\\](https://example.com/docs) here");
// The backslash must be doubled (escaped), never left bare before the `]`.
assert.doesNotMatch(out, /[^\\]\\\]/);
});
test("markdownLinkText escapes a backslash preceding a bracket (no bracket leak)", () => {
const text = urlMarker("a\\[b", "https://example.com");
const out = cleanChatGptText(text);
// `\` → `\\`, then `[` → `\[` : label renders as literal `a\[b`.
assert.equal(out, "[a\\\\\\[b](https://example.com)");
});
test("bracket-only labels keep their existing escaping (regression guard)", () => {
const text = urlMarker("a[b]c", "https://example.com");
const out = cleanChatGptText(text);
assert.equal(out, "[a\\[b\\]c](https://example.com)");
});
test("plain labels with no metacharacters pass through unchanged", () => {
const text = urlMarker("Plain Title", "https://example.com");
const out = cleanChatGptText(text);
assert.equal(out, "[Plain Title](https://example.com)");
});

View File

@@ -1,390 +0,0 @@
// ChatGPT-web citation-marker → Markdown link rendering (#6635).
//
// content_references metadata (grouped_webpages, sources_footnote, webpage/url
// mentions) is resolved into real Markdown links instead of raw ChatGPT UI
// private-use marker tokens (citeturn0search0, entity[...], etc.).
// These tests live in a dedicated file (chatgpt-web.test.ts is a frozen
// god-file at the file-size cap and cannot grow) — mirrors the minimal-mock
// pattern already used by chatgpt-web-tools-5240.test.ts.
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");
// ─── Minimal TLS-fetch mock ──────────────────────────────────────────────────
// Tailored to the citation flow: root/DPL, session→accessToken, sentinel→token
// (no PoW), conv→SSE built from the caller-supplied events. Warmup GETs fall
// through to 404, which the executor tolerates.
function makeHeaders(map: Record<string, string> = {}) {
const h = new Headers();
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
return h;
}
function sseText(events: unknown[]): string {
const chunks: string[] = [];
for (const evt of events) {
const { __event, ...payload } = evt as Record<string, unknown> & { __event?: string };
if (__event) chunks.push(`event: ${__event}\r\n`);
chunks.push(`data: ${JSON.stringify(payload)}\r\n\r\n`);
}
chunks.push("data: [DONE]\r\n\r\n");
return chunks.join("");
}
function installMockFetch({
conv,
conversationDetail,
}: {
conv: { status: number; events: unknown[] };
conversationDetail?: { status: number; body: unknown };
}) {
const calls = { urls: [] as string[], bodies: [] as unknown[], conversationDetail: 0 };
__setTlsFetchOverrideForTesting(
async (url: string, opts: { method?: string; body?: unknown } = {}) => {
const u = String(url);
calls.urls.push(u);
calls.bodies.push(opts.body);
const json = (body: unknown, status = 200) => ({
status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify(body),
body: null,
});
if (
(u === "https://chatgpt.com/" || u === "https://chatgpt.com") &&
(opts.method || "GET") === "GET"
) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test123"><script src="https://cdn.oaistatic.com/_next/static/chunks/main-test.js"></script></html>',
body: null,
};
}
if (u.includes("/api/auth/session")) {
return json({
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
});
}
if (u.includes("/sentinel/chat-requirements")) {
return json({ token: "req-token", proofofwork: { required: false } });
}
// /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) {
calls.conversationDetail++;
return json(conversationDetail.body, conversationDetail.status);
}
}
if (
u.endsWith("/backend-api/f/conversation") ||
u.endsWith("/backend-api/conversation") ||
/\/backend-api\/(f\/)?conversation\?/.test(u)
) {
return {
status: conv.status,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText(conv.events),
body: null,
};
}
// Warmup (/me, /conversations, /models) — tolerated.
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
}
);
return {
calls,
restore() {
__setTlsFetchOverrideForTesting(null);
},
};
}
test("Non-streaming: resolves ChatGPT web citation markers into markdown links", async () => {
__resetChatGptWebCachesForTesting();
const urlMarker = "urlTesla";
const citationMarker = "citeturn0search0turn0search3";
const answerPrefix = `${urlMarker} FSD v14 is rolling out `;
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: {
content_type: "text",
parts: [`${answerPrefix}${citationMarker}`],
},
status: "finished_successfully",
metadata: {
content_references: [
{
type: "webpage",
title: "Tesla",
matched_text: urlMarker,
start_idx: 0,
end_idx: urlMarker.length,
safe_urls: ["https://www.tesla.com/en_au/support/autopilot"],
},
{
type: "grouped_webpages",
matched_text: citationMarker,
start_idx: answerPrefix.length,
end_idx: answerPrefix.length + citationMarker.length,
items: [
{
title: "Tesla FSD v14 release notes",
url: "https://www.tesla.com/support/fsd-v14?utm_source=chatgpt.com",
attribution: "tesla.com",
},
{
title: "Owner discussion",
url: "https://example.com/owners/fsd-v14",
attribution: "example.com",
},
],
},
],
},
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-sol-pro",
body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
assert.match(
content,
/\[Tesla\]\(https:\/\/www\.tesla\.com\/en_au\/support\/autopilot\) FSD v14 is rolling out/
);
assert.match(
content,
/\[1\]\(https:\/\/www\.tesla\.com\/support\/fsd-v14\?utm_source=chatgpt\.com\)/
);
assert.match(content, /\[2\]\(https:\/\/example\.com\/owners\/fsd-v14\)/);
assert.doesNotMatch(content, /|||turn0search/);
} finally {
m.restore();
}
});
test("Streaming: buffers split ChatGPT citation markers until metadata can link them", async () => {
__resetChatGptWebCachesForTesting();
const citationMarker = "citeturn0search0turn0search3";
const prefix = "Tesla FSD v14 is rolling out ";
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: [prefix] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: [prefix + "citeturn0search0"] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: [prefix + citationMarker + "."] },
status: "finished_successfully",
metadata: {
content_references: [
{
type: "grouped_webpages",
matched_text: citationMarker,
start_idx: prefix.length,
end_idx: prefix.length + citationMarker.length,
items: [
{
title: "Tesla source",
url: "https://www.tesla.com/fsd",
attribution: "tesla.com",
},
{
title: "Owners source",
url: "https://example.com/owners",
attribution: "example.com",
},
],
},
],
},
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-sol-pro",
body: {
messages: [{ role: "user", content: "latest Tesla FSD in Australia" }],
stream: true,
},
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const text = await result.response.text();
const content = text
.split("\n")
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]")
.map((l) => {
try {
return JSON.parse(l.slice(6));
} catch {
return null;
}
})
.filter((j) => j?.choices?.[0]?.delta?.content)
.map((j) => j.choices[0].delta.content)
.join("");
assert.equal(
content,
"Tesla FSD v14 is rolling out [1](https://www.tesla.com/fsd)[2](https://example.com/owners)."
);
assert.doesNotMatch(content, /|||turn0search/);
} finally {
m.restore();
}
});
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({
conv: {
status: 200,
events: [
{
conversation_id: "conv-pro",
message: {
id: "progress-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Working on it…"] },
status: "in_progress",
},
},
{ __event: "stream_handoff", conversation_id: "conv-pro" },
],
},
conversationDetail: {
status: 200,
body: {
mapping: {
thought: {
message: {
id: "thought",
author: { role: "assistant" },
content: { content_type: "thoughts", parts: ["hidden thinking"] },
status: "finished_successfully",
end_turn: true,
create_time: 1,
update_time: 1,
},
},
final: {
message: {
id: "final",
author: { role: "assistant" },
content: {
content_type: "text",
parts: [`👉 Final full Pro answer. ${citationMarker}`],
},
status: "finished_successfully",
end_turn: true,
create_time: 2,
update_time: 2,
metadata: {
content_references: [
{
type: "grouped_webpages",
matched_text: citationMarker,
start_idx: "👉 Final full Pro answer. ".length,
end_idx: "👉 Final full Pro answer. ".length + citationMarker.length,
items: [
{
title: "Polled Pro source",
url: "https://example.com/pro-source",
attribution: "example.com",
},
],
},
],
},
},
},
},
},
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-sol-pro",
body: { messages: [{ role: "user", content: "hard problem" }] },
stream: false,
credentials: { apiKey: "cookie-pro-poll" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const json = await result.response.json();
assert.equal(
json.choices[0].message.content,
"👉 Final full Pro answer. [1](https://example.com/pro-source)"
);
assert.equal(m.calls.conversationDetail, 1);
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const sentBody = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(sentBody.history_and_training_disabled, true);
} finally {
m.restore();
}
});

View File

@@ -1,260 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TlsFetchOptions } from "../../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");
function makeHeaders(values: Record<string, string> = {}): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) headers.set(name, value);
return headers;
}
function sseText(events: unknown[]): string {
return `${events.map((event) => `data: ${JSON.stringify(event)}\r\n\r\n`).join("")}data: [DONE]\r\n\r\n`;
}
type ResumeRequest = {
body: { conversation_id?: string; offset?: number };
headers: Record<string, string>;
};
function installHandoffMock(
finalText: string,
options: { firstResumeStatus?: number } = {}
): {
calls: { conversationDetail: number; resume: ResumeRequest[] };
restore: () => void;
} {
const calls = {
conversationDetail: 0,
resume: [] as ResumeRequest[],
};
__setTlsFetchOverrideForTesting(async (url: string, request: TlsFetchOptions = {}) => {
const target = String(url);
const json = (body: unknown, status = 200) => ({
status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify(body),
body: null,
});
if (
(target === "https://chatgpt.com/" || target === "https://chatgpt.com") &&
(request.method ?? "GET") === "GET"
) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"><script src="/_next/static/chunks/main.js"></script></html>',
body: null,
};
}
if (target.includes("/api/auth/session")) {
return json({
accessToken: "jwt-test",
expires: new Date(Date.now() + 3_600_000).toISOString(),
user: { id: "account-test" },
});
}
if (target.includes("/sentinel/chat-requirements")) {
return json({ token: "requirements-token", proofofwork: { required: false } });
}
if (target.endsWith("/backend-api/f/conversation/resume")) {
const body = JSON.parse(request.body ?? "{}") as ResumeRequest["body"];
calls.resume.push({ body, headers: request.headers ?? {} });
if (options.firstResumeStatus && calls.resume.length === 1) {
return {
status: options.firstResumeStatus,
headers: makeHeaders({ "Content-Type": "text/plain" }),
text: "not ready",
body: null,
};
}
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText([
{
conversation_id: "conversation-handoff",
message: {
id: "assistant-final",
author: { role: "assistant" },
content: { content_type: "text", parts: [finalText] },
status: "in_progress",
},
},
{
conversation_id: "conversation-handoff",
message: {
id: "assistant-final",
author: { role: "assistant" },
content: { content_type: "text", parts: [finalText] },
status: "finished_successfully",
end_turn: true,
},
},
]),
body: null,
};
}
if (target.endsWith("/backend-api/f/conversation")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText([
{
type: "resume_conversation_token",
token: "resume-token",
conversation_id: "conversation-handoff",
},
{
type: "stream_handoff",
conversation_id: "conversation-handoff",
turn_exchange_id: "turn-handoff",
options: [
{ type: "resume_sse_endpoint", topic_id: "conversation-turn-handoff" },
{ type: "subscribe_ws_topic", topic_id: "conversation-turn-handoff" },
],
},
]),
body: null,
};
}
if (/\/backend-api\/conversation\/[^/?#]+$/.test(target)) {
calls.conversationDetail++;
return json(
{
detail: {
message: "You do not have access to this temporary conversation.",
code: "conversation_not_found",
},
},
404
);
}
// Browser warmup requests are non-fatal, but returning 200 keeps test logs quiet.
if (
target.includes("/backend-api/me") ||
target.includes("/backend-api/conversations?") ||
target.includes("/backend-api/models?")
) {
return json({});
}
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
return {
calls,
restore() {
__setTlsFetchOverrideForTesting(null);
},
};
}
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}`;
const mock = installHandoffMock(expected);
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model,
body: { messages: [{ role: "user", content: "hard problem" }] },
stream: false,
credentials: { apiKey: `cookie-${model}` },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const response = await result.response.json();
assert.equal(response.choices[0].message.content, expected);
assert.equal(mock.calls.resume.length, 1);
assert.deepEqual(mock.calls.resume[0].body, {
conversation_id: "conversation-handoff",
offset: 0,
});
assert.equal(mock.calls.resume[0].headers["x-conduit-token"], "resume-token");
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});
}
});
test("ChatGPT Web handoff retries the next resume offset after a 404", async () => {
__resetChatGptWebCachesForTesting();
const mock = installHandoffMock("OFFSET_ONE_OK", { firstResumeStatus: 404 });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-sol-pro",
body: { messages: [{ role: "user", content: "hard problem" }] },
stream: false,
credentials: { apiKey: "cookie-offset" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const response = await result.response.json();
assert.equal(response.choices[0].message.content, "OFFSET_ONE_OK");
assert.deepEqual(
mock.calls.resume.map((call) => call.body.offset),
[0, 1]
);
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});
test("ChatGPT Web streaming appends the native resumed Pro answer", async () => {
__resetChatGptWebCachesForTesting();
const mock = installHandoffMock("STREAM_RESUME_OK");
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-sol-pro",
body: { messages: [{ role: "user", content: "hard problem" }], stream: true },
stream: true,
credentials: { apiKey: "cookie-stream" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const responseText = await result.response.text();
const content = responseText
.split("\n")
.filter((line) => line.startsWith("data: ") && line !== "data: [DONE]")
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>)
.map((event) => {
const choices = event.choices as Array<{ delta?: { content?: string } }> | undefined;
return choices?.[0]?.delta?.content ?? "";
})
.join("");
assert.equal(content, "STREAM_RESUME_OK");
assert.equal(mock.calls.resume.length, 1);
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
test("central image handler blocks retired common ChatGPT Web ids before network dispatch", async () => {
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
throw new Error("Retired image providers must not reach the network");
};
try {
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const viaRequestedModel = await handleImageGeneration({
body: { model: `${provider}/gpt-5.5`, prompt: "draw a lighthouse" },
credentials: { apiKey: "unused" },
log: null,
});
assert.deepEqual(viaRequestedModel, {
success: false,
status: 410,
error: "Provider is retired and unavailable.",
code: "PROVIDER_RETIRED",
});
const viaBareProviderId = await handleImageGeneration({
body: { model: provider, prompt: "draw a lighthouse" },
credentials: { apiKey: "unused" },
log: null,
});
assert.deepEqual(viaBareProviderId, viaRequestedModel);
const viaResolvedProvider = await handleImageGeneration({
body: { model: `${provider}/gpt-5.5`, prompt: "draw a lighthouse" },
credentials: { apiKey: "unused" },
resolvedProvider: provider,
log: null,
});
assert.deepEqual(viaResolvedProvider, viaRequestedModel);
}
const similarButDistinct = await handleImageGeneration({
body: { model: "chatgpt-web-preview/gpt-5.5", prompt: "draw a lighthouse" },
credentials: { apiKey: "unused" },
log: null,
});
assert.equal(similarButDistinct.status, 400);
assert.notEqual((similarButDistinct as { code?: string }).code, "PROVIDER_RETIRED");
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -1,96 +0,0 @@
// Regression guard for the escalated mesh-bot report: a user generated an
// image via the ChatGPT Web provider; the image WAS produced upstream but
// OmniRoute returned `502 "ChatGPT Web completed without returning image
// markdown"` — i.e. the silent-drop path where an image_asset_pointer existed
// but resolution failed, and the handler reported it as "no image made".
//
// The fix distinguishes "image generated but not retrievable" (executor sets
// x_image_resolution_failed) from "no image at all", so the 502 is accurate
// and actionable instead of misleading.
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-cgptweb-silentdrop-"));
const { detectImageResolutionFailure } = await import("../../open-sse/executors/chatgpt-web.ts");
const { handleChatGptWebImageGeneration } = await import(
"../../open-sse/handlers/imageGeneration/providers/chatgptWeb.ts"
);
function fakeExecutor(jsonBody: object, status = 200) {
return {
execute: async () => ({
response: new Response(JSON.stringify(jsonBody), {
status,
headers: { "Content-Type": "application/json" },
}),
}),
};
}
const baseArgs = {
model: "gpt-4o",
provider: "chatgpt-web",
body: { prompt: "a kitten" },
credentials: { apiKey: "sess-cookie" },
log: null,
signal: null,
clientHeaders: {},
};
test("detectImageResolutionFailure: true only when a pointer existed but none resolved", () => {
assert.equal(detectImageResolutionFailure(1, 0), true);
assert.equal(detectImageResolutionFailure(2, 0), true);
assert.equal(detectImageResolutionFailure(0, 0), false); // no image at all
assert.equal(detectImageResolutionFailure(1, 1), false); // resolved fine
});
test("handler surfaces a specific 502 when the image was generated but not retrievable", async () => {
const res = await handleChatGptWebImageGeneration({
...baseArgs,
executorFactory: () =>
fakeExecutor({
choices: [{ message: { role: "assistant", content: "Here's your image:" } }],
x_image_resolution_failed: true,
}),
});
assert.equal(res.success, false);
assert.equal(res.status, 502);
// must NOT be the misleading "completed without returning image markdown"
assert.ok(
!/completed without returning image markdown/i.test(res.error),
`expected specific retrieval error, got: ${res.error}`
);
// must clearly say the image was generated but could not be retrieved
assert.match(res.error, /could not (be )?retriev|generated an image but/i);
});
test("handler keeps the generic 502 when no image was generated at all", async () => {
const res = await handleChatGptWebImageGeneration({
...baseArgs,
executorFactory: () =>
fakeExecutor({
choices: [{ message: { role: "assistant", content: "I can't create that." } }],
}),
});
assert.equal(res.success, false);
assert.equal(res.status, 502);
assert.match(res.error, /completed without returning image markdown/i);
});
test("handler returns success when the executor produced image markdown", async () => {
const url = "/v1/chatgpt-web/image/abcdef0123456789";
const res = await handleChatGptWebImageGeneration({
...baseArgs,
executorFactory: () =>
fakeExecutor({
choices: [{ message: { role: "assistant", content: `Here you go:\n\n![image](${url})` } }],
}),
});
assert.equal(res.success, true);
assert.equal(res.data.data.length, 1);
assert.equal(res.data.data[0].url, url);
});

View File

@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-chatgpt-web-management-retirement-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.JWT_SECRET = "chatgpt-web-management-retirement-secret";
process.env.INITIAL_PASSWORD = "admin-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providersRoute = await import("../../src/app/api/providers/route.ts");
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
const bulkRoute = await import("../../src/app/api/providers/bulk/route.ts");
const importRoute = await import("../../src/app/api/providers/import/route.ts");
const bulkWebSessionRoute = await import("../../src/app/api/providers/bulk-web-session/route.ts");
const validateRoute = await import("../../src/app/api/providers/validate/route.ts");
const connectionTestRoute = await import("../../src/app/api/providers/[id]/test/route.ts");
const originalFetch = globalThis.fetch;
let networkCalls = 0;
async function resetStorage(): Promise<void> {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
networkCalls = 0;
}
async function managementPost(url: string, body: unknown): Promise<Request> {
return makeManagementSessionRequest(url, { method: "POST", body });
}
async function assertRetired(response: Response): Promise<void> {
assert.equal(response.status, 410);
const body = (await response.json()) as {
error?: { code?: string; message?: string };
};
assert.equal(body.error?.code, "PROVIDER_RETIRED");
assert.equal(body.error?.message, "Provider is retired and unavailable.");
}
test.before(() => {
globalThis.fetch = async () => {
networkCalls += 1;
throw new Error("Retired provider management paths must not reach the network");
};
});
test.beforeEach(resetStorage);
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("create, bulk import and validation paths reject retired provider ids with 410", async () => {
for (const provider of ["chatgpt-web", "cgpt-web"]) {
await assertRetired(
await providersRoute.POST(
await managementPost("http://localhost/api/providers", {
provider,
name: `${provider} retired create`,
apiKey: "retired-secret",
})
)
);
await assertRetired(
await bulkRoute.POST(
await managementPost("http://localhost/api/providers/bulk", {
provider,
entries: [{ name: `${provider} retired bulk`, apiKey: "retired-secret" }],
})
)
);
await assertRetired(
await importRoute.POST(
await managementPost("http://localhost/api/providers/import", {
entries: [{ provider, name: `${provider} retired import`, apiKey: "retired-secret" }],
})
)
);
await assertRetired(
await bulkWebSessionRoute.POST(
await managementPost("http://localhost/api/providers/bulk-web-session", {
provider,
entries: [{ name: `${provider} retired web import`, credential: "retired-cookie" }],
})
)
);
await assertRetired(
await validateRoute.POST(
await managementPost("http://localhost/api/providers/validate", {
provider,
apiKey: "retired-secret",
})
)
);
}
assert.equal(networkCalls, 0);
});
test("retired connections cannot be reactivated, updated or probed", async () => {
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const connection = await providersDb.createProviderConnection({
provider,
authType: "apikey",
name: `${provider} retired existing`,
apiKey: "retired-secret",
isActive: true,
testStatus: "active",
});
const id = String(connection.id);
const updateRequest = await makeManagementSessionRequest(
`http://localhost/api/providers/${id}`,
{ method: "PUT", body: { isActive: true, testStatus: "active" } }
);
await assertRetired(
await providerByIdRoute.PUT(updateRequest, { params: Promise.resolve({ id }) })
);
const batchRequest = await makeManagementSessionRequest("http://localhost/api/providers", {
method: "PATCH",
body: { ids: [id], isActive: true },
});
await assertRetired(await providersRoute.PATCH(batchRequest));
await assertRetired(
await connectionTestRoute.POST(
new Request(`http://localhost/api/providers/${id}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
}),
{ params: Promise.resolve({ id }) }
)
);
}
assert.equal(networkCalls, 0);
});

View File

@@ -1,34 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveChatGptModel,
resolveChatGptSystemHints,
} from "../../open-sse/executors/chatgpt-web/models.ts";
test("ChatGPT Web performance lanes use their native model and effort pairs", () => {
const cases = [
["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 [model, slug, effort, isPro] of cases) {
assert.deepEqual(resolveChatGptModel(model), { slug, effort, isPro }, model);
}
});
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"]);
});

View File

@@ -1,35 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
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 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 + resolver and does not import the host", () => {
const src = readFileSync(LEAF, "utf8");
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"/);
});
test("host imports the resolvers back from the leaf", () => {
const host = readFileSync(HOST, "utf8");
assert.match(host, /from "\.\/chatgpt-web\/models\.ts"/);
});
test("resolveChatGptModel maps a dot-form model id to a chatgpt slug", async () => {
const { resolveChatGptModel, MODEL_MAP } =
await import("../../open-sse/executors/chatgpt-web/models.ts");
const firstKey = Object.keys(MODEL_MAP)[0];
const resolved = resolveChatGptModel(firstKey);
assert.equal(typeof resolved.slug, "string");
assert.ok(resolved.slug.length > 0);
});

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getRegistryEntry, REGISTRY } from "../../open-sse/config/providerRegistry.ts";
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
import { AI_PROVIDERS } from "../../src/shared/constants/providers.ts";
const RETIRED_PROVIDER_IDS = ["chatgpt-web", "cgpt-web"] as const;
test("common ChatGPT Web is unavailable while ChatGPT Web Codex remains registered", () => {
assert.equal(REGISTRY["chatgpt-web"], undefined);
assert.equal(AI_PROVIDERS["chatgpt-web"], undefined);
for (const providerId of RETIRED_PROVIDER_IDS) {
assert.equal(getRegistryEntry(providerId), null);
assert.equal(hasSpecializedExecutor(providerId), false);
assert.throws(
() => getExecutor(providerId),
(error: unknown) => {
const typed = error as Error & { code?: string; status?: number };
assert.equal(typed.code, "PROVIDER_RETIRED");
assert.equal(typed.status, 410);
assert.equal(typed.message, "Provider is retired and unavailable.");
return true;
}
);
}
assert.ok(getRegistryEntry("chatgpt-web-codex"));
assert.ok(getRegistryEntry("cgpt-codex"));
assert.equal(hasSpecializedExecutor("chatgpt-web-codex"), true);
assert.equal(hasSpecializedExecutor("cgpt-codex"), true);
});

View File

@@ -0,0 +1,299 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatgpt-web-retired-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
const modelAliasesDb = await import("../../src/lib/db/models/aliases.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const modelAliasResolver = await import("../../src/lib/modelAliasResolver.ts");
const { getModelInfo } = await import("../../src/sse/services/model.ts");
const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts");
const auth = await import("../../src/sse/services/auth.ts");
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
const originalFetch = globalThis.fetch;
function isRetiredError(error: unknown): boolean {
const typed = error as Error & { code?: string; status?: number };
assert.equal(typed.code, "PROVIDER_RETIRED");
assert.equal(typed.status, 410);
assert.equal(typed.message, "Provider is retired and unavailable.");
return true;
}
async function resetStorage(): Promise<void> {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
core.getDbInstance();
modelAliasResolver.invalidateAliasCache();
}
test.beforeEach(async () => {
globalThis.fetch = originalFetch;
await resetStorage();
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("retired common ChatGPT Web prefixes cannot shadow compatible nodes", async () => {
for (const [index, prefix] of ["chatgpt-web", "cgpt-web", "ChatGPT-Web", "CGPT-WEB"].entries()) {
await providerNodesDb.createProviderNode({
id: `openai-compatible-retired-chatgpt-web-${index}`,
type: "openai-compatible",
name: `Retired ChatGPT Web prefix ${prefix}`,
prefix,
apiType: "chat",
baseUrl: "https://retired.example.invalid/v1",
});
await assert.rejects(() => getModelInfo(`${prefix}/gpt-5.5`), isRetiredError);
}
const codex = await getModelInfo("chatgpt-web-codex/high");
assert.equal(codex.provider, "chatgpt-web-codex");
assert.equal(codex.model, "high");
});
test("provider writes return the durable ChatGPT Web tombstone instead of stale active data", async () => {
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const created = await providersDb.createProviderConnection({
provider,
authType: "apikey",
name: `${provider} retired write`,
apiKey: `sk-${provider}-retired-write`,
isActive: true,
testStatus: "active",
});
assert.equal(created.isActive, false);
assert.equal(created.testStatus, "unavailable");
assert.equal(created.errorCode, "PROVIDER_REMOVED");
const updated = await providersDb.updateProviderConnection(String(created.id), {
isActive: true,
testStatus: "active",
errorCode: null,
});
assert.equal(updated?.isActive, false);
assert.equal(updated?.testStatus, "unavailable");
assert.equal(updated?.errorCode, "PROVIDER_REMOVED");
}
});
test("credential selection rejects retired ids even if a writer bypasses migration triggers", async () => {
const db = core.getDbInstance();
db.exec(`
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_insert;
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_update;
`);
for (const provider of ["chatgpt-web", "cgpt-web"]) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, api_key, is_active, test_status, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, ?, 1, 'active', datetime('now'), datetime('now'))"
).run(
`${provider}-bypassed-trigger`,
provider,
`${provider} bypassed trigger`,
`sk-${provider}-bypassed-trigger`
);
const credentials = await auth.getProviderCredentials(provider);
assert.equal(credentials, null);
}
});
test("chat resolution returns a sanitized retirement response", async () => {
const result = await resolveModelOrError(
"cgpt-web/gpt-5.5",
{
model: "cgpt-web/gpt-5.5",
messages: [{ role: "user", content: "hello" }],
},
"/v1/chat/completions"
);
assert.ok(result.error instanceof Response);
assert.equal(result.error.status, 410);
const body = (await result.error.json()) as {
error?: { code?: string; message?: string };
};
assert.equal(body.error?.code, "PROVIDER_RETIRED");
assert.equal(body.error?.message, "Provider is retired and unavailable.");
assert.equal(JSON.stringify(body).includes("cgpt-web"), false);
});
test("persisted aliases cannot rewrite retired ChatGPT Web models before routing", async () => {
await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "Retired ChatGPT Web alias control",
apiKey: "sk-chatgpt-web-retirement-control",
isActive: true,
testStatus: "active",
});
await modelAliasesDb.setModelAlias("chatgpt-web/gpt-5.5", "openai/gpt-4o");
await modelAliasesDb.setModelAlias("cgpt-web", "openai/gpt-4o");
await modelAliasesDb.setModelAlias("friendly-retired-chatgpt", "chatgpt-web/gpt-5.5");
await modelAliasesDb.setModelAlias("friendly-retired-cgpt", "cgpt-web/gpt-5.5");
await modelAliasesDb.setModelAlias("cgpt-web-preview", "openai/gpt-4o");
await settingsDb.updateSettings({
wildcardAliases: [{ pattern: "wildcard-retired-chatgpt-*", target: "chatgpt-web/gpt-5.5" }],
});
modelAliasResolver.invalidateAliasCache();
const fetchCalls: string[] = [];
globalThis.fetch = async (input: string | URL | Request) => {
fetchCalls.push(String(input));
return Response.json({
id: "chatcmpl-chatgpt-web-retirement-control",
choices: [{ message: { role: "assistant", content: "healthy control" } }],
});
};
const retired = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "chatgpt-web/gpt-5.5",
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(retired.status, 410);
assert.equal(fetchCalls.length, 0);
const retiredBody = (await retired.json()) as {
error?: { code?: string; message?: string };
};
assert.equal(retiredBody.error?.code, "PROVIDER_RETIRED");
assert.equal(retiredBody.error?.message, "Provider is retired and unavailable.");
assert.equal(JSON.stringify(retiredBody).includes("chatgpt-web"), false);
const retiredBareAlias = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "cgpt-web",
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(retiredBareAlias.status, 410);
assert.equal(fetchCalls.length, 0);
const retiredBareBody = (await retiredBareAlias.json()) as {
error?: { code?: string; message?: string };
};
assert.equal(retiredBareBody.error?.code, "PROVIDER_RETIRED");
assert.equal(retiredBareBody.error?.message, "Provider is retired and unavailable.");
for (const alias of [
"friendly-retired-chatgpt",
"friendly-retired-cgpt",
"wildcard-retired-chatgpt-model",
]) {
const retiredTargetAlias = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: alias,
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(retiredTargetAlias.status, 410);
const retiredTargetBody = (await retiredTargetAlias.json()) as {
error?: { code?: string; message?: string };
};
assert.equal(retiredTargetBody.error?.code, "PROVIDER_RETIRED");
assert.equal(retiredTargetBody.error?.message, "Provider is retired and unavailable.");
assert.equal(fetchCalls.length, 0);
}
const legitimateBareAlias = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "cgpt-web-preview",
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(legitimateBareAlias.status, 200);
assert.equal(fetchCalls.length, 1);
});
test("priority combo skips a retired ChatGPT Web target and uses its fallback", async () => {
await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "Healthy ChatGPT Web combo fallback",
apiKey: "sk-chatgpt-web-combo-fallback",
isActive: true,
testStatus: "active",
});
await combosDb.createCombo({
name: "retired-chatgpt-web-fallback",
strategy: "priority",
models: [
{ provider: "chatgpt-web", model: "gpt-5.5" },
{ provider: "openai", model: "gpt-4o" },
],
});
const fetchCalls: string[] = [];
globalThis.fetch = async (input: string | URL | Request) => {
fetchCalls.push(String(input));
return Response.json({
id: "chatcmpl-chatgpt-web-combo-fallback",
choices: [{ message: { role: "assistant", content: "healthy fallback" } }],
});
};
const response = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-OmniRoute-No-Cache": "true",
},
body: JSON.stringify({
model: "retired-chatgpt-web-fallback",
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(response.status, 200);
assert.equal(fetchCalls.length, 1);
const body = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
assert.equal(body.choices?.[0]?.message?.content, "healthy fallback");
});

View File

@@ -1,82 +0,0 @@
// #5531 — chatgpt-web sentinel PoW crashes on the Electron desktop app with
// "Digest method not supported" because Electron's BoringSSL lacks SHA-3
// (electron/electron#30530). The PoW must hash through a runtime-portable
// SHA3-512 that falls back to a pure-JS Keccak when native SHA-3 is absent.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const { sha3_512Hex, sha3_512HexJs, __setSha3NativeForTesting } =
await import("../../open-sse/utils/sha3-512.ts");
// FIPS-202 known-answer vectors for SHA3-512.
const FIPS: Record<string, string> = {
"":
"a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a6" +
"15b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26",
abc:
"b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e" +
"10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0",
};
function nativeSha3Available(): boolean {
try {
createHash("sha3-512").update(Buffer.alloc(0)).digest("hex");
return true;
} catch {
return false;
}
}
test("pure-JS SHA3-512 matches the FIPS-202 known-answer vectors", () => {
for (const [msg, want] of Object.entries(FIPS)) {
assert.equal(sha3_512HexJs(msg), want, `FIPS-202 vector mismatch for "${msg}"`);
}
});
test("pure-JS SHA3-512 is bit-identical to native createHash('sha3-512') on 300 random inputs", () => {
if (!nativeSha3Available()) {
// Runtime without native SHA-3 (e.g. an Electron CI) — FIPS vectors already cover correctness.
return;
}
for (let i = 0; i < 300; i++) {
const len = (i * 7) % 200; // spans multi-block (>72B) and exact-block-boundary cases
const buf = Buffer.alloc(len);
for (let j = 0; j < len; j++) buf[j] = (i * 31 + j * 17) & 0xff;
const native = createHash("sha3-512").update(buf).digest("hex");
assert.equal(sha3_512HexJs(buf), native, `mismatch vs native at len=${len}`);
}
});
test("sha3_512Hex falls back to pure-JS when native SHA-3 is unavailable (Electron/BoringSSL sim) — #5531", () => {
__setSha3NativeForTesting(null); // simulate BoringSSL: createHash('sha3-512') would throw
try {
assert.equal(sha3_512Hex("abc"), FIPS.abc);
assert.equal(sha3_512Hex(""), FIPS[""]);
assert.equal(sha3_512Hex(Buffer.from("abc")), FIPS.abc);
} finally {
__setSha3NativeForTesting(undefined); // restore auto-detect
}
});
test("sha3_512Hex uses the native digest where available (parity with fallback)", () => {
if (!nativeSha3Available()) return;
__setSha3NativeForTesting(undefined); // force re-probe → native
assert.equal(sha3_512Hex("abc"), FIPS.abc);
assert.equal(sha3_512Hex("abc"), sha3_512HexJs("abc"));
});
test("chatgpt-web PoW routes SHA3-512 through the portable helper, not inline createHash (#5531 guard)", async () => {
const execPath = fileURLToPath(
new URL("../../open-sse/executors/chatgpt-web.ts", import.meta.url)
);
const src = await readFile(execPath, "utf8");
assert.ok(
!/createHash\(\s*["']sha3-512["']\s*\)/.test(src),
"PoW must NOT call native createHash('sha3-512') inline — it crashes under Electron/BoringSSL"
);
assert.ok(/sha3_512Hex\s*\(/.test(src), "chatgpt-web PoW must hash via sha3_512Hex()");
});

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
test("common ChatGPT Web derived implementation files are absent", () => {
const removedPaths = [
"open-sse/config/providers/registry/chatgpt-web/index.ts",
"open-sse/executors/chatgpt-web.ts",
"open-sse/executors/chatgpt-web/citations.ts",
"open-sse/executors/chatgpt-web/handoff.ts",
"open-sse/executors/chatgpt-web/models.ts",
"open-sse/executors/chatgptWebErrors.ts",
"open-sse/handlers/imageGeneration/providers/chatgptWeb.ts",
"open-sse/services/chatgptImageCache.ts",
"open-sse/services/chatgptTlsClient.ts",
"open-sse/utils/sha3-512.ts",
"src/app/api/v1/chatgpt-web/image/[id]/route.ts",
];
for (const relativePath of removedPaths) {
assert.equal(
fs.existsSync(path.join(process.cwd(), relativePath)),
false,
`${relativePath} must not ship`
);
}
assert.equal(fs.existsSync("open-sse/executors/chatgpt-web-codex.ts"), true);
assert.equal(fs.existsSync("open-sse/vendor/codex-chatgpt-web/bridge.ts"), true);
});

View File

@@ -1,269 +0,0 @@
// Tool-call emulation for the ChatGPT Web executor (#5240).
//
// chatgpt-web was omitted from the #3259 prompt-emulation rollout: body.tools
// was never read and both response builders hardcoded finish_reason:"stop".
// These tests live in a dedicated file (chatgpt-web.test.ts is a frozen
// god-file at the file-size cap and cannot grow).
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");
// ─── Minimal TLS-fetch mock ──────────────────────────────────────────────────
// 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.
function makeHeaders(map: Record<string, string> = {}) {
const h = new Headers();
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
return h;
}
function sseText(events: unknown[]): string {
return events.map((e) => `data: ${JSON.stringify(e)}\r\n\r\n`).join("") + "data: [DONE]\r\n\r\n";
}
/** A single finished assistant turn whose text is `parts`. */
function convWithAssistantText(parts: string) {
return [
{
conversation_id: "tc-1",
message: {
id: "tm-1",
author: { role: "assistant" },
content: { content_type: "text", parts: [parts] },
status: "in_progress",
},
},
{
conversation_id: "tc-1",
message: {
id: "tm-1",
author: { role: "assistant" },
content: { content_type: "text", parts: [parts] },
status: "finished_successfully",
},
},
];
}
function installMockFetch(convEvents: unknown[]) {
const calls = { urls: [] as string[], bodies: [] as unknown[] };
__setTlsFetchOverrideForTesting(async (url: string, opts: any = {}) => {
const u = String(url);
calls.urls.push(u);
calls.bodies.push(opts.body);
const json = (body: unknown, status = 200) => ({
status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify(body),
body: null,
});
if (
(u === "https://chatgpt.com/" || u === "https://chatgpt.com") &&
(opts.method || "GET") === "GET"
) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test123"><script src="https://cdn.oaistatic.com/_next/static/chunks/main-test.js"></script></html>',
body: null,
};
}
if (u.includes("/api/auth/session")) {
return json({
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
});
}
if (u.includes("/sentinel/chat-requirements")) {
return json({ token: "req-token", proofofwork: { required: false } });
}
if (
u.endsWith("/backend-api/f/conversation") ||
u.endsWith("/backend-api/conversation") ||
/\/backend-api\/(f\/)?conversation\?/.test(u)
) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText(convEvents),
body: null,
};
}
// Warmup (/me, /conversations, /models) — tolerated.
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
return {
calls,
restore() {
__setTlsFetchOverrideForTesting(null);
},
};
}
const WEATHER_TOOL = {
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a location",
parameters: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"],
},
},
};
const TOOL_CALL_TEXT = '<tool>{"name":"get_weather","arguments":{"location":"Tokyo"}}</tool>';
function baseOpts(extra: Record<string, unknown>) {
return {
model: "gpt-5.5",
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
...extra,
};
}
test("Tools request-side: <tool> contract is serialized into the upstream system message (#5240)", async () => {
__resetChatGptWebCachesForTesting();
const m = installMockFetch(convWithAssistantText("ok"));
try {
const executor = new ChatGptWebExecutor();
await executor.execute(
baseOpts({
body: {
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
tools: [WEATHER_TOOL],
},
stream: false,
}) as any
);
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.ok(convIdx >= 0, "conversation endpoint was hit");
const convBody = JSON.parse(m.calls.bodies[convIdx] as string);
const systemMsg = convBody.messages.find((mm: any) => mm.author.role === "system");
assert.ok(systemMsg, "a system message carrying the tool contract was sent");
const systemText = systemMsg.content.parts.join("");
assert.match(systemText, /<tool>/, "system prompt instructs the model to emit <tool> blocks");
assert.match(systemText, /get_weather/, "system prompt lists the requested tool");
} finally {
m.restore();
}
});
test("Tools non-stream: <tool>{...}</tool> text becomes OpenAI tool_calls + finish_reason (#5240)", async () => {
__resetChatGptWebCachesForTesting();
const m = installMockFetch(convWithAssistantText(TOOL_CALL_TEXT));
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute(
baseOpts({
body: {
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
tools: [WEATHER_TOOL],
},
stream: false,
}) as any
);
assert.equal(result.response.status, 200);
const json = await result.response.json();
assert.equal(json.choices[0].finish_reason, "tool_calls");
const tc = json.choices[0].message.tool_calls;
assert.ok(Array.isArray(tc) && tc.length === 1, "exactly one tool_call");
assert.equal(tc[0].type, "function");
assert.equal(tc[0].function.name, "get_weather");
assert.equal(typeof tc[0].function.arguments, "string", "arguments is a JSON string");
assert.deepEqual(JSON.parse(tc[0].function.arguments), { location: "Tokyo" });
assert.equal(json.choices[0].message.content, null);
} finally {
m.restore();
}
});
test("Tools stream: terminal chunk carries delta.tool_calls + finish_reason tool_calls (#5240)", async () => {
__resetChatGptWebCachesForTesting();
const m = installMockFetch(convWithAssistantText(TOOL_CALL_TEXT));
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute(
baseOpts({
body: {
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
tools: [WEATHER_TOOL],
stream: true,
},
stream: true,
}) as any
);
assert.equal(result.response.status, 200);
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
const text = await result.response.text();
const chunks = text
.split("\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)));
const toolChunk = chunks.find((c) => c.choices[0].delta && c.choices[0].delta.tool_calls);
assert.ok(toolChunk, "a chunk carries delta.tool_calls");
assert.equal(toolChunk.choices[0].finish_reason, "tool_calls");
const tc = toolChunk.choices[0].delta.tool_calls;
assert.equal(tc[0].function.name, "get_weather");
assert.deepEqual(JSON.parse(tc[0].function.arguments), { location: "Tokyo" });
const lastLine = text.trim().split("\n").filter(Boolean).pop();
assert.equal(lastLine, "data: [DONE]");
} finally {
m.restore();
}
});
test("Tools regression: no-tools request still streams plain content with finish_reason stop (#5240)", async () => {
__resetChatGptWebCachesForTesting();
const m = installMockFetch(convWithAssistantText("Just plain text, no tools."));
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute(
baseOpts({
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
}) as any
);
const text = await result.response.text();
const chunks = text
.split("\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)));
assert.ok(
chunks.some(
(c) => c.choices[0].delta && c.choices[0].delta.content === "Just plain text, no tools."
),
"plain content is streamed"
);
assert.ok(
chunks.every((c) => !(c.choices[0].delta && c.choices[0].delta.tool_calls)),
"no tool_calls emitted without a tools array"
);
const finishChunk = chunks.find((c) => c.choices[0].finish_reason);
assert.equal(finishChunk.choices[0].finish_reason, "stop");
} finally {
m.restore();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const combo = await import("../../open-sse/services/combo.ts");
const providerModels = await import("../../open-sse/config/providerModels.ts");
@@ -63,6 +64,33 @@ test("expandAutoComboCandidatePool adds every model of an active provider when n
}
});
test("expandAutoComboCandidatePool excludes restored retired ChatGPT Web connections", async () => {
const db = core.getDbInstance();
db.exec(`
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_insert;
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_update;
`);
for (const provider of ["chatgpt-web", "cgpt-web"]) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, api_key, is_active, test_status, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, ?, 1, 'active', datetime('now'), datetime('now'))"
).run(
`${provider}-restored-expansion`,
provider,
`${provider} restored expansion`,
`sk-${provider}-restored-expansion`
);
await modelsDb.addCustomModel(provider, "gpt-5.5", "Retired model fixture");
}
const expanded = await combo.expandAutoComboCandidatePool([], { config: {} });
assert.equal(
expanded.some((target) => ["chatgpt-web", "cgpt-web"].includes(target.provider)),
false
);
});
test("expandAutoComboCandidatePool is a no-op when an explicit candidatePool exists", async () => {
await providersDb.createProviderConnection({
provider: "openai",

View File

@@ -1,9 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
classifyProviderError,
PROVIDER_ERROR_TYPES,
} from "../../open-sse/services/errorClassifier.ts";
import { classifyProviderError } from "../../open-sse/services/errorClassifier.ts";
// #6315 / #6345 — a single generic upstream 403 on a no-credential ("authType:
// none") provider was permanently banning the whole
@@ -12,7 +9,6 @@ import {
// 403 should be RECOVERABLE (null) and handled by the existing connection
// cooldown/retry layer, same as apikey providers already are.
test("#6345: theoldllm 'Request blocked'/access_denied 403 -> recoverable (null), not FORBIDDEN", () => {
const body = { error: "Request blocked", type: "access_denied" };
assert.equal(classifyProviderError(403, body, "theoldllm"), null);
@@ -21,31 +17,3 @@ test("#6345: theoldllm 'Request blocked'/access_denied 403 -> recoverable (null)
test("control: apikey-provider bare 403 still recoverable (null) — no regression", () => {
assert.equal(classifyProviderError(403, "forbidden", "openai"), null);
});
test("#8813: chatgpt-web SENTINEL_BLOCKED 403 with 'Sentinel/Turnstile required' → FORBIDDEN (terminal)", () => {
// The executor returns error code "SENTINEL_BLOCKED" in the JSON body.
// This is a TERMINAL state — retrying the same blocked session will keep 403ing.
// Must be classified as FORBIDDEN so the circuit breaker marks the connection
// as banned and combo routing falls back to other providers.
const body = JSON.stringify({
error: {
message:
"ChatGPT blocked the request (Sentinel/Turnstile required). Try again later or open chatgpt.com in a browser to refresh state.",
type: "upstream_error",
code: "SENTINEL_BLOCKED",
},
});
assert.equal(
classifyProviderError(403, body, "chatgpt-web"),
PROVIDER_ERROR_TYPES.FORBIDDEN
);
});
test("#8813: chatgpt-web SENTINEL_BLOCKED 403 with raw 'Sentinel blocked' text → FORBIDDEN (terminal)", () => {
// Edge case: when the raw text includes "Sentinel" and 403, classify as terminal.
assert.equal(
classifyProviderError(403, "Sentinel blocked the request", "chatgpt-web"),
PROVIDER_ERROR_TYPES.FORBIDDEN
);
});

View File

@@ -44,7 +44,6 @@ const NOAUTH_IDS = Object.keys(NOAUTH_PROVIDERS) as NoauthId[];
* not to actually authenticate.
*/
const FAKE_CREDS: Record<string, string> = {
"chatgpt-web": "__Secure-next-auth.session-token=fake-audit-sweep",
"grok-web": "sso=fake-audit-sweep",
"gemini-web": "__Secure-1PSID=fake-audit-sweep",
"perplexity-web": "__Secure-next-auth.session-token=fake-audit-sweep",

View File

@@ -8,7 +8,7 @@ const PRICING = { input: 1, output: 2 };
const TOKENS = { input: 1_000_000, output: 1_000_000 };
test("isFlatRateProvider: cookie-web providers are flat-rate", () => {
for (const id of ["chatgpt-web", "grok-web", "gemini-web", "claude-web", "kimi-web"]) {
for (const id of ["grok-web", "gemini-web", "claude-web", "kimi-web"]) {
assert.equal(isFlatRateProvider(id), true, `${id} should be flat-rate`);
}
});
@@ -32,10 +32,15 @@ test("isFlatRateProvider: dedicated subscription / coding-plan providers are fla
});
test("isFlatRateProvider: case-insensitive + trimmed", () => {
assert.equal(isFlatRateProvider(" CHATGPT-WEB "), true);
assert.equal(isFlatRateProvider(" GROK-WEB "), true);
assert.equal(isFlatRateProvider("MINIMAX"), true);
});
test("isFlatRateProvider: retired common ChatGPT Web ids are no longer active billing lanes", () => {
assert.equal(isFlatRateProvider("chatgpt-web"), false);
assert.equal(isFlatRateProvider("cgpt-web"), false);
});
test("isFlatRateProvider: metered / cost-tracked providers are NOT flat-rate (no hidden cost)", () => {
// codex/cx = OmniRoute actively tracks Codex token cost (Fast-tier multipliers,
// GPT-5.x pricing) and Codex can be a metered account; byteplus = metered ModelArk;
@@ -65,7 +70,7 @@ test("isFlatRateProvider: empty / nullish is not flat-rate", () => {
test("computeCostFromPricing: flat-rate provider with flatRateAsZero → $0", () => {
assert.equal(
computeCostFromPricing(PRICING, TOKENS, { provider: "chatgpt-web", flatRateAsZero: true }),
computeCostFromPricing(PRICING, TOKENS, { provider: "grok-web", flatRateAsZero: true }),
0
);
assert.equal(
@@ -81,7 +86,7 @@ test("computeCostFromPricing: flat-rate provider with flatRateAsZero → $0", ()
test("computeCostFromPricing: opt-in only — flat-rate provider WITHOUT the flag still estimates", () => {
// Proves the guard never silently changes budget/routing/per-request paths.
assert.equal(computeCostFromPricing(PRICING, TOKENS, { provider: "chatgpt-web" }), 3);
assert.equal(computeCostFromPricing(PRICING, TOKENS, { provider: "grok-web" }), 3);
});
test("#11149: opencode-go is a flat-rate subscription, not metered", () => {

View File

@@ -1,9 +1,8 @@
// Repro probe for issue #7676:
// gemini-web executor never reads back the live Playwright cookie jar after a
// successful run, so rotated __Secure-1PSIDTS / __Secure-1PSIDCC values are
// never persisted via onCredentialsRefreshed — unlike chatgpt-web.ts, which
// already forwards its rotated cookie through the same callback
// (open-sse/executors/chatgpt-web.ts:2843).
// never persisted via onCredentialsRefreshed, despite the shared rotating-session
// callback contract used by web-session executors.
import test from "node:test";
import assert from "node:assert/strict";
@@ -28,7 +27,10 @@ test("#7676: GeminiWebExecutor persists rotated __Secure-1PSIDTS/__Secure-1PSIDC
addCookies: async () => {},
cookies: async () => rotatedJarCookies,
newPage: async () => ({
on: (event: string, handler: (resp: { url: () => string; text: () => Promise<string> }) => void) => {
on: (
event: string,
handler: (resp: { url: () => string; text: () => Promise<string> }) => void
) => {
if (event === "response") {
const body =
")]}'\n" +

View File

@@ -58,7 +58,6 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
"open-sse/handlers/chatCore/cliproxyModelMapping.ts": 1,
"open-sse/handlers/chatCore/cliproxyapiCredentials.ts": 1,
"open-sse/handlers/imageGeneration.ts": 1,
"open-sse/handlers/imageGeneration/providers/chatgptWeb.ts": 1,
"open-sse/handlers/imageGeneration/providers/geminiWeb.ts": 1,
"open-sse/handlers/videoGeneration.ts": 1,
"open-sse/services/compression/eval/executorModelClient.ts": 1,
@@ -171,7 +170,6 @@ const CLASSIFICATION: Record<InventoryKind, Record<string, BypassClass>> = {
"open-sse/handlers/chatCore/cliproxyModelMapping.ts": "A",
"open-sse/handlers/chatCore/cliproxyapiCredentials.ts": "A",
"open-sse/handlers/imageGeneration.ts": "B",
"open-sse/handlers/imageGeneration/providers/chatgptWeb.ts": "B",
"open-sse/handlers/imageGeneration/providers/geminiWeb.ts": "B",
"open-sse/handlers/videoGeneration.ts": "B",
"open-sse/services/compression/eval/executorModelClient.ts": "B",

View File

@@ -15,6 +15,8 @@ const settingsDb = await import("../../src/lib/db/settings.ts");
const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts");
const providerImageRoute =
await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts");
const providerChatRoute =
await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts");
const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
@@ -137,6 +139,67 @@ test("image routes expose CORS preflight handlers", async () => {
}
});
test("v1 image routes fail closed for retired common ChatGPT Web ids without network", async () => {
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
throw new Error("Retired image providers must not reach the network");
};
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const generationResponse = await imageRoute.POST(
new Request("http://localhost/api/v1/images/generations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: `${provider}/gpt-5.5`, prompt: "draw a lighthouse" }),
})
);
const generationBody = (await generationResponse.json()) as ErrorResponseBody;
assert.equal(generationResponse.status, 410);
assert.equal(generationBody.error.code, "PROVIDER_RETIRED");
assert.equal(generationBody.error.message, "Provider is retired and unavailable.");
const editResponse = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
body: createCodexEditForm("make it brighter", { model: `${provider}/gpt-5.5` }),
})
);
const editBody = (await editResponse.json()) as ErrorResponseBody;
assert.equal(editResponse.status, 410);
assert.equal(editBody.error.code, "PROVIDER_RETIRED");
assert.equal(editBody.error.message, "Provider is retired and unavailable.");
const providerImageResponse = await providerImageRoute.POST(
new Request(`http://localhost/api/v1/providers/${provider}/images/generations`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-5.5", prompt: "draw a lighthouse" }),
}),
{ params: Promise.resolve({ provider }) }
);
const providerImageBody = (await providerImageResponse.json()) as ErrorResponseBody;
assert.equal(providerImageResponse.status, 410);
assert.equal(providerImageBody.error.code, "PROVIDER_RETIRED");
assert.equal(providerImageBody.error.message, "Provider is retired and unavailable.");
const providerChatResponse = await providerChatRoute.POST(
new Request(`http://localhost/api/v1/providers/${provider}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-5.5", messages: [{ role: "user", content: "hi" }] }),
}),
{ params: Promise.resolve({ provider }) }
);
const providerChatBody = (await providerChatResponse.json()) as ErrorResponseBody;
assert.equal(providerChatResponse.status, 410);
assert.equal(providerChatBody.error.code, "PROVIDER_RETIRED");
assert.equal(providerChatBody.error.message, "Provider is retired and unavailable.");
}
assert.equal(fetchCalls, 0);
});
test("v1 image models GET exposes image-only modalities for credential-backed image-only models", async () => {
await seedConnection("topaz", { apiKey: "topaz-key" });
await seedConnection("stability-ai", { apiKey: "stability-key" });
@@ -251,7 +314,7 @@ test("v1 image edit POST enforces disabled API key policy", async () => {
const formData = new FormData();
formData.set("prompt", "make the background lighter");
formData.set("model", "cgpt-web/gpt-5.5");
formData.set("model", "openai/gpt-image-2");
formData.set("image", new File([new Uint8Array([1, 2, 3])], "source.png", { type: "image/png" }));
const response = await imageEditRoute.POST(
@@ -267,6 +330,33 @@ test("v1 image edit POST enforces disabled API key policy", async () => {
assert.match(body.error.message, /disabled/);
});
test("v1 image edit retirement takes precedence over API key policy", async () => {
const createdKey = await apiKeysDb.createApiKey("Disabled retired image key", "retired-edit");
await apiKeysDb.updateApiKeyPermissions(createdKey.id, { isActive: false });
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
throw new Error("Retired image providers must not reach the network");
};
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { Authorization: `Bearer ${createdKey.key}` },
body: createCodexEditForm("make it brighter", { model: `${provider}/gpt-5.5` }),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 410);
assert.equal(body.error.code, "PROVIDER_RETIRED");
assert.equal(body.error.message, "Provider is retired and unavailable.");
}
assert.equal(fetchCalls, 0);
});
test("v1 image edit POST guards multipart prompts after parsing", async () => {
const originalEnabled = process.env.INPUT_SANITIZER_ENABLED;
const originalMode = process.env.INPUT_SANITIZER_MODE;
@@ -442,7 +532,7 @@ test("v1 image edit POST rejects excessive or malformed Codex reference sets", a
test("v1 image edit POST keeps non-Codex providers single-reference", async () => {
const formData = new FormData();
formData.set("model", "cgpt-web/gpt-5.5");
formData.set("model", "openai/gpt-image-2");
formData.set("prompt", "combine these references");
formData.set("image", new File([VALID_PNG_BYTES], "reference-1.png", { type: "image/png" }));
formData.append("image[]", new File([VALID_PNG_BYTES], "reference-2.png", { type: "image/png" }));

View File

@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts";
test("ChatGPT Web image catalog exposes GPT-5.5 Instant instead of GPT-5.3 Instant", () => {
assert.deepEqual(IMAGE_PROVIDERS["chatgpt-web"].models, [
{ id: "gpt-5.5", name: "GPT-5.5 Instant (ChatGPT Web Image)" },
]);
test("retired common ChatGPT Web models stay absent from the image catalog and bare scan", () => {
assert.equal(IMAGE_PROVIDERS["chatgpt-web"], undefined);
assert.deepEqual(parseImageModel("cgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
provider: null,
model: "cgpt-web/gpt-5.5",
});
assert.deepEqual(parseImageModel("gpt-5.5"), {
provider: null,
model: "gpt-5.5",
});
});

View File

@@ -5,7 +5,7 @@
* - `/v1/images/generations` resolved built-in ids and `prefix/model` custom ids but NOT
* a bare combo/alias name (`model: "image"`) — it fell through to "Invalid image model".
* - `/v1/images/edits` resolved a base URL only for `/images/generations` and rejected any
* non-chatgpt-web provider, so custom OpenAI-compatible providers could not edit, and
* non-default web provider, so custom OpenAI-compatible providers could not edit, and
* JSON/data-URL edit clients got "Invalid multipart body".
*/
import test from "node:test";
@@ -188,7 +188,15 @@ test("resolveImageRouteModel keeps codex bare aliases over same-name combos", as
assert.equal(await resolveImageRouteModel("gpt-5.6-sol"), "gpt-5.6-sol");
});
test("resolveImageRouteModel leaves built-in / already-resolved ids untouched", async () => {
assert.equal(await resolveImageRouteModel("cgpt-web/gpt-5.5"), "cgpt-web/gpt-5.5");
test("resolveImageRouteModel rejects retired common ChatGPT Web ids before prefix remapping", async () => {
await assert.rejects(resolveImageRouteModel("chatgpt-web/gpt-5.5"), {
code: "PROVIDER_RETIRED",
status: 410,
});
await assert.rejects(resolveImageRouteModel("cgpt-web/gpt-5.5"), {
code: "PROVIDER_RETIRED",
status: 410,
});
assert.equal(await resolveImageRouteModel("codex/gpt-5.6-sol"), "codex/gpt-5.6-sol");
assert.equal(await resolveSingleImageComboTarget("definitely-not-a-combo-3215"), null);
});

View File

@@ -46,8 +46,13 @@ describe("resolveKeepaliveThreshold", () => {
assert.equal(resolveKeepaliveThreshold("opencode-zen/gpt-4"), 15000);
});
it("returns 15000ms for web-session provider (chatgpt-web)", () => {
assert.equal(resolveKeepaliveThreshold("chatgpt-web/gpt-5"), 15000);
it("uses the default for retired common ChatGPT Web ids", () => {
assert.equal(resolveKeepaliveThreshold("chatgpt-web/gpt-5"), 2000);
assert.equal(resolveKeepaliveThreshold("cgpt-web/gpt-5"), 2000);
});
it("keeps the longer threshold for ChatGPT Web Codex", () => {
assert.equal(resolveKeepaliveThreshold("chatgpt-web-codex/high"), 15000);
});
it("returns 15000ms for web-session provider (grok-web)", () => {
@@ -62,7 +67,8 @@ describe("resolveKeepaliveThreshold", () => {
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("pollinations"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("pol"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("opencode-zen"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("chatgpt-web"));
assert.ok(!SLOW_KEEPALIVE_PROVIDERS.has("chatgpt-web"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("chatgpt-web-codex"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("grok-web"));
assert.ok(SLOW_KEEPALIVE_PROVIDERS.has("claude-web"));
});

View File

@@ -202,45 +202,3 @@ 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

@@ -81,19 +81,19 @@ test("getMcpModelsCatalog exposes codex default thinking effort when no override
test("getMcpModelsCatalog exposes stored thinking effort overrides", async () => {
const result = await getMcpModelsCatalog(
{ provider: "chatgpt-web" },
{ provider: "gemini-web" },
{
listProviderConnections: async () => [
{
id: "conn-chatgpt",
provider: "chatgpt-web",
id: "conn-gemini-web",
provider: "gemini-web",
isActive: true,
providerSpecificData: { thinkingEffort: "extended" },
},
],
fetchJson: async () => ({
source: "api",
models: [{ id: "gpt-5", owned_by: "chatgpt-web", supportedEndpoints: ["chat"] }],
models: [{ id: "gemini-3.1-pro", owned_by: "gemini-web", supportedEndpoints: ["chat"] }],
}),
}
);
@@ -132,7 +132,9 @@ test("getMcpModelsCatalog returns empty result when requested provider has no ac
const result = await getMcpModelsCatalog(
{ provider: "github" },
{
listProviderConnections: async () => [{ id: "conn-codex", provider: "codex", isActive: true }],
listProviderConnections: async () => [
{ id: "conn-codex", provider: "codex", isActive: true },
],
fetchJson: async () => {
throw new Error("fetchJson should not be called without a matching active provider");
},
@@ -144,4 +146,4 @@ test("getMcpModelsCatalog returns empty result when requested provider has no ac
source: "provider_connections",
warning: "No active connections found for provider 'github'.",
});
});
});

View File

@@ -9,6 +9,9 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelAliasesDb = await import("../../src/lib/db/models/aliases.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const modelAliasResolver = await import("../../src/lib/modelAliasResolver.ts");
const { POST } = await import("../../src/app/api/v1/messages/count_tokens/route.ts");
type CountTokensResponse = {
@@ -18,6 +21,10 @@ type CountTokensResponse = {
model?: string;
};
type CountTokensErrorResponse = {
error: { code?: string; message?: string };
};
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -38,6 +45,7 @@ async function seedConnection(provider, overrides = {}) {
test.beforeEach(async () => {
await resetStorage();
modelAliasResolver.invalidateAliasCache();
});
test.after(async () => {
@@ -108,6 +116,48 @@ test("messages/count_tokens falls back to estimate when model is missing", async
assert.equal(body.source, "local");
});
test("messages/count_tokens does not mask retired ChatGPT Web models as a local estimate", async () => {
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
throw new Error("Retired count_tokens requests must not reach the network");
};
try {
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const alias = `count-via-${provider}`;
await modelAliasesDb.setModelAlias(alias, `${provider}/gpt-5.5`);
await settingsDb.updateSettings({
wildcardAliases: [
{ pattern: `count-wildcard-${provider}-*`, target: `${provider}/gpt-5.5` },
],
});
modelAliasResolver.invalidateAliasCache();
for (const model of [`${provider}/gpt-5.5`, alias, `count-wildcard-${provider}-model`]) {
const response = await POST(
new Request("http://localhost/api/v1/messages/count_tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: "Count these tokens" }],
}),
})
);
const body = (await response.json()) as CountTokensErrorResponse;
assert.equal(response.status, 410);
assert.equal(body.error.code, "PROVIDER_RETIRED");
assert.equal(body.error.message, "Provider is retired and unavailable.");
}
}
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("count_tokens fallback uses exact tiktoken count with source=local", async () => {
const req = new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",

View File

@@ -0,0 +1,531 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatgpt-web-retirement-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const RETIRED_PROVIDER_IDS = ["chatgpt-web", "cgpt-web"] as const;
const CONTROL_PROVIDER = "chatgpt-web-codex";
type ConnectionState = {
id: string;
is_active: number;
test_status: string;
error_code: string;
last_error: string;
last_error_type: string;
last_error_source: string;
last_error_at: string;
updated_at: string;
};
type LeaseState = {
id: number;
generation: number;
state: string;
ended_at: string | null;
end_reason: string | null;
};
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("migration 163 retires every common ChatGPT Web id fail-closed and preserves audit history", async () => {
const db = core.getDbInstance();
const applied = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = 163")
.get() as { version: number } | undefined;
assert.ok(applied, "migration 163 must be recorded as applied");
// Recreate a pre-migration fixture even though a fresh test database already
// applied migration 163 during startup.
db.exec(`
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_insert;
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_update;
DROP TRIGGER IF EXISTS provider_connections_preserve_chatgpt_web_identity_insert;
DROP TRIGGER IF EXISTS provider_connections_preserve_chatgpt_web_identity_update;
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_chatgpt_web_insert;
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_chatgpt_web_update;
`);
// The domain module reconciles API-key policy columns on a fresh database.
// Production upgrades already carry these columns from normal API-key use.
await apiKeysDb.getApiKeys();
for (const provider of [...RETIRED_PROVIDER_IDS, CONTROL_PROVIDER]) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, datetime('now'), datetime('now'))"
).run(`${provider}-connection`, provider, `${provider}-fixture`);
}
for (const provider of RETIRED_PROVIDER_IDS) {
db.prepare(
"UPDATE provider_connections SET test_status = 'active', last_error = 'legacy error', " +
"last_error_type = 'legacy', last_error_source = 'legacy:test', " +
"last_error_at = '2000-01-01T00:00:00.000Z', updated_at = '2000-01-01T00:00:00.000Z' " +
"WHERE provider = ?"
).run(provider);
}
const normalizedProviderVariants = [
{ id: "mixed-case-chatgpt-web-connection", provider: " ChAtGpT-WeB " },
{ id: "mixed-case-cgpt-web-alias-connection", provider: "\tCGPT-WEB\n" },
{ id: "nbsp-chatgpt-web-connection", provider: "\u00a0CHATGPT-WEB\uFEFF" },
{ id: "em-space-cgpt-web-alias-connection", provider: "\u2003cgpt-web\u2029" },
{ id: "ideographic-chatgpt-web-connection", provider: "\u3000CHATGPT-WEB\u3000" },
] as const;
for (const { id, provider } of normalizedProviderVariants) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, last_error, " +
"last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, 'active', 'legacy error', 'legacy', " +
"'legacy:test', '2000-01-01T00:00:00.000Z', datetime('now'), " +
"'2000-01-01T00:00:00.000Z')"
).run(id, provider, `${id}-fixture`);
}
const retiredConnectionIds = RETIRED_PROVIDER_IDS.map((provider) => `${provider}-connection`);
db.prepare(
"INSERT INTO api_keys " +
"(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
"VALUES ('restricted-key', 'restricted-key', 'restricted-secret', " +
"'restricted-hash', 'restrict', ?, 1, datetime('now'))"
).run(JSON.stringify(retiredConnectionIds));
const mixedConnectionIds = [...retiredConnectionIds, `${CONTROL_PROVIDER}-connection`];
const mixedAllowedConnectionsRaw =
' [ "chatgpt-web-connection" , "cgpt-web-connection" , "chatgpt-web-codex-connection" ] ';
db.prepare(
"INSERT INTO api_keys " +
"(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
"VALUES ('mixed-key', 'mixed-key', 'mixed-secret', " +
"'mixed-hash', 'mixed', ?, 1, datetime('now'))"
).run(mixedAllowedConnectionsRaw);
const leaseIds = new Map<string, number>();
const staleLeaseEndedAt = "2000-01-01T00:00:00.000Z";
for (const provider of RETIRED_PROVIDER_IDS) {
const connectionId = `${provider}-connection`;
const leaseProvider = provider === "chatgpt-web" ? "legacy-imported-provider" : provider;
const insertedLease = db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, 'restricted-key', ?, ?, 7, " +
"'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run(provider.padEnd(64, "0"), leaseProvider, connectionId);
leaseIds.set(provider, Number(insertedLease.lastInsertRowid));
if (provider === "chatgpt-web") {
db.prepare("UPDATE exclusive_connection_leases SET ended_at = ? WHERE id = ?").run(
staleLeaseEndedAt,
Number(insertedLease.lastInsertRowid)
);
}
db.prepare(
"INSERT INTO usage_history (provider, model, timestamp) " +
"VALUES (?, 'gpt-5.5', datetime('now'))"
).run(provider);
db.prepare(
"INSERT INTO call_logs (id, timestamp, provider, model, status) " +
"VALUES (?, datetime('now'), ?, 'gpt-5.5', 200)"
).run(`${provider}-call`, provider);
db.prepare(
"INSERT INTO quota_snapshots " +
"(provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) " +
"VALUES (?, ?, 'monthly', 50, 0, ?)"
).run(provider, connectionId, new Date().toISOString());
}
const controlLeaseId = Number(
db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, 'mixed-key', ?, ?, 11, " +
"'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run("chatgpt-web-codex".padEnd(64, "0"), CONTROL_PROVIDER, `${CONTROL_PROVIDER}-connection`)
.lastInsertRowid
);
const readConnection = (provider: string) =>
db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, updated_at FROM provider_connections " +
"WHERE provider = ?"
)
.get(provider) as ConnectionState;
const readConnectionById = (id: string) =>
db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, updated_at FROM provider_connections " +
"WHERE id = ?"
)
.get(id) as ConnectionState;
const readLease = (id: number) =>
db
.prepare(
"SELECT id, generation, state, ended_at, end_reason FROM exclusive_connection_leases " +
"WHERE id = ?"
)
.get(id) as LeaseState;
const readTotalChanges = () =>
(db.prepare("SELECT total_changes() AS changes").get() as { changes: number }).changes;
const sql = fs.readFileSync(
path.join(process.cwd(), "src/lib/db/migrations/163_retire_chatgpt_web.sql"),
"utf8"
);
db.exec(sql);
const firstConnections = new Map(
RETIRED_PROVIDER_IDS.map((provider) => [provider, readConnection(provider)])
);
const firstLeases = new Map(
RETIRED_PROVIDER_IDS.map((provider) => [provider, readLease(leaseIds.get(provider)!)])
);
const changesBeforeSecondExecution = readTotalChanges();
db.exec(sql);
assert.equal(
readTotalChanges() - changesBeforeSecondExecution,
0,
"a second execution must not rewrite any retired connection or lease row"
);
for (const provider of RETIRED_PROVIDER_IDS) {
const connection = firstConnections.get(provider)!;
const lease = firstLeases.get(provider)!;
assert.deepEqual(readConnection(provider), connection, "timestamps must remain stable");
assert.deepEqual(
readLease(leaseIds.get(provider)!),
lease,
"the invalidated lease must remain stable"
);
assert.equal(connection.id, `${provider}-connection`);
assert.equal(connection.is_active, 0);
assert.equal(connection.test_status, "unavailable");
assert.equal(connection.error_code, "PROVIDER_REMOVED");
assert.equal(connection.last_error, "Provider integration retired from OmniRoute v3.8.50");
assert.equal(connection.last_error_type, "provider_removed");
assert.equal(connection.last_error_source, "migration:retire-chatgpt-web");
assert.notEqual(connection.last_error_at, "2000-01-01T00:00:00.000Z");
assert.notEqual(connection.updated_at, "2000-01-01T00:00:00.000Z");
assert.equal(lease.id, leaseIds.get(provider));
assert.equal(lease.generation, 7);
assert.equal(lease.state, "INVALIDATED");
assert.ok(lease.ended_at);
if (provider === "chatgpt-web") {
assert.notEqual(
lease.ended_at,
staleLeaseEndedAt,
"the retirement event must replace a stale restored end timestamp"
);
}
assert.equal(lease.end_reason, "CONNECTION_INELIGIBLE");
assert.ok(db.prepare("SELECT id FROM usage_history WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM call_logs WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM quota_snapshots WHERE provider = ?").get(provider));
}
for (const { id } of normalizedProviderVariants) {
const connection = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
"FROM provider_connections WHERE id = ?"
)
.get(id) as {
is_active: number;
test_status: string;
error_code: string;
last_error_type: string;
last_error_source: string;
};
assert.deepEqual(connection, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_type: "provider_removed",
last_error_source: "migration:retire-chatgpt-web",
});
}
const control = db
.prepare("SELECT is_active FROM provider_connections WHERE id = 'chatgpt-web-codex-connection'")
.get() as { is_active: number };
assert.equal(
control.is_active,
1,
"the independent ChatGPT Web Codex provider must remain active"
);
assert.deepEqual(
readLease(controlLeaseId),
{
id: controlLeaseId,
generation: 11,
state: "ACTIVE",
ended_at: null,
end_reason: null,
},
"an unrelated active lease must not be invalidated"
);
const apiKey = db
.prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'restricted-key'")
.get() as { is_active: number; allowed_connections: string };
assert.equal(apiKey.is_active, 1);
assert.deepEqual(
JSON.parse(apiKey.allowed_connections),
retiredConnectionIds,
"an allowlist containing only common ChatGPT Web ids must remain non-empty and fail closed"
);
const mixedApiKey = db
.prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'mixed-key'")
.get() as { is_active: number; allowed_connections: string };
assert.equal(mixedApiKey.is_active, 1);
assert.equal(
mixedApiKey.allowed_connections,
mixedAllowedConnectionsRaw,
"the migration must preserve a mixed allowlist byte-for-byte"
);
assert.deepEqual(
JSON.parse(mixedApiKey.allowed_connections),
mixedConnectionIds,
"a mixed allowlist must preserve both retired ids and its unrelated connection"
);
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-cgpt-web', 'chatgpt-web', 'apikey', 'post migration import', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationConnection = db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, updated_at FROM provider_connections " +
"WHERE id = 'post-migration-cgpt-web'"
)
.get() as ConnectionState;
assert.equal(postMigrationConnection.is_active, 0);
assert.equal(postMigrationConnection.test_status, "unavailable");
assert.equal(postMigrationConnection.error_code, "PROVIDER_REMOVED");
assert.equal(postMigrationConnection.last_error_type, "provider_removed");
assert.equal(postMigrationConnection.last_error_source, "migration:retire-chatgpt-web");
db.prepare(
"INSERT OR REPLACE INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-replace-cgpt-web', '\fCGPT-WEB\r', 'apikey', 'replace import', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationReplace = readConnectionById("post-migration-replace-cgpt-web");
assert.equal(postMigrationReplace.is_active, 0);
assert.equal(postMigrationReplace.test_status, "unavailable");
assert.equal(postMigrationReplace.error_code, "PROVIDER_REMOVED");
assert.equal(postMigrationReplace.last_error_source, "migration:retire-chatgpt-web");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-cgpt-web-alias', ' CGPT-WEB ', 'apikey', 'post migration alias', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationAlias = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_source " +
"FROM provider_connections WHERE id = 'post-migration-cgpt-web-alias'"
)
.get() as {
is_active: number;
test_status: string;
error_code: string;
last_error_source: string;
};
assert.deepEqual(postMigrationAlias, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_source: "migration:retire-chatgpt-web",
});
const insertActiveLease = (owner: string, provider: string, connectionId: string) =>
Number(
db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, 1, 'ACTIVE', " +
"datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run(owner.padEnd(64, "0"), `${owner}-key`, provider, connectionId).lastInsertRowid
);
const alreadyTombstonedInsertLeaseId = insertActiveLease(
"already-tombstoned-cgpt-web-insert",
"legacy-imported-provider",
"already-tombstoned-cgpt-web-insert-connection"
);
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "ACTIVE");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, error_code, last_error, " +
"last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
"VALUES ('already-tombstoned-cgpt-web-insert-connection', '\u00a0chatgpt-web\uFEFF', " +
"'apikey', 'already tombstoned restore', 0, 'unavailable', 'PROVIDER_REMOVED', " +
"'Provider integration retired from OmniRoute v3.8.50', 'provider_removed', " +
"'migration:retire-chatgpt-web', '2001-01-01T00:00:00.000Z', datetime('now'), datetime('now'))"
).run();
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "INVALIDATED");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES ('already-tombstoned-cgpt-web-update-connection', 'legacy-provider', 'apikey', " +
"'update to retired', 1, datetime('now'), datetime('now'))"
).run();
const alreadyTombstonedUpdateLeaseId = insertActiveLease(
"already-tombstoned-cgpt-web-update",
"legacy-imported-provider",
"already-tombstoned-cgpt-web-update-connection"
);
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "ACTIVE");
db.prepare(
"UPDATE provider_connections SET provider = '\u2003CGPT-WEB\u2029', is_active = 0, " +
"test_status = 'unavailable', error_code = 'PROVIDER_REMOVED', " +
"last_error = 'Provider integration retired from OmniRoute v3.8.50', " +
"last_error_type = 'provider_removed', last_error_source = 'migration:retire-chatgpt-web', " +
"last_error_at = '2001-01-01T00:00:00.000Z' " +
"WHERE id = 'already-tombstoned-cgpt-web-update-connection'"
).run();
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "INVALIDATED");
const directRetiredLeaseId = insertActiveLease(
"post-direct-cgpt-web",
" ChAtGpT-WeB ",
"direct-retired-cgpt-web-provider-connection"
);
assert.equal(readLease(directRetiredLeaseId).state, "INVALIDATED");
const retiredConnectionLeaseId = insertActiveLease(
"post-retired-cgpt-web-connection",
"legacy-imported-provider",
"post-migration-cgpt-web"
);
assert.equal(readLease(retiredConnectionLeaseId).state, "INVALIDATED");
const restoredBeforeConnectionLeaseId = insertActiveLease(
"restored-before-cgpt-web-connection",
"legacy-imported-provider",
"restored-chatgpt-web-connection"
);
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "ACTIVE");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES ('restored-chatgpt-web-connection', 'chatgpt-web', 'apikey', " +
"'restored after lease', 1, datetime('now'), datetime('now'))"
).run();
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "INVALIDATED");
const openCodeLeaseId = insertActiveLease(
"post-chatgpt-web-codex-control",
"chatgpt-web-codex",
"post-chatgpt-web-codex-control-connection"
);
assert.deepEqual(readLease(openCodeLeaseId), {
id: openCodeLeaseId,
generation: 1,
state: "ACTIVE",
ended_at: null,
end_reason: null,
});
db.prepare(
"UPDATE provider_connections SET provider = ' CgPt-WeB ', is_active = 1, test_status = 'active', " +
"error_code = NULL, last_error = NULL, last_error_type = NULL, " +
"last_error_source = NULL, last_error_at = NULL WHERE provider = 'cgpt-web'"
).run();
const updateProtectedConnection = readConnectionById("cgpt-web-connection");
assert.equal(updateProtectedConnection.is_active, 0);
assert.equal(updateProtectedConnection.test_status, "unavailable");
assert.equal(updateProtectedConnection.error_code, "PROVIDER_REMOVED");
assert.equal(updateProtectedConnection.last_error_type, "provider_removed");
assert.equal(updateProtectedConnection.last_error_source, "migration:retire-chatgpt-web");
assert.throws(
() =>
db
.prepare(
"UPDATE provider_connections SET provider = 'openai', is_active = 1, " +
"test_status = 'active', error_code = NULL WHERE id = 'chatgpt-web-connection'"
)
.run(),
/retired provider connection identity cannot be changed/i
);
const updateIdentityControl = db
.prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
.get("chatgpt-web-connection") as {
provider: string;
is_active: number;
error_code: string;
};
assert.deepEqual(updateIdentityControl, {
provider: "chatgpt-web",
is_active: 0,
error_code: "PROVIDER_REMOVED",
});
assert.throws(
() =>
db
.prepare(
"INSERT OR REPLACE INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('cgpt-web-connection', 'openai', 'apikey', 'identity replacement', 1, " +
"'active', datetime('now'), datetime('now'))"
)
.run(),
/retired provider connection identity cannot be changed/i
);
const replaceIdentityControl = db
.prepare("SELECT provider, is_active, error_code FROM provider_connections WHERE id = ?")
.get("cgpt-web-connection") as {
provider: string;
is_active: number;
error_code: string;
};
assert.deepEqual(replaceIdentityControl, {
provider: " CgPt-WeB ",
is_active: 0,
error_code: "PROVIDER_REMOVED",
});
db.prepare(
"UPDATE provider_connections SET name = 'renamed' WHERE id = 'cgpt-web-connection'"
).run();
const unrelatedUpdate = readConnectionById("cgpt-web-connection");
assert.equal(unrelatedUpdate.last_error_at, updateProtectedConnection.last_error_at);
assert.equal(unrelatedUpdate.updated_at, updateProtectedConnection.updated_at);
});

View File

@@ -33,10 +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("chatgpt-web"), false);
assert.equal(providerUsesCuratedModelsOnly("cgpt-web"), false);
assert.equal(providerUsesCuratedModelsOnly("qwen-cloud"), false);
assert.equal(providerUsesCuratedModelsOnly("kimi-coding"), false);
});

View File

@@ -4,16 +4,14 @@
// only for non-streaming requests (the `hasTools && !stream` gate). Streaming
// requests — the default for agentic coding clients — got the raw <tool> text
// as plain delta.content and never emitted a tool_calls SSE delta, so clients
// could not execute tools. These tests live in a dedicated file mirroring
// tests/unit/chatgpt-web-tools-5240.test.ts (the reference fix for chatgpt-web).
// could not execute tools. These tests pin the shared web-tool response contract.
import test from "node:test";
import assert from "node:assert/strict";
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
const { __setTlsFetchOverrideForTesting } = await import(
"../../open-sse/services/perplexityTlsClient.ts"
);
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/perplexityTlsClient.ts");
// ─── Helper: Build a mock SSE stream from Perplexity events ─────────────────

View File

@@ -6,7 +6,7 @@
// updateProviderConnectionSchema (edit connection)
//
// That `apiKey` field is reused as the raw `Cookie:` header value for cookie-
// based web providers (Gemini Business, Copilot M365, ChatGPT Web, Claude Web,
// based web providers (Gemini Business, Copilot M365, ChatGPT Web (Codex), Claude Web,
// …). Real multi-cookie session headers (many `__Secure-*` entries, large
// session tokens) legitimately exceed 10,000 chars. The provider's own
// `validate` schema (validateProviderApiKeySchema) has NO cap, so the cookie

View File

@@ -32,6 +32,11 @@ const { createProviderNodeSchema, updateProviderNodeSchema } =
await import("../../src/shared/validation/schemas.ts");
const { RESERVED_PROVIDER_PREFIXES, isReservedProviderPrefix, RESERVED_PREFIX_COUNT } =
await import("../../src/shared/constants/reservedProviderPrefixes.ts");
const { buildReservedPrefixes, getProviderPrefixIndex } =
await import("../../src/lib/providerNodePrefixes.ts");
const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
const { isCommonChatGptWebRetiredProviderId } =
await import("../../src/shared/constants/chatgptWebRetirement.ts");
async function resetStorage() {
core.resetDbInstance();
@@ -90,6 +95,41 @@ test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => {
assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true);
});
test("retired ChatGPT Web ids remain permanently reserved without capturing Codex variants", () => {
for (const prefix of ["chatgpt-web", "cgpt-web", " ChatGPT-Web ", "CGPT-WEB"]) {
assert.equal(isReservedProviderPrefix(prefix), true, `${prefix} must stay reserved`);
}
assert.equal(buildReservedPrefixes().has("chatgpt-web"), true);
assert.equal(buildReservedPrefixes().has("cgpt-web"), true);
for (const prefix of ["chatgpt-web-codex", "cgpt-codex"]) {
assert.equal(isReservedProviderPrefix(prefix), true, `${prefix} remains a live built-in`);
assert.equal(isCommonChatGptWebRetiredProviderId(prefix), false);
}
assert.equal(isReservedProviderPrefix("chatgpt-web-preview"), false);
assert.equal(isCommonChatGptWebRetiredProviderId("chatgpt-web-preview"), false);
});
test("mixed-case retired ChatGPT Web prefixes are never advertised as compatible nodes", async () => {
for (const [index, prefix] of ["ChatGPT-Web", "CGPT-WEB"].entries()) {
const id = `openai-compatible-retired-prefix-${index}`;
await providerNodesDb.createProviderNode({
id,
type: "openai-compatible",
name: `Retired mixed-case prefix ${index}`,
prefix,
apiType: "chat",
baseUrl: "https://retired.example.invalid/v1",
});
}
const index = await getProviderPrefixIndex();
for (const prefix of ["ChatGPT-Web", "CGPT-WEB"]) {
assert.equal(index.entries.get(prefix)?.status, "reserved");
assert.equal(index.prefixToNode.has(prefix), false);
}
});
test("shared set is case-sensitive like the runtime guard", () => {
assert.equal(isReservedProviderPrefix("TokenRouter"), false);
assert.equal(isReservedProviderPrefix("TOKENROUTER"), false);
@@ -148,6 +188,24 @@ test("createProviderNodeSchema rejects reserved alias 'trk'", () => {
assert.equal(result.success, false);
});
test("provider-node schemas reject both retired common ChatGPT Web prefixes", () => {
for (const prefix of ["chatgpt-web", "cgpt-web", "CHATGPT-WEB"]) {
const createResult = createProviderNodeSchema.safeParse({
name: "Retired provider shadow",
prefix,
apiType: "chat",
baseUrl: "https://example.invalid/v1",
});
assert.equal(createResult.success, false, `create accepted ${prefix}`);
const updateResult = updateProviderNodeSchema.safeParse({
name: "Retired provider shadow",
prefix,
});
assert.equal(updateResult.success, false, `update accepted ${prefix}`);
}
});
test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => {
const result = createProviderNodeSchema.safeParse({
name: "Case Test",

View File

@@ -15,7 +15,7 @@ test("token-kind cookie-auth web sessions use the API-key test path", () => {
});
test("cookie-kind web sessions do not use the API-key test path", () => {
assert.equal(shouldUseApiKeyConnectionTest("cookie", "chatgpt-web"), false);
assert.equal(shouldUseApiKeyConnectionTest("cookie", "perplexity-web"), false);
assert.equal(shouldUseApiKeyConnectionTest("cookie", "claude-web"), false);
});
@@ -36,7 +36,14 @@ test("token-kind web sessions WITHOUT a token-aware validator stay off the API-k
});
test("every token-kind web session with a real token-aware validator uses the API-key test path", () => {
for (const providerId of ["deepseek-web", "kimi-web", "tinycms-web", "copilot-m365-web", "copilot-web", "zai-web"]) {
for (const providerId of [
"deepseek-web",
"kimi-web",
"tinycms-web",
"copilot-m365-web",
"copilot-web",
"zai-web",
]) {
assert.equal(shouldUseApiKeyConnectionTest("cookie", providerId), true, providerId);
}
});

View File

@@ -834,164 +834,6 @@ test("grok-web validator: Cloudflare challenge page is detected and reported", a
assert.match(result.error || "", /Cloudflare anti-bot/i);
});
// ─── chatgpt-web validator ──────────────────────────────────────────────────
// Mocks the TLS-impersonating fetch so unit tests don't need the native binding.
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/chatgptTlsClient.ts");
function makeTlsResponse(status: number, body: string, headers: Record<string, string> = {}) {
const h = new Headers();
for (const [k, v] of Object.entries(headers)) h.set(k, v);
return { status, headers: h, text: body, body: null };
}
test.afterEach(() => {
__setTlsFetchOverrideForTesting(null);
});
test("chatgpt-web validator: accepts a valid session response with accessToken", async () => {
let captured: { url: string; opts: unknown } | null = null;
__setTlsFetchOverrideForTesting(async (url, opts) => {
captured = { url, opts };
return makeTlsResponse(
200,
JSON.stringify({ accessToken: "tok-abc", expires: "2030-01-01T00:00:00Z" }),
{ "content-type": "application/json" }
);
});
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "__Secure-next-auth.session-token=eyJSESSION",
});
assert.equal(result.valid, true);
assert.equal(captured?.url, "https://chatgpt.com/api/auth/session");
assert.equal(
(captured?.opts.headers as Record<string, string>).Cookie,
"__Secure-next-auth.session-token=eyJSESSION"
);
});
test("chatgpt-web validator: prepends session-token name to bare values", async () => {
let capturedCookie = "";
__setTlsFetchOverrideForTesting(async (_url, opts) => {
capturedCookie = (opts.headers as Record<string, string>).Cookie || "";
return makeTlsResponse(200, JSON.stringify({ accessToken: "tok" }), {
"content-type": "application/json",
});
});
await validateProviderApiKey({ provider: "chatgpt-web", apiKey: "eyJBARE" });
assert.equal(capturedCookie, "__Secure-next-auth.session-token=eyJBARE");
});
test("chatgpt-web validator: passes full DevTools cookie blob through verbatim", async () => {
let capturedCookie = "";
__setTlsFetchOverrideForTesting(async (_url, opts) => {
capturedCookie = (opts.headers as Record<string, string>).Cookie || "";
return makeTlsResponse(200, JSON.stringify({ accessToken: "tok" }), {
"content-type": "application/json",
});
});
const blob =
"Cookie: oai-did=foo; __Secure-next-auth.session-token.0=eyJchunk0; __Secure-next-auth.session-token.1=eyJchunk1; cf_clearance=cf123;";
await validateProviderApiKey({ provider: "chatgpt-web", apiKey: blob });
assert.equal(
capturedCookie,
"oai-did=foo; __Secure-next-auth.session-token.0=eyJchunk0; __Secure-next-auth.session-token.1=eyJchunk1; cf_clearance=cf123;"
);
});
test("chatgpt-web validator: 401 without cf-mitigated → invalid session cookie", async () => {
__setTlsFetchOverrideForTesting(async () =>
makeTlsResponse(401, JSON.stringify({ error: "unauthorized" }), {
"content-type": "application/json",
})
);
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "stale-token",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /Invalid ChatGPT session cookie/i);
});
test("chatgpt-web validator: 403 with cf-mitigated header → Cloudflare hint", async () => {
__setTlsFetchOverrideForTesting(async () =>
makeTlsResponse(403, "<html>Just a moment...</html>", {
"content-type": "text/html",
"cf-mitigated": "challenge",
})
);
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "good-but-no-cf-cookies",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /Cloudflare blocked the validator/i);
});
test("chatgpt-web validator: 200 without accessToken → session expired", async () => {
__setTlsFetchOverrideForTesting(async () =>
makeTlsResponse(200, JSON.stringify({}), { "content-type": "application/json" })
);
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "expired-token",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /session expired/i);
});
test("chatgpt-web validator: 5xx → ChatGPT unavailable", async () => {
__setTlsFetchOverrideForTesting(async () =>
makeTlsResponse(503, "service unavailable", { "content-type": "text/plain" })
);
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "any-token",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /ChatGPT unavailable \(503\)/);
});
test("chatgpt-web validator: 200 non-JSON content-type surfaces a cookie hint", async () => {
__setTlsFetchOverrideForTesting(async () =>
makeTlsResponse(200, "<html>blocked</html>", {
"content-type": "text/html",
"cf-ray": "ray-123",
})
);
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "any-token",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /non-JSON.*text\/html.*cf-ray=ray-123/i);
});
test("chatgpt-web validator: TlsClientUnavailableError surfaces a clear message", async () => {
const { TlsClientUnavailableError } = await import("../../open-sse/services/chatgptTlsClient.ts");
__setTlsFetchOverrideForTesting(async () => {
throw new TlsClientUnavailableError("native binding failed to load");
});
const result = await validateProviderApiKey({
provider: "chatgpt-web",
apiKey: "any-token",
});
assert.equal(result.valid, false);
assert.match(result.error || "", /chatgpt-web requires this/i);
});
test("search provider validators cover success, client errors, server errors and custom user agent injection", async () => {
const calls = [];
globalThis.fetch = async (url, init = {}) => {

View File

@@ -30,7 +30,7 @@ test("should_return_AUTH_007_when_models_endpoint_returns_401", async () => {
mockFetch(401, JSON.stringify({ error: "unauthorized" }));
const result = await validateWebCookieProvider({
provider: "chatgpt-web",
provider: "huggingchat",
apiKey: "expired_cookie=session=abc123",
providerSpecificData: {},
});
@@ -45,7 +45,7 @@ test("should_return_AUTH_007_when_models_endpoint_returns_403", async () => {
mockFetch(403, JSON.stringify({ error: "forbidden" }));
const result = await validateWebCookieProvider({
provider: "chatgpt-web",
provider: "huggingchat",
apiKey: "expired_cookie=session=abc123",
providerSpecificData: {},
});
@@ -57,8 +57,8 @@ test("should_return_AUTH_007_when_models_endpoint_returns_403", async () => {
});
test("should_return_unsupported_when_models_endpoint_returns_200_for_a_conversation_baseUrl", async () => {
// #7857: chatgpt-web's registry baseUrl is a conversation endpoint
// ("https://chatgpt.com/backend-api/conversation"), not a real API root, so
// #7857: huggingchat's registry baseUrl is a conversation endpoint,
// not a real API root, so
// "${baseUrl}/models" is a path that never existed upstream. A 200 from that
// nonsense path (e.g. a login-page SPA shell) is not a meaningful auth signal and
// is indistinguishable from a genuinely valid session — it must be reported as
@@ -66,7 +66,7 @@ test("should_return_unsupported_when_models_endpoint_returns_200_for_a_conversat
mockFetch(200, JSON.stringify({ ok: true, data: [] }));
const result = await validateWebCookieProvider({
provider: "chatgpt-web",
provider: "huggingchat",
apiKey: "valid_cookie=session=abc123",
providerSpecificData: {},
});
@@ -95,7 +95,7 @@ test("should_return_error_when_cookie_is_empty", async () => {
mockFetch(200, "{}");
const result = await validateWebCookieProvider({
provider: "chatgpt-web",
provider: "huggingchat",
apiKey: "",
providerSpecificData: {},
});

View File

@@ -108,7 +108,7 @@ describe("Radar guided setup provider action", () => {
});
it("keeps a subscription-risk provider behind the existing acknowledgement gate", async () => {
providerId = "chatgpt-web";
providerId = "grok-web";
const { container, root } = await renderProviderPage();
expect(container.querySelector('input[type="password"]')).toBeNull();

View File

@@ -10,10 +10,16 @@ import {
// Pure host-resolution helper backing the modal link.
test("known -web provider returns the host derived from its `website`", () => {
const link = resolveWebProviderHost("chatgpt-web");
assert.ok(link, "expected a resolved link for chatgpt-web");
assert.equal(link.host, "chatgpt.com");
assert.equal(link.url, "https://chatgpt.com");
const link = resolveWebProviderHost("perplexity-web");
assert.ok(link, "expected a resolved link for perplexity-web");
assert.equal(link.host, "perplexity.ai");
assert.equal(link.url, "https://www.perplexity.ai");
});
test("retired common ChatGPT Web ids no longer resolve provider links", () => {
assert.equal(resolveWebProviderHost("chatgpt-web"), null);
assert.equal(resolveWebProviderHost("cgpt-web"), null);
assert.equal(resolveWebProviderHost("chatgpt-web-codex")?.host, "chatgpt.com");
});
test("website with a path keeps the full URL but exposes the bare host", () => {
@@ -33,10 +39,7 @@ test("provider with no `website` but a registry baseUrl returns the origin", ()
undefined,
"test premise: duckduckgo-web must be absent from WEB_COOKIE_PROVIDERS"
);
const link = resolveWebProviderHost(
"duckduckgo-web",
"https://duckduckgo.com/duckchat/v1/chat"
);
const link = resolveWebProviderHost("duckduckgo-web", "https://duckduckgo.com/duckchat/v1/chat");
assert.ok(link);
assert.equal(link.host, "duckduckgo.com");
assert.equal(link.url, "https://duckduckgo.com");

View File

@@ -12,6 +12,9 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const modelAliasesDb = await import("../../src/lib/db/models/aliases.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const modelAliasResolver = await import("../../src/lib/modelAliasResolver.ts");
const route = await import("../../src/app/api/v1/session-leases/route.ts");
const OWNER_A = "vlo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
@@ -67,6 +70,7 @@ async function resetStorage(): Promise<void> {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
attemptedExternalCalls = 0;
modelAliasResolver.invalidateAliasCache();
}
test.before(() => {
@@ -114,6 +118,41 @@ test("requires authentication, managed scope, and canonical explicit owner", asy
assert.equal(attemptedExternalCalls, 0);
});
test("lease acquire preserves deterministic retirement errors for common ChatGPT Web ids", async () => {
const connection = await seedConnection(1);
const managed = await seedKey([connection.id]);
const unmanaged = await seedKey([connection.id], []);
const beforePolicy = await route.POST(
request(unmanaged.key, { action: "acquire", model: "chatgpt-web/gpt-5.5" }, OWNER_A)
);
assert.equal(beforePolicy.status, 410);
assert.equal(((await json(beforePolicy)).error as { code?: string }).code, "PROVIDER_RETIRED");
for (const provider of ["chatgpt-web", "cgpt-web"]) {
const alias = `lease-via-${provider}`;
await modelAliasesDb.setModelAlias(alias, `${provider}/gpt-5.5`);
await settingsDb.updateSettings({
wildcardAliases: [{ pattern: `lease-wildcard-${provider}-*`, target: `${provider}/gpt-5.5` }],
});
modelAliasResolver.invalidateAliasCache();
for (const model of [`${provider}/gpt-5.5`, alias, `lease-wildcard-${provider}-model`]) {
const response = await route.POST(
request(managed.key, { action: "acquire", model }, OWNER_A)
);
const body = await json(response);
assert.equal(response.status, 410);
assert.equal((body.error as { code?: string }).code, "PROVIDER_RETIRED");
assert.equal(
(body.error as { message?: string }).message,
"Provider is retired and unavailable."
);
}
}
assert.equal(attemptedExternalCalls, 0);
});
test("requires JSON mutation input after authenticating and exposes generic CORS headers", async () => {
const connection = await seedConnection(1);
const managed = await seedKey([connection.id]);

View File

@@ -11,7 +11,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const TLS_CLIENT_WRAPPERS = [
"open-sse/services/chatgptTlsClient.ts",
"open-sse/services/claudeTlsClient.ts",
"open-sse/services/grokTlsClient.ts",
"open-sse/services/perplexityTlsClient.ts",
@@ -52,7 +51,7 @@ test("buildNativeTlsClientOptions passes downloadDir to tls-client-node (#8579)"
assert.equal(options.downloadDir, join(dataDir, "tls-client", "bin"));
});
test("all web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => {
test("all remaining web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => {
const base = readFileSync(join(ROOT, "open-sse/services/tlsClientBase.ts"), "utf8");
assert.match(
base,

View File

@@ -42,7 +42,7 @@ test("Dockerfile's --ignore-scripts npm ci is compensated for tls-client-node's
"tls-client-node has no --ignore-scripts compensation in Dockerfile or " +
"scripts/build/postinstall.mjs (unlike better-sqlite3 and wreq-js) — " +
"node_modules/tls-client-node/bin/ is never populated in the official " +
"Docker image, so chatgpt-web/claude-web/grok-web/lmarena/perplexity-web " +
"Docker image, so claude-web/grok-web/lmarena/perplexity-web " +
"all fail with TlsClientUnavailableError at first request (#7802)"
);
});

View File

@@ -92,7 +92,7 @@ describe("tokenExtractionConfig", () => {
});
it("getExtractionConfig returns config for known providers", () => {
const providers = ["claude-web", "chatgpt-web", "gemini-web", "grok-web", "deepseek-web"];
const providers = ["claude-web", "perplexity-web", "gemini-web", "grok-web", "deepseek-web"];
for (const id of providers) {
const cfg = getExtractionConfig(id);
assert.ok(cfg !== undefined, `getExtractionConfig("${id}") returned undefined`);
@@ -100,6 +100,11 @@ describe("tokenExtractionConfig", () => {
}
});
it("does not expose in-app extraction for retired common ChatGPT Web ids", () => {
assert.equal(getExtractionConfig("chatgpt-web"), undefined);
assert.equal(getExtractionConfig("cgpt-web"), undefined);
});
it("captures Copilot's bearer authorization header instead of an unrelated cookie", () => {
const cfg = getExtractionConfig("copilot-web");
assert.deepEqual(cfg?.tokenSources, [{ type: "header", name: "Authorization" }]);

View File

@@ -2,7 +2,7 @@
//
// #5088 — When the inline credential "Check" fails, the modal showed only a bare
// "invalid" badge and threw away the detailed reason returned by
// /api/providers/validate. For claude-web/chatgpt-web the real cause is often an
// /api/providers/validate. For browser-session providers the real cause is often an
// environment error (e.g. "TLS impersonation client failed to start: EACCES …"),
// which the backend already surfaces in `data.error` — but the UI hid it, so the
// reporter had to dig it out via a separate Provider Test. The detailed message
@@ -32,7 +32,12 @@ function render(props: Record<string, unknown>) {
const root = createRoot(el);
act(() => {
root.render(
<AddApiKeyModal isOpen onSave={async () => undefined} onClose={() => {}} {...(props as any)} />
<AddApiKeyModal
isOpen
onSave={async () => undefined}
onClose={() => {}}
{...(props as any)}
/>
);
});
containers.push({ root, el });
@@ -68,7 +73,10 @@ beforeEach(() => {
json: () => Promise.resolve({ valid: false, error: TLS_EACCES_ERROR }),
} as Response);
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: true }),
} as Response);
})
);
});
@@ -92,8 +100,7 @@ describe("AddApiKeyModal — surfaces the detailed validation error (#5088)", ()
// The validate ("check") button is the first button that follows the
// credential input in DOM order (it sits right next to it).
const checkBtn = Array.from(el.querySelectorAll("button")).find(
(b) =>
(apiKeyInput.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
(b) => (apiKeyInput.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
)!;
expect(checkBtn).toBeTruthy();
act(() => {

View File

@@ -50,9 +50,9 @@ afterEach(() => {
describe("AddApiKeyModal — cookie modal sizing (#6265)", () => {
it("caps height on the OUTERMOST dialog wrapper, not on an inner body div", () => {
// chatgpt-web is a `kind: "cookie"` web-session provider — same shared modal
// perplexity-web is a `kind: "cookie"` web-session provider — same shared modal
// path lmarena/claude-web/gemini-web/kimi-web/z-ai all go through.
const el = render({ provider: "chatgpt-web", providerName: "ChatGPT (Web)" });
const el = render({ provider: "perplexity-web", providerName: "Perplexity Web" });
const dialog = el.querySelector<HTMLElement>('[role="dialog"]');
expect(dialog).toBeTruthy();

View File

@@ -1,34 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts";
test("#10848 bare id that only exists on a cookie-auth web bridge should not silently resolve to it", () => {
const chatgptWeb = IMAGE_PROVIDERS["chatgpt-web"];
assert.equal(chatgptWeb.authHeader, "cookie");
const otherProvidersWithSameId = Object.entries(IMAGE_PROVIDERS).filter(
([providerId, config]) =>
providerId !== "chatgpt-web" && config.models.some((m) => m.id === "gpt-5.5")
);
assert.deepEqual(
otherProvidersWithSameId,
[],
"expected only chatgpt-web (cookie) to register gpt-5.5"
);
const resolved = parseImageModel("gpt-5.5");
assert.notDeepEqual(
resolved,
{ provider: "chatgpt-web", model: "gpt-5.5" },
"bare 'gpt-5.5' must not silently bind to the cookie-auth chatgpt-web bridge"
);
assert.deepEqual(parseImageModel("chatgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
model: "gpt-5.5",
});
assert.deepEqual(parseImageModel("cgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
model: "gpt-5.5",
});
});

View File

@@ -1,10 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
findModelById,
handleGetModelById,
} from "@/app/api/v1/models/modelById";
import { findModelById, handleGetModelById } from "@/app/api/v1/models/modelById";
// #4674 — GET /v1/models/{model} previously had no route handler, so the request
// fell through to the Next.js catch-all and returned the HTML dashboard instead of
@@ -13,7 +10,7 @@ import {
const CATALOG = [
{ id: "claude/claude-sonnet-4-6", object: "model", owned_by: "claude" },
{ id: "cgpt-web/gpt-5.5", object: "model", owned_by: "chatgpt-web" },
{ id: "openai/gpt-5.4", object: "model", owned_by: "openai" },
{ id: "gpt-5", object: "model", owned_by: "openai" },
];
@@ -29,9 +26,9 @@ test("findModelById returns the exact-id match", () => {
});
test("findModelById handles provider-prefixed ids containing a slash", () => {
const found = findModelById(CATALOG, "cgpt-web/gpt-5.5");
const found = findModelById(CATALOG, "openai/gpt-5.4");
assert.ok(found);
assert.equal(found.id, "cgpt-web/gpt-5.5");
assert.equal(found.id, "openai/gpt-5.4");
});
test("findModelById returns null for an unknown model", () => {

View File

@@ -12,12 +12,12 @@ const B = await import("../../src/lib/providers/validation/webProvidersB.ts");
const meta = await import("../../src/lib/providers/validation/metaAi.ts");
const HOST = await import("../../src/lib/providers/validation.ts");
test("webProvidersA exposes its six validators (deepseek/qwen/grok/chatgpt/perplexity/blackbox)", () => {
test("webProvidersA exposes its six validators (kimi/deepseek/qwen/grok/perplexity/blackbox)", () => {
for (const name of [
"validateKimiWebProvider",
"validateDeepSeekWebProvider",
"validateQwenWebProvider",
"validateGrokWebProvider",
"validateChatGptWebProvider",
"validatePerplexityWebProvider",
"validateBlackboxWebProvider",
]) {

View File

@@ -120,21 +120,21 @@ test("createVirtualAutoCombo excludes web-session providers with empty required
test("createVirtualAutoCombo excludes web-session providers with irrelevant providerSpecificData", async () => {
await providersDb.createProviderConnection({
provider: "chatgpt-web",
provider: "perplexity-web",
authType: "apikey",
name: "ChatGPT Web Invalid Session",
name: "Perplexity Web Invalid Session",
providerSpecificData: { unrelated: "value" },
defaultModel: "gpt-4o",
defaultModel: "pplx-auto",
});
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
assert.equal(
combo.models.some((model) => model.providerId === "chatgpt-web"),
combo.models.some((model) => model.providerId === "perplexity-web"),
false,
"web-session providers with irrelevant providerSpecificData must not be auto-combo candidates"
);
assert.equal(combo.autoConfig.candidatePool.includes("chatgpt-web"), false);
assert.equal(combo.autoConfig.candidatePool.includes("perplexity-web"), false);
});
test("createVirtualAutoCombo groups same-provider web sessions behind one logical model", async () => {
@@ -172,22 +172,34 @@ test("createVirtualAutoCombo groups same-provider web sessions behind one logica
);
});
test("createVirtualAutoCombo includes cookie web-session providers with required cookie data", async () => {
await providersDb.createProviderConnection({
provider: "chatgpt-web",
authType: "apikey",
name: "ChatGPT Web Session",
providerSpecificData: { cookie: "__Secure-next-auth.session-token=chatgpt-session" },
defaultModel: "gpt-4o",
});
test("createVirtualAutoCombo excludes restored active ChatGPT Web rows that bypassed triggers", async () => {
const db = core.getDbInstance();
db.exec(`
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_insert;
DROP TRIGGER IF EXISTS provider_connections_retire_chatgpt_web_update;
`);
for (const provider of ["chatgpt-web", "cgpt-web"]) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, api_key, default_model, is_active, test_status, " +
"created_at, updated_at) VALUES (?, ?, 'apikey', ?, ?, 'gpt-5.5', 1, 'active', " +
"datetime('now'), datetime('now'))"
).run(
`${provider}-restored-auto`,
provider,
`${provider} restored auto`,
`sk-${provider}-restored-auto`
);
}
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
const chatgptWeb = combo.models.find(
(model) => model.providerId === "chatgpt-web" && model.model === "chatgpt-web/gpt-4o"
assert.equal(
combo.models.some((model) => ["chatgpt-web", "cgpt-web"].includes(model.providerId)),
false
);
assert.ok(chatgptWeb, "the configured cookie web-session model should be a candidate");
assert.ok(combo.autoConfig.candidatePool.includes("chatgpt-web"));
assert.equal(combo.autoConfig.candidatePool.includes("chatgpt-web"), false);
assert.equal(combo.autoConfig.candidatePool.includes("cgpt-web"), false);
});
test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_connections rows", async () => {

View File

@@ -10,7 +10,8 @@ import { harvestToCredentials, type HarvestResult } from "@/lib/vncSession/harve
test("manifest lookup resolves known providers and rejects unknown", () => {
assert.equal(isVncProvider("gemini-web"), true);
assert.equal(isVncProvider("chatgpt-web"), true);
assert.equal(isVncProvider("chatgpt-web"), false);
assert.equal(isVncProvider("chatgpt-web-codex"), true);
assert.equal(isVncProvider("not-a-provider"), false);
assert.equal(getVncProvider(null), null);
assert.equal(getVncProvider("gemini-web")?.url, "https://gemini.google.com");
@@ -24,7 +25,10 @@ test("provider list has well-formed URLs, unique IDs, and canonical requirements
for (const entry of providers) {
assert.match(entry.url, /^https:\/\//, `bad url for ${entry.id}`);
assert.ok(["cookie", "token"].includes(entry.requirement.kind), `bad kind for ${entry.id}`);
assert.ok(Array.isArray(entry.requirement.storageKeys), `storageKeys not array for ${entry.id}`);
assert.ok(
Array.isArray(entry.requirement.storageKeys),
`storageKeys not array for ${entry.id}`
);
assert.equal(getVncProvider(entry.id)?.id, entry.id);
}
});

View File

@@ -14,7 +14,7 @@
// This test proves the cookie-validation probe reaches a local forward proxy
// (via a real CONNECT tunnel — the same mechanism undici uses for both HTTP and
// HTTPS targets) when one is configured via HTTP_PROXY, exactly like the
// specialty web-cookie validators (chatgpt-web, grok-web, ...) already do via
// specialty web-cookie validators (grok-web, perplexity-web, etc.) already do via
// validationRead/validationWrite.
import test from "node:test";
import assert from "node:assert/strict";

View File

@@ -118,13 +118,13 @@ test("web session credential validator requires provider-specific non-empty valu
false
);
assert.equal(
webSessionCredentials.hasUsableWebSessionCredential("chatgpt-web", {
webSessionCredentials.hasUsableWebSessionCredential("perplexity-web", {
cookie: "__Secure-next-auth.session-token=session",
}),
true
);
assert.equal(
webSessionCredentials.hasUsableWebSessionCredential("chatgpt-web", { unrelated: "value" }),
webSessionCredentials.hasUsableWebSessionCredential("perplexity-web", { unrelated: "value" }),
false
);
});
@@ -132,5 +132,5 @@ test("web session credential validator requires provider-specific non-empty valu
test("no-auth web providers can be saved without an API key", () => {
assert.equal(providers.providerAllowsOptionalApiKey("veoaifree-web"), true);
assert.equal(webSessionCredentials.requiresWebSessionCredential("veoaifree-web"), false);
assert.equal(webSessionCredentials.requiresWebSessionCredential("chatgpt-web"), true);
assert.equal(webSessionCredentials.requiresWebSessionCredential("perplexity-web"), true);
});

View File

@@ -1,6 +1,6 @@
// Tool contract serialization for ChatGPT Web performance models (#7679).
// Tool contract serialization for web-cookie model adapters (#7679).
//
// GPT-5.6 Sol via chatgpt-web ignores the injected `<tool>` pseudo-contract
// Some web-cookie models ignore 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.