diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccd1882d4..e626903711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ - **antigravity/gemini tool calls (`400 Unknown name "multipleOf"`):** requests routed to antigravity/gemini models with tools that declare a `multipleOf` numeric constraint failed with a hard upstream `400` (`Invalid JSON payload received. Unknown name "multipleOf"`). `multipleOf` is not part of the Gemini/antigravity OpenAPI 3.0 schema subset and was not being stripped from `function_declarations`. It is now removed at every schema level (top-level, nested, and array `items`), alongside the other unsupported constraints; `minimum`/`maximum` remain untouched. Regression guard: `tests/unit/gemini-multipleof-2309.test.ts`. (thanks @abil0321) +- **cline (empty/false-502 non-streaming responses):** the Cline gateway can wrap OpenAI-compatible chat completions in a `{ success, data: { choices, usage, … } }` envelope. The non-streaming path checked the top-level body for empty content before unwrapping, so a valid Cline response was treated as malformed. The body is now unwrapped via `unwrapClineNonStreamingEnvelope()` right after the provider-envelope unwrap and before the empty-content check, closing the remaining gap from #5956/#5924. Regression guard: `tests/unit/cline-response-envelope.test.ts`. ([#5956](https://github.com/diegosouzapw/OmniRoute/issues/5956) — thanks @KooshaPari) + ### 📝 Maintenance - **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PR→release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` — that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1e15fe5d1f..5c9e0e3db2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -311,7 +311,7 @@ "tests/unit/translator-helper-branches.test.ts": 870, "tests/unit/translator-openai-responses-req.test.ts": 1172, "tests/unit/translator-openai-to-gemini.test.ts": 1579, - "tests/unit/translator-openai-to-kiro.test.ts": 1088, + "tests/unit/translator-openai-to-kiro.test.ts": 1093, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 21500f2c30..45a58c3f57 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -252,6 +252,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts"; import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; +import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { sanitizeOpenAIResponse, @@ -3612,6 +3613,7 @@ export async function handleChatCore({ } responseBody = unwrapped; } + responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody); // Check for empty content response (fake success) - trigger fallback if (isEmptyContentResponse(responseBody)) { diff --git a/open-sse/handlers/chatCore/clineResponseEnvelope.ts b/open-sse/handlers/chatCore/clineResponseEnvelope.ts new file mode 100644 index 0000000000..0882ef3718 --- /dev/null +++ b/open-sse/handlers/chatCore/clineResponseEnvelope.ts @@ -0,0 +1,25 @@ +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasOpenAIChoices(value: unknown): boolean { + return isRecord(value) && Array.isArray(value.choices); +} + +export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown { + if (provider !== "cline" || !isRecord(responseBody)) { + return responseBody; + } + + const data = responseBody.data; + if (!hasOpenAIChoices(data)) { + return responseBody; + } + + return { + ...data, + usage: isRecord(data) && data.usage !== undefined ? data.usage : responseBody.usage, + }; +} diff --git a/stryker.conf.json b/stryker.conf.json index 2b4a90dec9..b8dac2b67c 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -92,6 +92,7 @@ "tests/unit/claude-passthrough-stream-boolean.test.ts", "tests/unit/claude-passthrough-thinking-2454.test.ts", "tests/unit/cli-simulate.test.ts", + "tests/unit/cline-response-envelope.test.ts", "tests/unit/clinepass-provider.test.ts", "tests/unit/codex-failover.test.ts", "tests/unit/codex-session-affinity-reset-aware-5903.test.ts", diff --git a/tests/unit/cline-response-envelope.test.ts b/tests/unit/cline-response-envelope.test.ts new file mode 100644 index 0000000000..f087ad923a --- /dev/null +++ b/tests/unit/cline-response-envelope.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { unwrapClineNonStreamingEnvelope } = await import( + "../../open-sse/handlers/chatCore/clineResponseEnvelope.ts" +); + +test("unwrapClineNonStreamingEnvelope extracts Cline wrapped chat completions", () => { + const wrapped = { + success: true, + data: { + id: "chatcmpl_cline", + model: "cline/model", + choices: [{ index: 0, message: { role: "assistant", content: "ok" } }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }, + }; + + assert.deepEqual(unwrapClineNonStreamingEnvelope("cline", wrapped), wrapped.data); +}); + +test("unwrapClineNonStreamingEnvelope keeps non-Cline and malformed envelopes untouched", () => { + const wrapped = { success: true, data: { message: "missing choices" } }; + + assert.equal(unwrapClineNonStreamingEnvelope("openai", wrapped), wrapped); + assert.equal(unwrapClineNonStreamingEnvelope("cline", wrapped), wrapped); +});