mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(stream): drop leaked chat bootstrap chunk for responses clients (#3035)
Integrated into release/v3.8.8. Drops the leaked empty chat.completion.chunk bootstrap frame before response.* SSE events so strict /v1/responses clients (OpenCode) don't fail on the first frame. Thanks @CitrusIce!
This commit is contained in:
@@ -236,6 +236,14 @@
|
||||
|
||||
- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.7] — 2026-05-29
|
||||
|
||||
@@ -1255,6 +1255,45 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
try {
|
||||
let parsed = JSON.parse(trimmed.slice(5).trim());
|
||||
|
||||
// Some upstream Responses-compatible providers leak an initial Chat Completions
|
||||
// bootstrap chunk (assistant role + empty content) before emitting proper
|
||||
// `response.*` events. That chunk is invalid on /v1/responses and breaks strict
|
||||
// clients like OpenCode, so drop it only for Responses-native consumers.
|
||||
const hasActiveDeltaValue = (value: unknown): boolean => {
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value).some((entry) => hasActiveDeltaValue(entry));
|
||||
}
|
||||
return value !== null && value !== undefined;
|
||||
};
|
||||
|
||||
const isEmptyAssistantBootstrapChunkForResponsesClient =
|
||||
clientExpectsResponsesStream &&
|
||||
parsed?.object === "chat.completion.chunk" &&
|
||||
Array.isArray(parsed?.choices) &&
|
||||
parsed.choices.length > 0 &&
|
||||
parsed.choices.every((choice) => {
|
||||
const candidate = choice && typeof choice === "object" ? choice : {};
|
||||
const delta =
|
||||
candidate.delta && typeof candidate.delta === "object"
|
||||
? candidate.delta
|
||||
: null;
|
||||
|
||||
if (!delta || delta.role !== "assistant") return false;
|
||||
if (hasActiveDeltaValue(delta.content)) return false;
|
||||
if (candidate.finish_reason !== null && candidate.finish_reason !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { role: _role, content: _content, ...restDelta } = delta;
|
||||
return !hasActiveDeltaValue(restDelta);
|
||||
});
|
||||
|
||||
if (isEmptyAssistantBootstrapChunkForResponsesClient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect Responses SSE payloads (have a `type` field like "response.created",
|
||||
// "response.output_item.added", etc.) and skip Chat Completions-specific
|
||||
// sanitization to avoid corrupting the stream for Responses-native clients.
|
||||
|
||||
@@ -652,6 +652,71 @@ test("createSSEStream passthrough preserves Responses API events and completion
|
||||
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for Responses clients", async () => {
|
||||
const text = await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-dummy",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gpt-5.4",
|
||||
choices: [
|
||||
{ index: 0, delta: { role: "assistant", content: null, refusal: null }, finish_reason: null },
|
||||
],
|
||||
})}\n\n`,
|
||||
`event: response.created\ndata: ${JSON.stringify({
|
||||
type: "response.created",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
model: "gpt-5.4",
|
||||
status: "in_progress",
|
||||
output: [],
|
||||
},
|
||||
})}\n\n`,
|
||||
`event: response.in_progress\ndata: ${JSON.stringify({
|
||||
type: "response.in_progress",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
model: "gpt-5.4",
|
||||
status: "in_progress",
|
||||
output: [],
|
||||
},
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.output_text.delta",
|
||||
delta: "OK",
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
model: "gpt-5.4",
|
||||
status: "completed",
|
||||
output: [],
|
||||
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
|
||||
},
|
||||
})}\n\n`,
|
||||
`data: [DONE]\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.OPENAI_RESPONSES,
|
||||
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
body: { input: "hello" },
|
||||
}
|
||||
);
|
||||
|
||||
assert.doesNotMatch(text, /chatcmpl-dummy/);
|
||||
assert.match(text, /response\.created/);
|
||||
assert.match(text, /response\.output_text\.delta/);
|
||||
assert.match(text, /"delta":"OK"/);
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents falls back to response.output_text.delta when completed output is empty", () => {
|
||||
const summary = buildStreamSummaryFromEvents(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user