diff --git a/open-sse/config/providers/registry/cline/index.ts b/open-sse/config/providers/registry/cline/index.ts index d1467fdd9f..4913465d19 100644 --- a/open-sse/config/providers/registry/cline/index.ts +++ b/open-sse/config/providers/registry/cline/index.ts @@ -5,6 +5,11 @@ export const clineProvider: RegistryEntry = { alias: "cl", format: "openai", executor: "openai", + // Cline's API only implements streaming (streamText). A non-streaming request + // returns "generateText is not implemented" / an empty body, so force upstream + // streaming and let chatCore convert the SSE back to JSON for stream:false + // clients (e.g. the model-test button, non-streaming API callers). + forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", authType: "oauth", authHeader: "Authorization", diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts index 3ea7dbcb3a..e6d3697a03 100644 --- a/open-sse/config/providers/registry/clinepass/index.ts +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -9,6 +9,11 @@ export const clinepassProvider: RegistryEntry = { alias: "clinepass", format: "openai", executor: "default", + // ClinePass shares Cline's streaming-only API — a non-streaming request returns + // "generateText is not implemented" / an empty body. Force upstream streaming; + // chatCore accumulates the SSE and converts it back to JSON for stream:false + // clients. (Same as the sibling `cline` provider.) + forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e6f7cb7391..72b448056a 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1588,7 +1588,13 @@ export async function handleChatCore({ headers: clientRawRequest?.headers, userAgent, }); - const upstreamStream = stream || isClaudeCodeCompatible; + // `forceStream` providers (e.g. Cline / ClinePass) only implement upstream + // streaming — a non-streaming request returns "generateText is not implemented" + // / an empty body. Force the upstream request to stream even when the client + // wants JSON; the non-streaming branch below accumulates the SSE and converts + // it back to JSON (same mechanism already used for Claude-Code-compatible + // providers via isClaudeCodeCompatible). + const upstreamStream = stream || isClaudeCodeCompatible || providerRequiresStreaming; let ccSessionId: string | null = null; const stripTypes = getStripTypesForProviderModel(provider || "", model || ""); diff --git a/tests/unit/cline-force-stream.test.ts b/tests/unit/cline-force-stream.test.ts new file mode 100644 index 0000000000..3b78cfc2b1 --- /dev/null +++ b/tests/unit/cline-force-stream.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { REGISTRY } from "@omniroute/open-sse/config/providers/index.ts"; +import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; + +// Cline / ClinePass only implement upstream streaming — a non-streaming request +// returns "generateText is not implemented" / an empty body. They must carry +// `forceStream: true` so chatCore forces upstream streaming (upstreamStream) even +// when the client wants JSON, then converts the SSE back to JSON. Regression guard +// for the "cline model test → generateText is not implemented / empty response" +// bug (live-verified on the VPS: stream:true works, stream:false failed). + +test("cline provider is flagged forceStream (streaming-only upstream)", () => { + assert.equal(REGISTRY.cline?.forceStream, true); +}); + +test("clinepass provider is flagged forceStream (streaming-only upstream)", () => { + assert.equal(REGISTRY.clinepass?.forceStream, true); +}); + +test("resolveStreamFlag forces streaming for a forceStream provider even when the client sent stream:false", () => { + // providerRequiresStreaming derives from REGISTRY[provider].forceStream === true + const providerRequiresStreaming = REGISTRY.cline?.forceStream === true; + assert.equal( + resolveStreamFlag(false, "application/json", "openai", { providerRequiresStreaming }), + true + ); +}); + +test("resolveStreamFlag still honors stream:false for a normal (non-forceStream) provider", () => { + const providerRequiresStreaming = REGISTRY.openai?.forceStream === true; // false + assert.equal( + resolveStreamFlag(false, "application/json", "openai", { providerRequiresStreaming }), + false + ); +});