diff --git a/CHANGELOG.md b/CHANGELOG.md index b851653963..f4f542bbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ --- +## [2.9.1] — 2026-03-21 + +> Sprint: Fix SSE omniModel data loss, merge per-protocol model compatibility. + +### Bug Fixes + +- **#511** — Critical: `` tag was sent after `finish_reason:stop` in SSE streams, causing data loss. Tag is now injected into the first non-empty content chunk, guaranteeing delivery before SDKs close the connection. + +### Merged PRs + +- **PR #512** (@zhangqiang8vip): Per-protocol model compatibility — `normalizeToolCallId` and `preserveOpenAIDeveloperRole` can now be configured per client protocol (OpenAI, Claude, Responses API). New `compatByProtocol` field in model config with Zod validation. + +### Triaged Issues + +- **#510** — Windows CLI healthcheck_failed: requested PATH/version info +- **#509** — Turbopack Electron regression: upstream Next.js bug, documented workarounds +- **#508** — macOS black screen: suggested `--disable-gpu` workaround + +--- + ## [2.9.0] — 2026-03-20 > Sprint: Cross-platform machineId fix, per-API-key rate limits, streaming context cache, Alibaba DashScope, search analytics, ZWS v5, and 8 issues closed. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c600c1709f..d9427207db 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 2.9.0 + version: 2.9.1 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index d1cc037b0e..8dc2e99668 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -467,50 +467,47 @@ export async function handleComboChat({ return res; } - // Streaming (Fix #490): append omniModel tag as a final SSE content delta - // before the [DONE] marker using TransformStream for zero-copy passthrough + // Streaming (Fix #490 + #511): prepend omniModel tag into the first + // non-empty content chunk so it arrives BEFORE finish_reason:stop. + // SDKs close the connection on finish_reason, so anything sent after + // that marker is silently dropped. if (!res.body) return res; - const tagContent = `\n${modelStr}`; + const tagContent = `\n${modelStr}\n`; const encoder = new TextEncoder(); const decoder = new TextDecoder(); - let buffer = ""; + let tagInjected = false; const transform = new TransformStream({ transform(chunk, controller) { - // Decode chunk and check for [DONE] marker - const text = decoder.decode(chunk, { stream: true }); - buffer += text; - - // Check if buffer contains the [DONE] marker - const doneIdx = buffer.indexOf("data: [DONE]"); - if (doneIdx === -1) { - // No [DONE] yet — flush buffer as-is (keep passthrough latency low) - controller.enqueue(encoder.encode(buffer)); - buffer = ""; + if (tagInjected) { + // Already injected — passthrough + controller.enqueue(chunk); return; } - // Found [DONE] — inject tag content delta before it - const beforeDone = buffer.slice(0, doneIdx); - const afterDone = buffer.slice(doneIdx); + const text = decoder.decode(chunk, { stream: true }); - // Build a synthetic SSE content delta chunk with the tag - const tagChunk = `data: ${JSON.stringify({ - choices: [ - { - delta: { content: tagContent }, - index: 0, - finish_reason: null, - }, - ], - })}\n\n`; + // Look for the first SSE data line with non-empty content + // Pattern: "content":"" — we inject tag at the start + const contentMatch = text.match(/"content":"([^"]+)/); + if (contentMatch) { + // Inject tag at the beginning of the first content value + const injected = text.replace( + /"content":"([^"]+)/, + `"content":"${tagContent.replace(/"/g, '\\"')}$1` + ); + tagInjected = true; + controller.enqueue(encoder.encode(injected)); + return; + } - controller.enqueue(encoder.encode(beforeDone + tagChunk + afterDone)); - buffer = ""; + // No content yet — passthrough + controller.enqueue(chunk); }, flush(controller) { - // If stream ends without [DONE], flush remaining buffer + tag - if (buffer.length > 0) { + // If stream ends without ever finding content (edge case), + // inject tag as a standalone chunk before the stream closes + if (!tagInjected) { const tagChunk = `data: ${JSON.stringify({ choices: [ { @@ -520,7 +517,7 @@ export async function handleComboChat({ }, ], })}\n\n`; - controller.enqueue(encoder.encode(buffer + tagChunk)); + controller.enqueue(encoder.encode(tagChunk)); } }, }); diff --git a/package-lock.json b/package-lock.json index 835b1c1e02..ac812eca80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.9.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.9.0", + "version": "2.9.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 12b03c3f55..dd1e39923b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "2.9.0", + "version": "2.9.1", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": {