mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
fix(sse): reconstruct Claude-format content in synthetic bypass responses
handleBypassRequest() returns a canned response for CLI warmup/title-
extraction patterns without calling the provider. For Claude-format
clients (e.g. Claude Code CLI), the non-streaming path merged translated
SSE chunks by taking message_start.message as-is — but the
openai-to-claude translator always initializes that message with
content: [] and streams the actual text via separate
content_block_start/delta events. Every synthetic Claude-format
bypass response therefore silently returned empty content.
mergeChunksToResponse() now rebuilds the content array from
content_block_start/delta events (mirroring the streaming path) and
carries over stop_reason/stop_sequence from message_delta. Extracted
the response-builder helpers (createOpenAIResponse,
create{Non}StreamingResponse, mergeChunksToResponse) out of
bypassHandler.ts into a new open-sse/utils/bypassResponse.ts module so
this logic has a single owner instead of being duplicated inline.
Co-authored-by: KunN-21 <kunn21.nv@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2404
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import { CORS_HEADERS } from "./cors.ts";
|
||||
import { detectFormat } from "../services/provider.ts";
|
||||
import { translateResponse, initState } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { SKIP_PATTERNS } from "../config/constants.ts";
|
||||
import { formatSSE } from "./stream.ts";
|
||||
import { createNonStreamingResponse, createStreamingResponse } from "./bypassResponse.ts";
|
||||
|
||||
/**
|
||||
* Check for bypass patterns — return fake response without calling provider.
|
||||
@@ -90,211 +88,3 @@ export function handleBypassRequest(body, model, userAgent = "") {
|
||||
? createStreamingResponse(sourceFormat, model)
|
||||
: createNonStreamingResponse(sourceFormat, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI standard format response
|
||||
*/
|
||||
function createOpenAIResponse(model) {
|
||||
const id = `chatcmpl-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const text = "CLI Command Execution: Clear Terminal";
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create non-streaming response with translation
|
||||
* Use translator to convert OpenAI → sourceFormat
|
||||
*/
|
||||
function createNonStreamingResponse(sourceFormat, model) {
|
||||
const openaiResponse = createOpenAIResponse(model);
|
||||
|
||||
// If sourceFormat is OpenAI, return directly
|
||||
if (sourceFormat === FORMATS.OPENAI) {
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(openaiResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Use translator to convert: simulate streaming then collect all chunks
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
const allTranslated = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) {
|
||||
allTranslated.push(...translated);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
allTranslated.push(...flushed);
|
||||
}
|
||||
|
||||
// For non-streaming, merge all chunks into final response
|
||||
const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(finalResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create streaming response with translation
|
||||
* Use translator to convert OpenAI chunks → sourceFormat
|
||||
*/
|
||||
function createStreamingResponse(sourceFormat, model) {
|
||||
const openaiResponse = createOpenAIResponse(model);
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
// Create OpenAI streaming chunks
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
|
||||
// Translate each chunk to sourceFormat using translator
|
||||
const translatedChunks = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) {
|
||||
for (const item of translated) {
|
||||
translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining events
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
for (const item of flushed) {
|
||||
translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
}
|
||||
|
||||
// Add [DONE]
|
||||
translatedChunks.push("data: [DONE]\n\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(translatedChunks.join(""), {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge translated chunks into final response object (for non-streaming)
|
||||
* Takes the last complete chunk as the final response
|
||||
*/
|
||||
function mergeChunksToResponse(chunks, sourceFormat) {
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return createOpenAIResponse("unknown");
|
||||
}
|
||||
|
||||
// For most formats, the last chunk before done contains the complete response
|
||||
// Find the most complete chunk (usually the last one with content)
|
||||
let finalChunk = chunks[chunks.length - 1];
|
||||
|
||||
// For Claude format, find the message_stop or final message
|
||||
if (sourceFormat === FORMATS.CLAUDE) {
|
||||
const messageStop = chunks.find((c) => c.type === "message_stop");
|
||||
if (messageStop) {
|
||||
// Reconstruct complete message from chunks
|
||||
const contentDelta = chunks.find((c) => c.type === "content_block_delta");
|
||||
const messageDelta = chunks.find((c) => c.type === "message_delta");
|
||||
const messageStart = chunks.find((c) => c.type === "message_start");
|
||||
|
||||
if (messageStart?.message) {
|
||||
finalChunk = messageStart.message;
|
||||
// Merge usage if available
|
||||
if (messageDelta?.usage) {
|
||||
finalChunk.usage = messageDelta.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI streaming chunks from complete response
|
||||
*/
|
||||
function createOpenAIStreamingChunks(completeResponse) {
|
||||
const { id, created, model, choices } = completeResponse;
|
||||
const content = choices[0].message.content;
|
||||
|
||||
return [
|
||||
// Chunk with content
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content,
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Final chunk with finish_reason
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: completeResponse.usage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
209
open-sse/utils/bypassResponse.ts
Normal file
209
open-sse/utils/bypassResponse.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { translateResponse, initState } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { formatSSE } from "./stream.ts";
|
||||
|
||||
/**
|
||||
* Shared synthetic-response builders for the various "answer without calling
|
||||
* the provider" code paths (CLI bypass patterns today; any future canned/
|
||||
* synthetic response can reuse these instead of re-deriving format
|
||||
* translation). Extracted out of bypassHandler.ts so the logic has exactly
|
||||
* one owner. Ported from upstream decolua/9router#2404 (bypassResponse.js),
|
||||
* with the Claude-format content reconstruction fixed — see
|
||||
* mergeChunksToResponse() below.
|
||||
*/
|
||||
|
||||
const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal";
|
||||
|
||||
/** Build a complete (non-chunked) OpenAI chat-completion response object. */
|
||||
export function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) {
|
||||
const id = `chatcmpl-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Split a complete OpenAI response into the two streaming chunks a client expects. */
|
||||
export function createOpenAIStreamingChunks(completeResponse) {
|
||||
const { id, created, model, choices } = completeResponse;
|
||||
const content = choices[0].message.content;
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: completeResponse.usage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge translated chunks into a final response object (for non-streaming
|
||||
* callers). For most formats the last chunk is already complete. Claude
|
||||
* format is chunk-oriented even for "one-shot" synthetic responses, so the
|
||||
* final message has to be reconstructed from content_block_start/delta
|
||||
* events — taking the raw message_start.message would return an empty
|
||||
* `content: []` (the translator always starts it empty and streams blocks
|
||||
* in via separate events).
|
||||
*/
|
||||
export function mergeChunksToResponse(chunks, sourceFormat) {
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return createOpenAIResponse("unknown");
|
||||
}
|
||||
|
||||
let finalChunk = chunks[chunks.length - 1];
|
||||
|
||||
if (sourceFormat === FORMATS.CLAUDE) {
|
||||
const messageStop = chunks.find((c) => c.type === "message_stop");
|
||||
if (messageStop) {
|
||||
const messageDelta = chunks.find((c) => c.type === "message_delta");
|
||||
const messageStart = chunks.find((c) => c.type === "message_start");
|
||||
|
||||
if (messageStart?.message) {
|
||||
finalChunk = { ...messageStart.message, content: [] };
|
||||
|
||||
const blockMap = new Map();
|
||||
for (const chunk of chunks) {
|
||||
if (chunk?.type === "content_block_start" && typeof chunk.index === "number") {
|
||||
blockMap.set(chunk.index, { ...(chunk.content_block || {}) });
|
||||
}
|
||||
if (chunk?.type === "content_block_delta" && typeof chunk.index === "number") {
|
||||
const current = blockMap.get(chunk.index) || { type: "text", text: "" };
|
||||
if (chunk.delta?.type === "text_delta") {
|
||||
current.type = current.type || "text";
|
||||
current.text = `${current.text || ""}${chunk.delta.text || ""}`;
|
||||
}
|
||||
blockMap.set(chunk.index, current);
|
||||
}
|
||||
}
|
||||
finalChunk.content = [...blockMap.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, block]) => block);
|
||||
|
||||
const startUsage = messageStart.message.usage;
|
||||
const deltaUsage = messageDelta?.usage;
|
||||
if (startUsage || deltaUsage) {
|
||||
finalChunk.usage = {
|
||||
...(startUsage || {}),
|
||||
...(deltaUsage || {}),
|
||||
};
|
||||
}
|
||||
if (messageDelta?.delta?.stop_reason !== undefined) {
|
||||
finalChunk.stop_reason = messageDelta.delta.stop_reason;
|
||||
}
|
||||
if (messageDelta?.delta?.stop_sequence !== undefined) {
|
||||
finalChunk.stop_sequence = messageDelta.delta.stop_sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalChunk;
|
||||
}
|
||||
|
||||
/** Build a non-streaming Response translated from OpenAI into `sourceFormat`. */
|
||||
export function createNonStreamingResponse(sourceFormat, model, text?: string) {
|
||||
const openaiResponse = createOpenAIResponse(model, text);
|
||||
|
||||
if (sourceFormat === FORMATS.OPENAI) {
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(openaiResponse), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
const allTranslated: unknown[] = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) allTranslated.push(...translated);
|
||||
}
|
||||
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) allTranslated.push(...flushed);
|
||||
|
||||
const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(finalResponse), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a streaming (SSE) Response translated from OpenAI into `sourceFormat`. */
|
||||
export function createStreamingResponse(sourceFormat, model, text?: string) {
|
||||
const openaiResponse = createOpenAIResponse(model, text);
|
||||
const state = initState(sourceFormat);
|
||||
state.model = model;
|
||||
|
||||
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
|
||||
const translatedChunks: string[] = [];
|
||||
|
||||
for (const chunk of openaiChunks) {
|
||||
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
|
||||
if (translated?.length > 0) {
|
||||
for (const item of translated) translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
}
|
||||
|
||||
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
|
||||
if (flushed?.length > 0) {
|
||||
for (const item of flushed) translatedChunks.push(formatSSE(item, sourceFormat));
|
||||
}
|
||||
|
||||
translatedChunks.push("data: [DONE]\n\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(translatedChunks.join(""), {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
74
tests/unit/bypass-response-claude-merge.test.ts
Normal file
74
tests/unit/bypass-response-claude-merge.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { mergeChunksToResponse } from "../../open-sse/utils/bypassResponse.ts";
|
||||
|
||||
/**
|
||||
* Regression guard for the Claude-format non-streaming bypass response bug:
|
||||
* mergeChunksToResponse() used to return `messageStart.message` as-is, which
|
||||
* the openai-to-claude translator always initializes with `content: []` —
|
||||
* the actual text only exists in the separate content_block_start/delta
|
||||
* events. A synthetic (non-streaming) Claude-format bypass response
|
||||
* therefore always came back with an empty `content` array, silently
|
||||
* dropping the bypass text ("CLI Command Execution: Clear Terminal", etc.)
|
||||
* from every Claude-format client (e.g. the Claude Code CLI).
|
||||
*/
|
||||
describe("mergeChunksToResponse (Claude format content reconstruction)", () => {
|
||||
const chunks = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "demo",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 2 },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello world" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 3 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
it("reconstructs the message content from content_block_start/delta chunks", () => {
|
||||
const result = mergeChunksToResponse(chunks, "claude") as Record<string, unknown>;
|
||||
|
||||
assert.equal(result.type, "message");
|
||||
assert.equal(result.role, "assistant");
|
||||
assert.deepEqual(result.content, [{ type: "text", text: "hello world" }]);
|
||||
});
|
||||
|
||||
it("merges start + delta usage and carries the final stop_reason", () => {
|
||||
const result = mergeChunksToResponse(chunks, "claude") as Record<string, unknown>;
|
||||
|
||||
assert.equal(result.stop_reason, "end_turn");
|
||||
assert.deepEqual(result.usage, {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the last chunk untouched for non-Claude formats", () => {
|
||||
const openaiChunks = [{ type: "chat.completion.chunk", choices: [] }];
|
||||
assert.equal(mergeChunksToResponse(openaiChunks, "openai"), openaiChunks[0]);
|
||||
});
|
||||
|
||||
it("falls back to a canned unknown response for an empty chunk list", () => {
|
||||
const result = mergeChunksToResponse([], "claude") as {
|
||||
model: string;
|
||||
choices: Array<{ message: { role: string } }>;
|
||||
};
|
||||
assert.equal(result.model, "unknown");
|
||||
assert.equal(result.choices[0].message.role, "assistant");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user