diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index d93eb652..eb1e88b5 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -89,7 +89,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const acceptHeader = clientRawRequest?.headers?.accept || ""; const clientPrefersJson = acceptHeader.includes("application/json"); const clientPrefersSSE = acceptHeader.includes("text/event-stream"); - if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) { + if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) { stream = false; } diff --git a/tests/unit/force-stream-config.test.js b/tests/unit/force-stream-config.test.js index 960770a1..ea3a1c3d 100644 --- a/tests/unit/force-stream-config.test.js +++ b/tests/unit/force-stream-config.test.js @@ -1,9 +1,136 @@ // Guards forceStream moved from chatCore hardcode → PROVIDERS schema (#5). -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { executeMock } = vi.hoisted(() => ({ + executeMock: vi.fn(), +})); + +vi.mock("../../open-sse/executors/index.js", () => ({ + getExecutor: vi.fn(() => ({ + execute: executeMock, + refreshCredentials: vi.fn().mockResolvedValue(null), + })), +})); + +vi.mock("../../open-sse/utils/requestLogger.js", () => ({ + createRequestLogger: vi.fn(async () => ({ + logClientRawRequest: vi.fn(), + logRawRequest: vi.fn(), + logTargetRequest: vi.fn(), + logError: vi.fn(), + })), +})); + +vi.mock("../../open-sse/utils/clientDetector.js", () => ({ + detectClientTool: vi.fn(() => null), + isNativePassthrough: vi.fn(() => false), +})); + +vi.mock("../../open-sse/utils/bypassHandler.js", () => ({ + handleBypassRequest: vi.fn(() => null), +})); + +vi.mock("../../open-sse/utils/streamHandler.js", () => ({ + createStreamController: vi.fn(() => ({ + signal: undefined, + handleComplete: vi.fn(), + handleError: vi.fn(), + })), +})); + +vi.mock("../../open-sse/services/tokenRefresh.js", () => ({ + refreshWithRetry: vi.fn(), +})); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + default: vi.fn(), + proxyAwareFetch: vi.fn(), +})); + +vi.mock("../../open-sse/translator/formats/claude.js", () => ({ + normalizeClaudePassthrough: vi.fn(), +})); + +vi.mock("../../open-sse/utils/toolDeduper.js", () => ({ + dedupeTools: vi.fn((tools) => ({ tools, stripped: [] })), +})); + +vi.mock("../../open-sse/rtk/caveman.js", () => ({ + injectCaveman: vi.fn(), +})); + +vi.mock("../../open-sse/rtk/ponytail.js", () => ({ + injectPonytail: vi.fn(), +})); + +vi.mock("../../open-sse/rtk/index.js", () => ({ + compressMessages: vi.fn(() => null), + formatRtkLog: vi.fn(() => ""), +})); + +vi.mock("../../open-sse/rtk/headroom.js", () => ({ + compressWithHeadroom: vi.fn(async () => null), + formatHeadroomLog: vi.fn(() => ""), +})); + +vi.mock("../../open-sse/providers/capabilities.js", () => ({ + getCapabilitiesForModel: vi.fn(() => ({})), +})); + +vi.mock("../../open-sse/translator/concerns/modality.js", () => ({ + stripUnsupportedModalities: vi.fn(() => false), +})); + +vi.mock("../../open-sse/translator/concerns/prefetch.js", () => ({ + prefetchRemoteImages: vi.fn(async () => 0), +})); + +vi.mock("../../open-sse/handlers/chatCore/requestDetail.js", () => ({ + buildRequestDetail: vi.fn((detail) => detail), + extractRequestConfig: vi.fn((body, stream) => ({ body, stream })), +})); + +vi.mock("../../open-sse/utils/error.js", () => ({ + createErrorResult: vi.fn((status, message) => ({ success: false, status, error: message })), + formatProviderError: vi.fn((error) => error.message), + parseUpstreamError: vi.fn(), +})); + +vi.mock("@/lib/usageDb.js", () => ({ + trackPendingRequest: vi.fn(), + appendRequestLog: vi.fn(() => Promise.resolve()), + saveRequestDetail: vi.fn(() => Promise.resolve()), +})); const FORCED = ["openai", "codex", "commandcode"]; +function makeOptions(bodyStream) { + const body = { + model: "gpt-4.1", + messages: [{ role: "user", content: "hello" }], + }; + if (bodyStream !== undefined) body.stream = bodyStream; + + return { + body, + modelInfo: { provider: "openai", model: "gpt-4.1" }, + credentials: { apiKey: "sk-test" }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: { accept: "application/json" }, + }, + connectionId: "test-connection", + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; +} + describe("forceStream provider config", () => { + beforeEach(() => { + executeMock.mockReset(); + executeMock.mockRejectedValue(new Error("boom")); + }); + it("only openai/codex/commandcode force streaming", async () => { const { PROVIDERS } = await import("../../open-sse/config/providers.js"); for (const id of FORCED) { @@ -14,4 +141,13 @@ describe("forceStream provider config", () => { expect(PROVIDERS[id]?.forceStream, `${id} not forced`).not.toBe(true); } }); + + it.each([undefined, false])( "keeps forced-stream providers streaming for JSON clients when body.stream is %s", async (bodyStream) => { + const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + + await handleChatCore(makeOptions(bodyStream)); + + expect(executeMock).toHaveBeenCalledTimes(1); + expect(executeMock.mock.calls[0][0].stream).toBe(true); + }); });