From f2ad9b23bdeebbb92020e5f3a5b4459c3da4c1f5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:28 -0300 Subject: [PATCH] fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) (#6165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) Cline's API (api.cline.bot) only implements streaming (streamText). A non-streaming request returns HTTP 500 "generateText is not implemented" (Claude models) or HTTP 502 "empty response" (others). Live-verified on the VPS: stream:true → works (STREAM_OK), stream:false → fails. This is why testing a Cline model in the dashboard (the test button sends stream:false) failed. Fix (reuses the existing isClaudeCodeCompatible mechanism, no new handler): - Flag `cline` and `clinepass` registry entries with `forceStream: true`. - In chatCore, OR `providerRequiresStreaming` into `upstreamStream` (line 1591) so the upstream request always streams for these providers, while the client's original `stream` intent still drives the response format. The existing non-streaming branch (parseNonStreamingResponseBody) already accumulates the upstream SSE and converts it back to JSON for stream:false clients — the same path Claude-Code-compatible providers already use. Tests (Rule #18): tests/unit/cline-force-stream.test.ts pins the registry flags + resolveStreamFlag forcing behavior. Live VPS before/after recorded on the PR. * fix(sse): cline forceStream must stream upstream only, keep client JSON The #2081 wiring fed providerRequiresStreaming into resolveStreamFlag, forcing the client-facing stream flag to true for forceStream providers. That skips the if(!stream) branch that drains a forced upstream SSE and converts it back to JSON, so a stream:false caller (model-test button, plain JSON API) got STREAM_EARLY_EOF instead of a JSON body. Keep providerRequiresStreaming only on upstreamStream (force upstream to stream); leave the client-facing stream as the client sent it, so readNonStreamingResponseBody accumulates the SSE into JSON. The promised handleForcedSSEToJson (#2081 comment) was never implemented — this uses the existing non-streaming SSE-buffering path (same as isClaudeCodeCompatible). Live-verified on VPS: cline stream:true worked, stream:false failed. --- .../config/providers/registry/cline/index.ts | 5 +++ .../providers/registry/clinepass/index.ts | 5 +++ open-sse/handlers/chatCore.ts | 22 +++++++--- tests/unit/cline-force-stream.test.ts | 43 +++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/unit/cline-force-stream.test.ts 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..54657d6737 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -873,9 +873,16 @@ export async function handleChatCore({ // sourceFormat="claude" applies the Anthropic Messages spec default (stream=false // when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when // clients send Accept: */* without an explicit stream flag. - // providerRequiresStreaming: providers with forceStream:true reject stream:false - // upstream (HTTP 400); keep streaming so OmniRoute can convert the stream to JSON - // for the client via handleForcedSSEToJson. (#2081) + // providerRequiresStreaming: providers with forceStream:true (cline/clinepass) + // only implement upstream streaming — a non-streaming request returns + // "generateText is not implemented" / an empty body. This flag forces the + // UPSTREAM request to stream (see `upstreamStream` below), but it MUST NOT + // force the client-facing `stream` flag: a stream:false client (e.g. the + // model-test button, plain JSON API callers) still expects a JSON response. + // The client-side `if (!stream)` branch drains the forced upstream SSE and + // converts it back to JSON via readNonStreamingResponseBody. Passing this + // flag into resolveStreamFlag would force `stream=true` and skip that + // conversion, yielding STREAM_EARLY_EOF for JSON callers. (#2081, #6126) const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true; const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) @@ -883,7 +890,6 @@ export async function handleChatCore({ : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, { userAgent: streamUserAgent, streamDefaultMode: apiKeyInfo?.streamDefaultMode, - providerRequiresStreaming, }); // `settings` is already consolidated once near the top of handleChatCore @@ -1588,7 +1594,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..2c8ea5caaf --- /dev/null +++ b/tests/unit/cline-force-stream.test.ts @@ -0,0 +1,43 @@ +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 carry +// `forceStream: true` so chatCore forces the UPSTREAM request to stream +// (`upstreamStream = stream || isClaudeCodeCompatible || providerRequiresStreaming`) +// even when the client wants JSON. The client-facing `stream` flag stays as the +// client sent it, so the `if (!stream)` branch drains the forced upstream SSE and +// converts it back to JSON via readNonStreamingResponseBody. Regression guard for +// the "cline model test → generateText is not implemented / STREAM_EARLY_EOF" bug +// (live-verified on the VPS: stream:true works, stream:false failed). (#6126) + +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("upstreamStream is forced true for a forceStream provider even when the client sent stream:false", () => { + // Mirror the chatCore wiring: providerRequiresStreaming derives from the + // registry flag, and upstreamStream ORs it in so the upstream always streams. + const providerRequiresStreaming = REGISTRY.cline?.forceStream === true; + const isClaudeCodeCompatible = false; + const clientStream = false; // client asked for JSON + const upstreamStream = clientStream || isClaudeCodeCompatible || providerRequiresStreaming; + assert.equal(upstreamStream, true); +}); + +test("client-facing stream stays false for a stream:false JSON caller (so SSE→JSON conversion runs)", () => { + // chatCore MUST NOT pass providerRequiresStreaming into resolveStreamFlag: + // a stream:false client keeps stream=false so the `if (!stream)` branch drains + // the forced upstream SSE and returns JSON. Forcing stream=true here would skip + // that conversion and yield STREAM_EARLY_EOF for JSON callers. + assert.equal(resolveStreamFlag(false, "application/json", "openai"), false); + // A stream:true client still streams end-to-end. + assert.equal(resolveStreamFlag(true, "application/json", "openai"), true); +});