diff --git a/.env.example b/.env.example index 102f5c5fa..6ef86cb8a 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,10 @@ STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应 # Dashboard 配置 DASHBOARD_LOGS_POLL_INTERVAL_MS=5000 # 日志页自动刷新轮询间隔(毫秒,默认 5000,范围 250-60000) +# OpenAI Responses WebSocket(可选三态覆盖) +# 未设置时读取数据库系统设置;仅接受 true/false/1/0,设置后强制覆盖数据库值。 +# ENABLE_OPENAI_RESPONSES_WEBSOCKET=false + # 熔断器配置 # 功能说明:控制网络错误是否计入熔断器失败计数 # - false (默认):网络错误(DNS 解析失败、连接超时、代理连接失败等)不计入熔断器,仅供应商错误(4xx/5xx HTTP 响应)计入 diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts index e66c1c409..9a2a9ab6d 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from "node:net"; -import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; import type { Provider } from "@/types/provider"; import { clearResponsesWsSessionsForTests, @@ -21,6 +21,12 @@ type ServerHandle = { close: () => Promise; }; +type StalledUpgradeServerHandle = { + port: number; + upgradeReceived: Promise; + close: () => Promise; +}; + function startMockServer( handler: (socket: import("ws").WebSocket, req: import("http").IncomingMessage) => void ): Promise { @@ -42,6 +48,67 @@ function startMockServer( }); } +async function startStalledUpgradeServer(): Promise { + const http = await import("node:http"); + const sockets = new Set(); + let resolveUpgradeReceived: (() => void) | null = null; + const upgradeReceived = new Promise((resolve) => { + resolveUpgradeReceived = resolve; + }); + const httpServer = http.createServer(); + + httpServer.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + httpServer.on("upgrade", () => { + resolveUpgradeReceived?.(); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + httpServer.once("error", onError); + httpServer.listen(0, "127.0.0.1", () => { + httpServer.off("error", onError); + resolve(); + }); + }); + + const address = httpServer.address() as AddressInfo; + return { + port: address.port, + upgradeReceived, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; +} + +function captureProcessErrors() { + const uncaughtExceptions: unknown[] = []; + const unhandledRejections: unknown[] = []; + const onUncaughtException = (error: unknown) => uncaughtExceptions.push(error); + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason); + + process.on("uncaughtException", onUncaughtException); + process.on("unhandledRejection", onUnhandledRejection); + + return { + uncaughtExceptions, + unhandledRejections, + stop() { + process.off("uncaughtException", onUncaughtException); + process.off("unhandledRejection", onUnhandledRejection); + }, + }; +} + +async function flushProcessEvents(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + function codexProvider(): Provider { return { id: 1, @@ -773,6 +840,105 @@ describe("tryResponsesWebsocketUpstream", () => { } }); + it("safely terminates a CONNECTING socket when aborted after construction", async () => { + const stalledServer = await startStalledUpgradeServer(); + const abortController = new AbortController(); + const processErrors = captureProcessErrors(); + const closeSpy = vi.spyOn(WebSocket.prototype, "close"); + const terminateSpy = vi.spyOn(WebSocket.prototype, "terminate"); + + try { + const resultPromise = tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${stalledServer.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + abortSignal: abortController.signal, + body: { model: "gpt-5.5", input: "hi" }, + }); + + await withTimeout( + stalledServer.upgradeReceived, + 1_000, + "upstream WebSocket did not enter the stalled handshake" + ); + abortController.abort(); + + const result = await withTimeout( + resultPromise, + 1_000, + "CONNECTING upstream WebSocket attempt hung after abort" + ); + await flushProcessEvents(); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect(result.message).toContain("aborted before first upstream WebSocket event"); + expect(terminateSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).not.toHaveBeenCalled(); + expect(processErrors.uncaughtExceptions).toEqual([]); + expect(processErrors.unhandledRejections).toEqual([]); + + const clientSocket = terminateSpy.mock.instances[0] as WebSocket; + expect(clientSocket.listenerCount("open")).toBe(0); + expect(clientSocket.listenerCount("message")).toBe(0); + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + expect(clientSocket.listenerCount("unexpected-response")).toBe(0); + } finally { + processErrors.stop(); + closeSpy.mockRestore(); + terminateSpy.mockRestore(); + await stalledServer.close(); + } + }); + + it("safely terminates a CONNECTING socket when the signal is already aborted", async () => { + const stalledServer = await startStalledUpgradeServer(); + const abortController = new AbortController(); + abortController.abort(); + const processErrors = captureProcessErrors(); + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const closeSpy = vi.spyOn(WebSocket.prototype, "close"); + const terminateSpy = vi.spyOn(WebSocket.prototype, "terminate"); + + try { + const result = await withTimeout( + tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${stalledServer.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + abortSignal: abortController.signal, + body: { model: "gpt-5.5", input: "hi" }, + }), + 1_000, + "already-aborted upstream WebSocket attempt hung" + ); + await flushProcessEvents(); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect(result.message).toContain("aborted before first upstream WebSocket event"); + expect(terminateSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).not.toHaveBeenCalled(); + expect(timeoutSpy.mock.calls.some(([, delay]) => delay === 20_000)).toBe(false); + expect(processErrors.uncaughtExceptions).toEqual([]); + expect(processErrors.unhandledRejections).toEqual([]); + + const clientSocket = terminateSpy.mock.instances[0] as WebSocket; + expect(clientSocket.listenerCount("open")).toBe(0); + expect(clientSocket.listenerCount("message")).toBe(0); + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + expect(clientSocket.listenerCount("unexpected-response")).toBe(0); + } finally { + processErrors.stop(); + timeoutSpy.mockRestore(); + closeSpy.mockRestore(); + terminateSpy.mockRestore(); + await stalledServer.close(); + } + }); + it("resolves and closes upstream when aborted before the first WS event", async () => { let resolveMessageReceived: (() => void) | null = null; const messageReceived = new Promise((resolve) => { @@ -821,6 +987,167 @@ describe("tryResponsesWebsocketUpstream", () => { expect(upstreamCloseCode).toBe(1000); }); + it("closes only once when abort races the first-event timeout", async () => { + let resolveMessageReceived: (() => void) | null = null; + const messageReceived = new Promise((resolve) => { + resolveMessageReceived = resolve; + }); + let resolveUpstreamClosed: (() => void) | null = null; + const upstreamClosed = new Promise((resolve) => { + resolveUpstreamClosed = resolve; + }); + server = await startMockServer((socket) => { + socket.on("message", () => { + resolveMessageReceived?.(); + }); + socket.on("close", () => { + resolveUpstreamClosed?.(); + }); + }); + + const realSetTimeout = globalThis.setTimeout.bind(globalThis); + let firstEventTimeoutCallback: (() => void) | null = null; + const placeholderTimer = realSetTimeout(() => {}, 60_000); + const timeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 20_000) { + firstEventTimeoutCallback = () => callback(...args); + return placeholderTimer; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + const abortController = new AbortController(); + const processErrors = captureProcessErrors(); + const closeSpy = vi.spyOn(WebSocket.prototype, "close"); + const terminateSpy = vi.spyOn(WebSocket.prototype, "terminate"); + + try { + const resultPromise = tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + abortSignal: abortController.signal, + body: { model: "gpt-5.5", input: "hi" }, + }); + + await withTimeout(messageReceived, 1_000, "upstream did not receive the WS request frame"); + expect(firstEventTimeoutCallback).not.toBeNull(); + const triggerFirstEventTimeout = firstEventTimeoutCallback as unknown as () => void; + + abortController.abort(); + triggerFirstEventTimeout(); + const closeInstances = closeSpy.mock.instances as unknown as WebSocket[]; + const clientCloseCallsAfterRace = closeInstances.filter( + (socket) => socket.url === `ws://127.0.0.1:${server?.port}/v1/responses` + ); + expect(clientCloseCallsAfterRace).toHaveLength(1); + const clientSocket = clientCloseCallsAfterRace[0] as WebSocket; + + const result = await withTimeout( + resultPromise, + 1_000, + "upstream WS attempt hung during abort and timeout race" + ); + await withTimeout(upstreamClosed, 1_000, "upstream WS did not close during abort race"); + await flushProcessEvents(); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect(result.message).toContain("aborted before first upstream WebSocket event"); + expect(terminateSpy).not.toHaveBeenCalled(); + expect(processErrors.uncaughtExceptions).toEqual([]); + expect(processErrors.unhandledRejections).toEqual([]); + + expect(clientSocket.listenerCount("open")).toBe(0); + expect(clientSocket.listenerCount("message")).toBe(0); + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + expect(clientSocket.listenerCount("unexpected-response")).toBe(0); + } finally { + clearTimeout(placeholderTimer); + processErrors.stop(); + timeoutSpy.mockRestore(); + closeSpy.mockRestore(); + terminateSpy.mockRestore(); + } + }); + + it("keeps an error sink when abort races a socket already in CLOSING", async () => { + let resolveMessageReceived: (() => void) | null = null; + const messageReceived = new Promise((resolve) => { + resolveMessageReceived = resolve; + }); + server = await startMockServer((socket) => { + socket.on("message", () => { + resolveMessageReceived?.(); + }); + }); + + const abortController = new AbortController(); + const processErrors = captureProcessErrors(); + const sendSpy = vi.spyOn(WebSocket.prototype, "send"); + const closeSpy = vi.spyOn(WebSocket.prototype, "close"); + const terminateSpy = vi.spyOn(WebSocket.prototype, "terminate"); + let clientSocket: WebSocket | null = null; + + try { + const resultPromise = tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + abortSignal: abortController.signal, + body: { model: "gpt-5.5", input: "hi" }, + }); + + await withTimeout(messageReceived, 1_000, "upstream did not receive the WS request frame"); + const sendInstances = sendSpy.mock.instances as unknown as WebSocket[]; + clientSocket = + sendInstances.find( + (socket) => socket.url === `ws://127.0.0.1:${server?.port}/v1/responses` + ) ?? null; + expect(clientSocket).not.toBeNull(); + if (!clientSocket) return; + + const mutableClientSocket = clientSocket as WebSocket & { _readyState: number }; + mutableClientSocket._readyState = WebSocket.CLOSING; + abortController.abort(); + + const result = await withTimeout( + resultPromise, + 1_000, + "CLOSING upstream WebSocket attempt hung after abort" + ); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect(closeSpy).not.toHaveBeenCalled(); + expect(terminateSpy).not.toHaveBeenCalled(); + expect(clientSocket.listenerCount("error")).toBe(1); + + clientSocket.emit("error", new Error("synthetic closing-race error")); + clientSocket.emit("close", 1000, Buffer.alloc(0)); + await flushProcessEvents(); + + expect(clientSocket.listenerCount("error")).toBe(0); + expect(clientSocket.listenerCount("close")).toBe(0); + expect(processErrors.uncaughtExceptions).toEqual([]); + expect(processErrors.unhandledRejections).toEqual([]); + } finally { + sendSpy.mockRestore(); + closeSpy.mockRestore(); + terminateSpy.mockRestore(); + if (clientSocket && clientSocket.readyState !== WebSocket.CLOSED) { + clientSocket.once("error", () => {}); + clientSocket.terminate(); + await flushProcessEvents(); + } + processErrors.stop(); + } + }); + it("keeps the persistent session map bounded when every retained session is active", async () => { setResponsesWsSessionMaxEntriesForTests(1); let connectionCount = 0; diff --git a/src/app/v1/_lib/responses-ws/upstream-adapter.ts b/src/app/v1/_lib/responses-ws/upstream-adapter.ts index bd379fc3a..45d1e119a 100644 --- a/src/app/v1/_lib/responses-ws/upstream-adapter.ts +++ b/src/app/v1/_lib/responses-ws/upstream-adapter.ts @@ -206,19 +206,46 @@ function isWsClosingOrClosed(ws: WebSocketType): boolean { return ws.readyState >= 2; } -function closeWs(ws: WebSocketType, code: number): void { - try { - ws.close(code); - } catch { - // ignore +function safelyCloseWebSocket(ws: WebSocketType, code: number): void { + if (ws.readyState === 3) return; + + // Active closes can still surface an asynchronous error after request-level + // listeners are detached. This one-shot sink keeps the error owned by this + // socket instead of letting it escape to process-level crash handlers. + const consumeCloseError = () => {}; + ws.once("error", consumeCloseError); + ws.once("close", () => { + ws.off("error", consumeCloseError); + }); + + // Another close path may already have started the handshake. Keep the + // temporary sink above, but do not issue a duplicate close or terminate. + if (ws.readyState === 2) return; + + if (ws.readyState === 0) { + // ws emits this error asynchronously when a CONNECTING socket is closed + // or terminated, so abort the incomplete handshake instead of attempting + // a normal close handshake. + try { + ws.terminate(); + } catch { + // ignore + } + return; } -} -function terminateWs(ws: WebSocketType): void { try { - ws.terminate?.(); + ws.close(code); } catch { - // ignore + // A readyState transition can race with close(). If the socket is still + // live, terminate it using the error consumer installed above. + if (!isWsClosingOrClosed(ws)) { + try { + ws.terminate(); + } catch { + // ignore + } + } } } @@ -235,7 +262,7 @@ function forgetPersistentSession(sessionId: string, ws?: WebSocketType): void { function closePersistentEntry(entry: PersistentWsEntry, code: number): void { forgetPersistentSession(entry.sessionId, entry.ws); - closeWs(entry.ws, code); + safelyCloseWebSocket(entry.ws, code); } function armPersistentIdleTimer(entry: PersistentWsEntry): void { @@ -441,7 +468,7 @@ export async function tryResponsesWebsocketUpstream(options: { const closeAndForget = (code: number) => { if (sessionId) forgetPersistentSession(sessionId, ws); - closeWs(ws, code); + safelyCloseWebSocket(ws, code); }; const messageQueue: string[] = []; @@ -460,6 +487,7 @@ export async function tryResponsesWebsocketUpstream(options: { let terminalEventSeen = false; let terminalEventShouldClosePersistent = false; let firstEventTimer: ReturnType | null = null; + let requestFinished = false; const sendFrame = () => { if (!isWsOpen(ws)) { @@ -643,7 +671,9 @@ export async function tryResponsesWebsocketUpstream(options: { }; const finishRequest = (options?: { closeCode?: number; forgetSession?: boolean }) => { - cleanupRequestListeners(); + if (requestFinished) return; + requestFinished = true; + let closeDetachedEntry = false; if (persistentEntry) { persistentEntry.active = false; @@ -663,8 +693,9 @@ export async function tryResponsesWebsocketUpstream(options: { if (options?.closeCode) { closeAndForget(options.closeCode); } else if (closeDetachedEntry) { - closeWs(ws, 1000); + safelyCloseWebSocket(ws, 1000); } + cleanupRequestListeners(); }; function onAbort() { @@ -699,18 +730,20 @@ export async function tryResponsesWebsocketUpstream(options: { // Bound the wait for the first event so a silent upstream cannot pin a // request slot indefinitely. Cleared on first message or any other // resolution. - firstEventTimer = setTimeout(() => { - if (firstEventSeen) return; - finishOpen({ - ok: false, - reason: "ws_error_pre_first_event", - message: "timeout_waiting_for_first_event", - // A silent upstream is most likely transient (load, latency); the - // next request should re-probe rather than skip the WS path. - cacheableAsUnsupported: false, - }); - closeAndForget(1011); - }, FIRST_EVENT_TIMEOUT_MS); + if (!openResolved) { + firstEventTimer = setTimeout(() => { + if (firstEventSeen) return; + finishOpen({ + ok: false, + reason: "ws_error_pre_first_event", + message: "timeout_waiting_for_first_event", + // A silent upstream is most likely transient (load, latency); the + // next request should re-probe rather than skip the WS path. + cacheableAsUnsupported: false, + }); + finishRequest({ closeCode: 1011, forgetSession: true }); + }, FIRST_EVENT_TIMEOUT_MS); + } if (reused) { sendFrame(); @@ -722,9 +755,7 @@ export async function tryResponsesWebsocketUpstream(options: { firstEventTimer = null; } if (!openResult.ok) { - cleanupRequestListeners(); - if (sessionId) forgetPersistentSession(sessionId, ws); - terminateWs(ws); + finishRequest({ closeCode: 1011, forgetSession: true }); return { failed: true, reason: openResult.reason, diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 736b55238..cc1211c18 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -23,6 +23,35 @@ const CACHE_TTL_MS = 60 * 1000; let cachedSettings: SystemSettings | null = null; let cachedAt: number = 0; +/** Avoid repeating the same invalid environment-variable warning on every request. */ +let hasWarnedInvalidResponsesWebsocketEnv = false; + +function getOpenaiResponsesWebsocketEnvOverride(): boolean | undefined { + const rawValue = process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET; + + if (rawValue === undefined) { + return undefined; + } + + switch (rawValue) { + case "true": + case "1": + return true; + case "false": + case "0": + return false; + default: + if (!hasWarnedInvalidResponsesWebsocketEnv) { + hasWarnedInvalidResponsesWebsocketEnv = true; + logger.warn( + "[SystemSettingsCache] Invalid ENABLE_OPENAI_RESPONSES_WEBSOCKET, using database setting", + { value: rawValue } + ); + } + return undefined; + } +} + /** * Read the current in-memory settings cache only. * Never triggers a DB refresh. @@ -197,6 +226,11 @@ export async function isHttp2Enabled(): Promise { * @returns Whether OpenAI Responses WebSocket support is enabled globally. */ export async function isOpenaiResponsesWebsocketEnabled(): Promise { + const envOverride = getOpenaiResponsesWebsocketEnvOverride(); + if (envOverride !== undefined) { + return envOverride; + } + const settings = await getCachedSystemSettings(); return settings.enableOpenaiResponsesWebsocket; } diff --git a/tests/unit/lib/config/system-settings-cache.test.ts b/tests/unit/lib/config/system-settings-cache.test.ts index 8bea01ee3..ffae5c4c2 100644 --- a/tests/unit/lib/config/system-settings-cache.test.ts +++ b/tests/unit/lib/config/system-settings-cache.test.ts @@ -7,6 +7,8 @@ const loggerDebugMock = vi.fn(); const loggerWarnMock = vi.fn(); const loggerInfoMock = vi.fn(); +const originalResponsesWebsocketEnv = process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET; + vi.mock("server-only", () => ({})); vi.mock("@/repository/system-config", () => ({ @@ -73,6 +75,7 @@ async function loadCache() { return { getCachedSystemSettings: mod.getCachedSystemSettings, isHttp2Enabled: mod.isHttp2Enabled, + isOpenaiResponsesWebsocketEnabled: mod.isOpenaiResponsesWebsocketEnabled, invalidateSystemSettingsCache: mod.invalidateSystemSettingsCache, }; } @@ -82,10 +85,16 @@ beforeEach(() => { vi.resetModules(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-03T00:00:00.000Z")); + delete process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET; }); afterEach(() => { vi.useRealTimers(); + if (originalResponsesWebsocketEnv === undefined) { + delete process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET; + } else { + process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET = originalResponsesWebsocketEnv; + } }); describe("SystemSettingsCache", () => { @@ -178,4 +187,47 @@ describe("SystemSettingsCache", () => { expect(await isHttp2Enabled()).toBe(true); }); + + test.each([ + ["true", true], + ["1", true], + ["false", false], + ["0", false], + ])("ENABLE_OPENAI_RESPONSES_WEBSOCKET=%s 应覆盖数据库设置", async (value, expected) => { + process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET = value; + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ enableOpenaiResponsesWebsocket: !expected }) + ); + const { isOpenaiResponsesWebsocketEnabled } = await loadCache(); + + expect(await isOpenaiResponsesWebsocketEnabled()).toBe(expected); + expect(getSystemSettingsMock).not.toHaveBeenCalled(); + }); + + test("未设置 ENABLE_OPENAI_RESPONSES_WEBSOCKET 时应读取数据库设置", async () => { + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ enableOpenaiResponsesWebsocket: false }) + ); + const { isOpenaiResponsesWebsocketEnabled } = await loadCache(); + + expect(await isOpenaiResponsesWebsocketEnabled()).toBe(false); + expect(getSystemSettingsMock).toHaveBeenCalledTimes(1); + }); + + test("ENABLE_OPENAI_RESPONSES_WEBSOCKET 非法时应仅警告一次并回退数据库设置", async () => { + process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET = " false "; + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ enableOpenaiResponsesWebsocket: true }) + ); + const { isOpenaiResponsesWebsocketEnabled } = await loadCache(); + + expect(await isOpenaiResponsesWebsocketEnabled()).toBe(true); + expect(await isOpenaiResponsesWebsocketEnabled()).toBe(true); + expect(getSystemSettingsMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).toHaveBeenCalledTimes(1); + expect(loggerWarnMock).toHaveBeenCalledWith( + "[SystemSettingsCache] Invalid ENABLE_OPENAI_RESPONSES_WEBSOCKET, using database setting", + { value: " false " } + ); + }); });