mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
fix(providers): retire common ChatGPT Web provider
This commit is contained in:
committed by
Markus Hartung
parent
825f8fe425
commit
9c5dd6760c
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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)");
|
||||
});
|
||||
@@ -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 = "urlTesla";
|
||||
const citationMarker = "citeturn0search0turn0search3";
|
||||
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 = "citeturn0search0turn0search3";
|
||||
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 + "citeturn0search0"] },
|
||||
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 = "citeturn0search0";
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
55
tests/unit/chatgpt-web-image-handler-retirement.test.ts
Normal file
55
tests/unit/chatgpt-web-image-handler-retirement.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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` } }],
|
||||
}),
|
||||
});
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.data.data.length, 1);
|
||||
assert.equal(res.data.data[0].url, url);
|
||||
});
|
||||
152
tests/unit/chatgpt-web-management-retirement.test.ts
Normal file
152
tests/unit/chatgpt-web-management-retirement.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
33
tests/unit/chatgpt-web-retirement.test.ts
Normal file
33
tests/unit/chatgpt-web-retirement.test.ts
Normal 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);
|
||||
});
|
||||
299
tests/unit/chatgpt-web-runtime-block.test.ts
Normal file
299
tests/unit/chatgpt-web-runtime-block.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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()");
|
||||
});
|
||||
31
tests/unit/chatgpt-web-source-retirement.test.ts
Normal file
31
tests/unit/chatgpt-web-source-retirement.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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'.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
531
tests/unit/migration-163-retire-chatgpt-web.test.ts
Normal file
531
tests/unit/migration-163-retire-chatgpt-web.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ─────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
]) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user