This commit is contained in:
decolua 2026-01-16 13:09:13 +07:00
parent 6b22b1f490
commit 1d481c2862

View file

@ -58,9 +58,9 @@ export class KiroExecutor extends BaseExecutor {
/** /**
* Transform AWS EventStream binary response to SSE text stream * Transform AWS EventStream binary response to SSE text stream
* Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout
*/ */
transformEventStreamToSSE(response, model) { transformEventStreamToSSE(response, model) {
const reader = response.body.getReader();
let buffer = new Uint8Array(0); let buffer = new Uint8Array(0);
let chunkIndex = 0; let chunkIndex = 0;
const responseId = `chatcmpl-${Date.now()}`; const responseId = `chatcmpl-${Date.now()}`;
@ -70,56 +70,30 @@ export class KiroExecutor extends BaseExecutor {
finishEmitted: false, finishEmitted: false,
hasToolCalls: false, hasToolCalls: false,
toolCallIndex: 0, toolCallIndex: 0,
seenToolIds: new Map() // Map toolUseId -> index seenToolIds: new Map()
}; };
const stream = new ReadableStream({ const transformStream = new TransformStream({
async pull(controller) { async transform(chunk, controller) {
try {
const { done, value } = await reader.read();
if (done) {
// Emit finish_reason chunk if not already sent
if (!state.finishEmitted) {
state.finishEmitted = true;
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
// Send final done message
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
return;
}
// Append to buffer // Append to buffer
const newBuffer = new Uint8Array(buffer.length + value.length); const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer); newBuffer.set(buffer);
newBuffer.set(value, buffer.length); newBuffer.set(chunk, buffer.length);
buffer = newBuffer; buffer = newBuffer;
// Parse events from buffer // Parse events from buffer
while (buffer.length >= 16) { let iterations = 0;
const maxIterations = 1000;
while (buffer.length >= 16 && iterations < maxIterations) {
iterations++;
const view = new DataView(buffer.buffer, buffer.byteOffset); const view = new DataView(buffer.buffer, buffer.byteOffset);
const totalLength = view.getUint32(0, false); const totalLength = view.getUint32(0, false);
if (totalLength < 16 || buffer.length < totalLength) break; if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break;
// Extract event
const eventData = buffer.slice(0, totalLength); const eventData = buffer.slice(0, totalLength);
buffer = buffer.slice(totalLength); buffer = buffer.slice(totalLength);
// Parse event headers and payload
const event = parseEventFrame(eventData); const event = parseEventFrame(eventData);
if (!event) continue; if (!event) continue;
@ -163,25 +137,19 @@ export class KiroExecutor extends BaseExecutor {
// Handle toolUseEvent // Handle toolUseEvent
if (eventType === "toolUseEvent" && event.payload) { if (eventType === "toolUseEvent" && event.payload) {
state.hasToolCalls = true;
state.hasToolCalls = true; // Track that we have tool calls
const toolUse = event.payload; const toolUse = event.payload;
// AWS Kiro sends toolUse as object or array
// If it's an array, process each tool separately
const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse];
for (const singleToolUse of toolUses) { for (const singleToolUse of toolUses) {
const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`;
const toolName = singleToolUse.name || ""; const toolName = singleToolUse.name || "";
const toolInput = singleToolUse.input; // Can be undefined, string, or object const toolInput = singleToolUse.input;
// Get or assign tool call index
let toolIndex; let toolIndex;
const isNewTool = !state.seenToolIds.has(toolCallId); const isNewTool = !state.seenToolIds.has(toolCallId);
if (isNewTool) { if (isNewTool) {
// NEW TOOL: Create start chunk
toolIndex = state.toolCallIndex++; toolIndex = state.toolCallIndex++;
state.seenToolIds.set(toolCallId, toolIndex); state.seenToolIds.set(toolCallId, toolIndex);
@ -210,23 +178,17 @@ export class KiroExecutor extends BaseExecutor {
chunkIndex++; chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)); controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`));
} else { } else {
// EXISTING TOOL: Get its index
toolIndex = state.seenToolIds.get(toolCallId); toolIndex = state.seenToolIds.get(toolCallId);
} }
// Emit arguments chunk if input exists
// AWS Kiro streams input as: undefined (first event) → string chunks
if (toolInput !== undefined) { if (toolInput !== undefined) {
let argumentsStr; let argumentsStr;
if (typeof toolInput === 'string') { if (typeof toolInput === 'string') {
// AWS Kiro sends partial JSON as STRING
argumentsStr = toolInput; argumentsStr = toolInput;
} else if (typeof toolInput === 'object') { } else if (typeof toolInput === 'object') {
// Fallback: if it's an object, stringify it
argumentsStr = JSON.stringify(toolInput); argumentsStr = JSON.stringify(toolInput);
} else { } else {
// Skip if not string or object
continue; continue;
} }
@ -271,12 +233,9 @@ export class KiroExecutor extends BaseExecutor {
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
} }
// Detect end of stream: meteringEvent + contextUsageEvent usually come last // Detect end of stream
// Kiro doesn't always send messageStopEvent, so we need to detect completion
if ((eventType === "meteringEvent" || eventType === "contextUsageEvent") && !state.endDetected) { if ((eventType === "meteringEvent" || eventType === "contextUsageEvent") && !state.endDetected) {
state.endDetected = true; state.endDetected = true;
// Schedule finish chunk emission after a short delay
setTimeout(() => {
if (!state.finishEmitted) { if (!state.finishEmitted) {
state.finishEmitted = true; state.finishEmitted = true;
const finishChunk = { const finishChunk = {
@ -292,20 +251,41 @@ export class KiroExecutor extends BaseExecutor {
}; };
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
} }
}, 100); // 100ms delay to check for more events
} }
} }
} catch (error) {
controller.error(error); if (iterations >= maxIterations) {
console.warn("[Kiro] Max iterations reached in event parsing");
} }
}, },
cancel() {
reader.cancel(); flush(controller) {
// Emit finish chunk if not already sent
if (!state.finishEmitted) {
state.finishEmitted = true;
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
// Send final done message
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
} }
}); });
// Create new response with SSE headers // Pipe response body through transform stream
return new Response(stream, { const transformedStream = response.body.pipeThrough(transformStream);
return new Response(transformedStream, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: { headers: {