fix: omniModel SSE tag data loss + v2.9.1 release (#511)

This commit is contained in:
diegosouzapw
2026-03-21 08:55:28 -03:00
parent 237d0746cf
commit 03f2ef1e2b
5 changed files with 53 additions and 36 deletions

View File

@@ -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: `<omniModel>` 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.

View File

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

View File

@@ -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<omniModel>${modelStr}</omniModel>`;
const tagContent = `\n<omniModel>${modelStr}</omniModel>\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":"<non-empty>" — 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));
}
},
});

4
package-lock.json generated
View File

@@ -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": [

View File

@@ -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": {