mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
fix: preserve Claude Code rendering on responses translation
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
|
||||
function normalizeToolName(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate OpenAI chunk to Responses API events
|
||||
* @returns {Array} Array of events with { event, data } structure
|
||||
@@ -477,6 +481,16 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
|
||||
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
|
||||
state.currentToolCallDeferred = false;
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
// Defer emission until output_item.done in case the final name is populated there.
|
||||
state.currentToolCallDeferred = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
@@ -493,7 +507,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
id: state.currentToolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name || "",
|
||||
name: toolName,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
@@ -513,6 +527,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
const argsDelta = data.delta || "";
|
||||
if (!argsDelta) return null;
|
||||
|
||||
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
|
||||
if (state.currentToolCallDeferred) return null;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
@@ -535,9 +552,93 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
// Function call done
|
||||
// Function call done — emit args chunk from item.arguments when no deltas were received,
|
||||
// then advance the tool-call index. This handles Codex Responses API payloads that
|
||||
// carry the complete arguments only in output_item.done (no preceding delta events).
|
||||
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
const buffered = state.currentToolCallArgsBuffer || "";
|
||||
const currentIndex = state.toolCallIndex; // capture before increment
|
||||
const callId = item.call_id || state.currentToolCallId || `call_${Date.now()}`;
|
||||
const toolName = normalizeToolName(item.name);
|
||||
|
||||
if (state.currentToolCallDeferred) {
|
||||
state.currentToolCallDeferred = false;
|
||||
state.currentToolCallArgsBuffer = "";
|
||||
state.currentToolCallId = null;
|
||||
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
|
||||
const argsStr =
|
||||
item.arguments != null
|
||||
? typeof item.arguments === "string"
|
||||
? item.arguments
|
||||
: JSON.stringify(item.arguments)
|
||||
: buffered;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
id: callId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: argsStr || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
state.currentToolCallArgsBuffer = ""; // reset for next tool call
|
||||
state.currentToolCallId = null;
|
||||
|
||||
// Only emit if arguments exist in the done event AND they weren't already streamed via deltas
|
||||
if (item.arguments != null && !buffered) {
|
||||
const argsStr =
|
||||
typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments);
|
||||
if (argsStr) {
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
function: { arguments: argsStr },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -611,11 +712,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: reasoningDelta },
|
||||
finish_reason: null,
|
||||
}],
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { reasoning_content: reasoningDelta },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
filterUsageForFormat,
|
||||
COLORS,
|
||||
} from "./usageTracking.ts";
|
||||
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE, unwrapGeminiChunk } from "./streamHelpers.ts";
|
||||
import {
|
||||
parseSSELine,
|
||||
hasValuableContent,
|
||||
fixInvalidId,
|
||||
formatSSE,
|
||||
unwrapGeminiChunk,
|
||||
} from "./streamHelpers.ts";
|
||||
import {
|
||||
createStructuredSSECollector,
|
||||
buildStreamSummaryFromEvents,
|
||||
@@ -585,9 +591,12 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// Content for call log is accumulated only from parsed (above) to avoid double-counting;
|
||||
// do not add again from item here.
|
||||
|
||||
// #723, #727: Sanitize intermediate stream chunks if target is OpenAI format loop
|
||||
// #723, #727: Sanitize only when the client-facing stream is OpenAI Chat format.
|
||||
// When translating Responses -> Claude, `item` is already a Claude SSE event;
|
||||
// sanitizing it as an OpenAI chunk strips message_start/content_block_delta/message_stop
|
||||
// and causes Claude Code to drop the assistant message.
|
||||
let itemSanitized: Record<string, unknown> = item;
|
||||
if (targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
if (sourceFormat === FORMATS.OPENAI) {
|
||||
itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record<string, unknown>;
|
||||
|
||||
// Extract reasoning tags from content if translation generated them
|
||||
|
||||
195
tests/unit/claude-code-rendering-fixes.test.mjs
Normal file
195
tests/unit/claude-code-rendering-fixes.test.mjs
Normal file
@@ -0,0 +1,195 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiResponsesToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/openai-responses.ts"
|
||||
);
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts");
|
||||
|
||||
test("Responses->Chat: output_item.done emits arguments when no delta chunks were sent", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: "",
|
||||
};
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "search_tasks",
|
||||
status: "completed",
|
||||
arguments: '{"query":"select:TaskCreate,TaskUpdate","max_results":10}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(
|
||||
result.choices[0].delta.tool_calls[0].function.arguments,
|
||||
'{"query":"select:TaskCreate,TaskUpdate","max_results":10}'
|
||||
);
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done does not re-emit arguments already streamed via deltas", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: '{"query":"search"}',
|
||||
};
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "search",
|
||||
status: "completed",
|
||||
arguments: '{"query":"search"}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: empty-name tool call is deferred until done provides a valid name", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallArgsBuffer: "",
|
||||
currentToolCallDeferred: false,
|
||||
};
|
||||
|
||||
const added = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_deferred", name: " " },
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(added, null);
|
||||
|
||||
const delta = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"query":"deferred"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(delta, null);
|
||||
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_deferred",
|
||||
name: "search_tasks",
|
||||
arguments: '{"query":"deferred"}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(done);
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.name, "search_tasks");
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"query":"deferred"}');
|
||||
});
|
||||
|
||||
test("Responses->Chat: empty-name tool call is dropped when done still has no valid name", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallArgsBuffer: "",
|
||||
currentToolCallDeferred: false,
|
||||
};
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_empty", name: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_empty",
|
||||
name: " ",
|
||||
arguments: '{"ignored":true}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(done, null);
|
||||
assert.equal(state.toolCallIndex, 0);
|
||||
});
|
||||
|
||||
test("Responses->Claude: translated Claude SSE is not sanitized into empty OpenAI chunks", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = createSSETransformStreamWithLogger(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.CLAUDE,
|
||||
"codex",
|
||||
null,
|
||||
null,
|
||||
"gpt-5.4",
|
||||
"conn-test",
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
const writer = stream.writable.getWriter();
|
||||
await writer.write(
|
||||
encoder.encode('data: {"type":"response.output_text.delta","delta":"hello"}\n\n')
|
||||
);
|
||||
await writer.write(
|
||||
encoder.encode(
|
||||
'data: {"type":"response.completed","response":{"usage":{"input_tokens":12,"output_tokens":3}}}\n\n'
|
||||
)
|
||||
);
|
||||
await writer.close();
|
||||
|
||||
const reader = stream.readable.getReader();
|
||||
let output = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
output += decoder.decode();
|
||||
|
||||
assert.match(output, /event: message_start/);
|
||||
assert.match(output, /event: content_block_start/);
|
||||
assert.match(output, /event: content_block_delta/);
|
||||
assert.match(output, /event: message_delta/);
|
||||
assert.match(output, /event: message_stop/);
|
||||
assert.doesNotMatch(output, /data: \{"object":"chat\.completion\.chunk"\}\n\n/);
|
||||
});
|
||||
Reference in New Issue
Block a user