mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix: omniModel SSE tag data loss + v2.9.1 release (#511)
This commit is contained in:
20
CHANGELOG.md
20
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: `<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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user