Files
9router/open-sse/translator/response/kiro-to-claude.js
T
706e6513c9 feat(kiro): headless API-key auth + direct Claude/Kiro route
Adds long-lived API-key (ksk_) authentication for Kiro/AWS CodeWhisperer
and a direct claude:kiro / kiro:claude translation route that avoids the
lossy OpenAI two-hop pivot.

- translator: claude-to-kiro request + kiro-to-claude response translators,
  registered on the exact source:target pair (direct route ahead of the
  OpenAI pivot in index.js). claude-to-kiro uses shared schema constants
  (ROLE/CLAUDE_BLOCK/DEFAULT_IMAGE_MIME) per app convention.
- auth: POST /api/oauth/kiro/api-key imports + validates a key via
  ListAvailableProfiles, persists authMethod="api_key" (no refresh token).
- executor: send tokentype: API_KEY header and try *.amazonaws.com hosts
  first for api-key creds; OAuth keeps kiro.dev first.
- fix: never inject the default placeholder profileArn for api-key auth
  (CodeWhisperer 403s an ARN not owned by the key's account).
- ui: API Key method in the Kiro connect modal; surface api-key accounts
  on the Quota Tracker and provider count.
- stream: env-overridable TTFT vs stall timeouts + Kiro keepalive frame.
- tests: claude-kiro-direct + kiro-profile-arn (11 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:01:30 +07:00

262 lines
7.9 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 };
}
// 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,
},
};
}
register(FORMATS.KIRO, FORMATS.CLAUDE, null, kiroToClaudeResponse);