fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) (#6165)

* 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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 02:29:28 -03:00
committed by GitHub
parent 8a7b62ec85
commit f2ad9b23bd
4 changed files with 70 additions and 5 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 || "");

View File

@@ -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);
});