9router/open-sse/translator/response/kiro-to-claude.js

277 lines
9.2 KiB
JavaScript

/**
* Kiro → Claude Response Translator (DIRECT route, no OpenAI pivot)
*
* IMPORTANT: This translator does NOT receive raw Kiro AWS-EventStream frames.
* KiroExecutor.transformEventStreamToSSE() (open-sse/executors/kiro.js) already
* parses the binary EventStream and emits OpenAI-shaped
* `chat.completion.chunk` objects. So the chunks arriving here are OpenAI
* streaming chunks, and our job is OpenAI-chunk → Claude SSE events — the same
* transformation openai-to-claude.js performs. We re-implement it here so the
* direct `kiro:claude` route is self-contained and lossless (reasoning_content
* → thinking blocks, tool_calls → tool_use blocks, usage → message_delta).
*
* Registered on the direct route by ../index.js; reached only when source
* format is Claude and target is Kiro.
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
function stopThinkingBlock(state, results) {
if (!state.thinkingBlockStarted) return;
results.push({ type: "content_block_stop", index: state.thinkingBlockIndex });
state.thinkingBlockStarted = false;
}
function stopTextBlock(state, results) {
if (!state.textBlockStarted || state.textBlockClosed) return;
state.textBlockClosed = true;
results.push({ type: "content_block_stop", index: state.textBlockIndex });
state.textBlockStarted = false;
}
function convertFinishReason(reason) {
switch (reason) {
case "stop":
return "end_turn";
case "length":
return "max_tokens";
case "tool_calls":
return "tool_use";
default:
return "end_turn";
}
}
/**
* Convert one OpenAI-format chunk (from KiroExecutor) into Claude SSE events.
* Returns an array of Claude events, or null when the chunk yields nothing.
*/
export function kiroToClaudeResponse(chunk, state) {
// KiroExecutor emits chat.completion.chunk objects; tolerate string chunks
// by attempting a parse (defensive — the direct path is always objects).
let data = chunk;
if (typeof chunk === "string") {
const trimmed = chunk.trim();
if (!trimmed || trimmed === "[DONE]") return null;
try {
data = JSON.parse(trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed);
} catch {
return null;
}
}
if (!data || !data.choices?.[0]) return null;
const results = [];
const choice = data.choices[0];
const delta = choice.delta || {};
// Track usage if present on the chunk.
if (data.usage && typeof data.usage === "object") {
const promptTokens =
typeof data.usage.prompt_tokens === "number" ? data.usage.prompt_tokens : 0;
const outputTokens =
typeof data.usage.completion_tokens === "number"
? data.usage.completion_tokens
: 0;
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
// Claude clients read cache_read/cache_creation to price a turn and to size
// their prompt cache. Both spellings are accepted because the Kiro executor
// emits the Chat shape and passthrough responses use the nested details form.
const cacheRead = data.usage.cache_read_input_tokens
?? data.usage.prompt_tokens_details?.cached_tokens;
const cacheCreation = data.usage.cache_creation_input_tokens
?? data.usage.prompt_tokens_details?.cache_creation_tokens;
if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead;
if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation;
}
// First chunk → emit message_start.
if (!state.messageStartSent) {
state.messageStartSent = true;
state.messageId =
(typeof data.id === "string" && data.id.replace("chatcmpl-", "")) ||
`msg_${Date.now()}`;
state.model = data.model || "kiro";
state.nextBlockIndex = 0;
results.push({
type: "message_start",
message: {
id: state.messageId,
type: "message",
role: "assistant",
model: state.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
});
}
// Reasoning / thinking content (Kiro reasoningContentEvent → reasoning_content).
const reasoningContent = delta.reasoning_content || delta.reasoning;
if (reasoningContent) {
stopTextBlock(state, results);
if (!state.thinkingBlockStarted) {
state.thinkingBlockIndex = state.nextBlockIndex++;
state.thinkingBlockStarted = true;
results.push({
type: "content_block_start",
index: state.thinkingBlockIndex,
content_block: { type: "thinking", thinking: "" },
});
}
results.push({
type: "content_block_delta",
index: state.thinkingBlockIndex,
delta: { type: "thinking_delta", thinking: reasoningContent },
});
}
// Regular text content.
if (delta.content) {
stopThinkingBlock(state, results);
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: delta.content },
});
}
// Tool calls.
if (delta.tool_calls) {
if (!state.toolCalls) state.toolCalls = new Map();
if (!state.toolArgBuffers) state.toolArgBuffers = new Map();
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (tc.id) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
const toolBlockIndex = state.nextBlockIndex++;
state.toolCalls.set(idx, {
id: tc.id,
name: tc.function?.name || "",
blockIndex: toolBlockIndex,
});
results.push({
type: "content_block_start",
index: toolBlockIndex,
content_block: {
type: "tool_use",
id: tc.id,
name: tc.function?.name || "",
input: {},
},
});
}
if (tc.function?.arguments) {
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
state.toolArgBuffers.set(
idx,
(state.toolArgBuffers.get(idx) || "") + tc.function.arguments
);
}
}
}
}
// Finish.
if (choice.finish_reason) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
if (state.toolCalls) {
for (const [idx, toolInfo] of state.toolCalls) {
const buffered = state.toolArgBuffers?.get(idx);
if (buffered) {
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: buffered },
});
}
results.push({ type: "content_block_stop", index: toolInfo.blockIndex });
}
}
state.finishReason = choice.finish_reason;
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
results.push({
type: "message_delta",
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
usage: finalUsage,
});
results.push({ type: "message_stop" });
}
return results.length > 0 ? results : null;
}
/**
* Non-streaming Kiro → Claude. KiroExecutor only produces a stream, so this is
* a defensive helper for any non-streaming caller that hands us an aggregated
* OpenAI-shaped completion.
*/
export function kiroToClaudeNonStreaming(data) {
const content = [];
const choice = data?.choices?.[0];
const message = choice?.message || {};
if (message.content) {
content.push({ type: "text", text: message.content });
}
if (Array.isArray(message.tool_calls)) {
for (const tc of message.tool_calls) {
let input = {};
try {
input =
typeof tc.function?.arguments === "string"
? JSON.parse(tc.function.arguments)
: tc.function?.arguments || {};
} catch {
input = {};
}
content.push({
type: "tool_use",
id: tc.id || `toolu_${Date.now()}`,
name: tc.function?.name || "",
input,
});
}
}
const usage = data?.usage || {};
return {
id: `msg_${Date.now()}`,
type: "message",
role: "assistant",
content,
model: data?.model || "kiro",
stop_reason: convertFinishReason(choice?.finish_reason || "stop"),
usage: {
input_tokens: usage.prompt_tokens || 0,
output_tokens: usage.completion_tokens || 0,
// Same cache preservation as the streaming path above.
...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number"
? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens }
: {}),
...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number"
? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens }
: {}),
},
};
}
register(FORMATS.KIRO, FORMATS.CLAUDE, null, kiroToClaudeResponse);