diff --git a/open-sse/utils/bypassHandler.ts b/open-sse/utils/bypassHandler.ts index 618d1f4db5..d2fd3b600b 100644 --- a/open-sse/utils/bypassHandler.ts +++ b/open-sse/utils/bypassHandler.ts @@ -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, - }, - ]; -} diff --git a/open-sse/utils/bypassResponse.ts b/open-sse/utils/bypassResponse.ts new file mode 100644 index 0000000000..87f4b7c12c --- /dev/null +++ b/open-sse/utils/bypassResponse.ts @@ -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", + }, + }), + }; +} diff --git a/tests/unit/bypass-response-claude-merge.test.ts b/tests/unit/bypass-response-claude-merge.test.ts new file mode 100644 index 0000000000..1428d7b923 --- /dev/null +++ b/tests/unit/bypass-response-claude-merge.test.ts @@ -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; + + 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; + + 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"); + }); +});