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>
This commit is contained in:
thienpv
2026-06-17 10:01:30 +07:00
committed by decolua
parent 3de6ce157c
commit 706e6513c9
20 changed files with 1437 additions and 57 deletions

View File

@@ -31,11 +31,23 @@ export const MEMORY_CONFIG = {
proxyDispatchersMaxSize: 20,
};
// Stream stall timeout: abort if no chunk received within this duration
export const STREAM_STALL_TIMEOUT_MS = 60 * 1000;
// Parse a positive integer env override, falling back to a default.
function envMs(name, def) {
const raw = process.env[name];
if (raw == null || raw === "") return def;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : def;
}
// Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
// slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
// Time-to-first-token timeout (prompt prefill). Env: STREAM_FIRST_CHUNK_TIMEOUT_MS.
export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_MS", 200 * 1000);
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration
export const FETCH_CONNECT_TIMEOUT_MS = 60 * 1000;
export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
// Default token limits
export const DEFAULT_MAX_TOKENS = 64000;

View File

@@ -20,13 +20,50 @@ export class KiroExecutor extends BaseExecutor {
"Amz-Sdk-Invocation-Id": uuidv4()
};
if (credentials.accessToken) {
// API-key auth: the key is stored as accessToken and sent as a bearer token
// exactly like an OAuth access token, but with an extra `tokentype: API_KEY`
// header so CodeWhisperer treats it as a long-lived API key rather than an
// OIDC/social access token. Mirrors the Kiro IDE headless-auth behavior.
const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key";
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
if (isApiKey && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
headers["tokentype"] = "API_KEY";
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
return headers;
}
/**
* Auth-aware endpoint ordering.
*
* API-key Kiro connections store a raw CodeWhisperer credential (validated
* against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles).
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
* and rejects an `tokentype: API_KEY` token with 401/403 — which
* BaseExecutor.execute() returns immediately (only 429 / network errors fall
* through to the next host). So for api-key auth we must try the *.amazonaws.com
* CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never
* routes api-key traffic through kiro.dev. OAuth keeps the default order
* (kiro.dev first) since its token is what that gateway accepts.
*/
getOrderedBaseUrls(credentials) {
const baseUrls = this.getBaseUrls();
const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key";
if (!isApiKey) return baseUrls;
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com"));
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
const baseUrls = this.getOrderedBaseUrls(credentials);
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
}
transformRequest(model, body, stream, credentials) {
return body;
}
@@ -38,6 +75,8 @@ export class KiroExecutor extends BaseExecutor {
* BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev →
* codewhisperer → q) advancing to the next host on 429 (shouldRetry) and on
* network/5xx errors, while tryRetry handles in-place retries per `retry: {429: 2}`.
* Note: api-key connections reorder these so the *.amazonaws.com hosts come
* first — see getOrderedBaseUrls/buildUrl above.
* Note: the baseUrls are alternate surfaces of one regional service, so rotation
* is edge-level failover — it does not grant fresh 429 quota. Per-account 429
* spreading is handled upstream by account rotation in sse/handlers/chat.js.
@@ -74,6 +113,8 @@ export class KiroExecutor extends BaseExecutor {
const transformStream = new TransformStream({
async transform(chunk, controller) {
// Track output so we can emit a keepalive if this frame yields no chunk.
const enqueueCountBefore = chunkIndex;
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
@@ -97,7 +138,7 @@ export class KiroExecutor extends BaseExecutor {
if (!event) continue;
const eventType = event.headers[":event-type"] || "";
// Track total content length for token estimation
if (!state.totalContentLength) state.totalContentLength = 0;
if (!state.contextUsagePercentage) state.contextUsagePercentage = 0;
@@ -106,7 +147,7 @@ export class KiroExecutor extends BaseExecutor {
if (eventType === "assistantResponseEvent" && event.payload?.content) {
const content = event.payload.content;
state.totalContentLength += content.length;
const chunk = {
id: responseId,
object: "chat.completion.chunk",
@@ -293,7 +334,7 @@ export class KiroExecutor extends BaseExecutor {
if (metrics && typeof metrics === 'object') {
const inputTokens = metrics.inputTokens || 0;
const outputTokens = metrics.outputTokens || 0;
if (inputTokens > 0 || outputTokens > 0) {
state.usage = {
prompt_tokens: inputTokens,
@@ -307,27 +348,27 @@ export class KiroExecutor extends BaseExecutor {
// Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent
if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) {
state.finishEmitted = true;
// Estimate tokens if not available from events
if (!state.usage) {
// Estimate output tokens from content length
const estimatedOutputTokens = state.totalContentLength > 0
const estimatedOutputTokens = state.totalContentLength > 0
? Math.max(1, Math.floor(state.totalContentLength / 4))
: 0;
// Estimate input tokens from contextUsagePercentage
// Kiro models typically have 200k context window
const estimatedInputTokens = state.contextUsagePercentage > 0
? Math.floor(state.contextUsagePercentage * 200000 / 100)
: 0;
state.usage = {
prompt_tokens: estimatedInputTokens,
completion_tokens: estimatedOutputTokens,
total_tokens: estimatedInputTokens + estimatedOutputTokens
};
}
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
@@ -339,12 +380,12 @@ export class KiroExecutor extends BaseExecutor {
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
// Include usage in final chunk if available
if (state.usage) {
finishChunk.usage = state.usage;
}
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
}
@@ -352,6 +393,12 @@ export class KiroExecutor extends BaseExecutor {
if (iterations >= maxIterations) {
console.warn("[Kiro] Max iterations reached in event parsing");
}
// No client chunk produced this frame — emit an SSE comment keepalive
// so the stall watchdog sees upstream activity (ignored by parser/client).
if (chunkIndex === enqueueCountBefore && !state.finishEmitted) {
controller.enqueue(new TextEncoder().encode(": ka\n\n"));
}
},
flush(controller) {

View File

@@ -87,5 +87,6 @@ export default {
},
features: {
usage: true,
usageApikey: true,
},
};

View File

@@ -50,7 +50,19 @@ function parseKiroQuotaData(data) {
export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) {
const authMethod = providerSpecificData?.authMethod || "builder-id";
const profileArn = providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod);
// API-key Kiro connections authenticate the quota API the same way the chat
// executor does: a bearer token plus a `tokentype: API_KEY` header so
// CodeWhisperer treats it as a long-lived API key rather than an OIDC token.
// Without this header the GetUsageLimits call is rejected (401/403).
const isApiKey = authMethod === "api_key";
const apiKeyHeaders = isApiKey ? { tokentype: "API_KEY" } : {};
// For api-key auth, never inject the shared default placeholder profileArn —
// CodeWhisperer 403s a request whose profileArn isn't owned by the key's
// account. Only send a profileArn actually resolved for this connection.
const profileArn = isApiKey
? (providerSpecificData?.profileArn || "")
: (providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
const getUsageParams = new URLSearchParams({
isEmailRequired: "true",
@@ -71,6 +83,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
"Accept": "application/json",
"x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE",
"user-agent": "aws-sdk-js/1.0.0 KiroIDE",
...apiKeyHeaders,
},
},
proxyOptions
@@ -85,10 +98,11 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
"Accept": "application/json",
...apiKeyHeaders,
},
body: JSON.stringify({
origin: "AI_EDITOR",
profileArn,
...(profileArn ? { profileArn } : {}),
resourceType: "AGENTIC_REQUEST",
}),
}, proxyOptions),
@@ -98,7 +112,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
run: async () => {
const params = new URLSearchParams({
origin: "AI_EDITOR",
profileArn,
...(profileArn ? { profileArn } : {}),
resourceType: "AGENTIC_REQUEST",
});
return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, {
@@ -106,6 +120,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
...apiKeyHeaders,
},
}, proxyOptions);
},

View File

@@ -76,21 +76,29 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// Step 1: source -> openai (if source is not openai)
if (sourceFormat !== FORMATS.OPENAI) {
const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`);
if (toOpenAI) {
result = toOpenAI(model, result, stream, credentials);
// Log OpenAI intermediate format
reqLogger?.logOpenAIRequest?.(result);
// Direct route: if a translator is registered for this exact source:target
// pair, use it instead of pivoting through OpenAI. This is lossless for
// pairs like claude:kiro (avoids the claude->openai->kiro double-hop).
const directFn = requestRegistry.get(`${sourceFormat}:${targetFormat}`);
if (directFn) {
result = directFn(model, result, stream, credentials);
} else {
// Step 1: source -> openai (if source is not openai)
if (sourceFormat !== FORMATS.OPENAI) {
const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`);
if (toOpenAI) {
result = toOpenAI(model, result, stream, credentials);
// Log OpenAI intermediate format
reqLogger?.logOpenAIRequest?.(result);
}
}
}
// Step 2: openai -> target (if target is not openai)
if (targetFormat !== FORMATS.OPENAI) {
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
if (fromOpenAI) {
result = fromOpenAI(model, result, stream, credentials);
// Step 2: openai -> target (if target is not openai)
if (targetFormat !== FORMATS.OPENAI) {
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
if (fromOpenAI) {
result = fromOpenAI(model, result, stream, credentials);
}
}
}
}
@@ -146,6 +154,16 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
let results = [chunk];
let openaiResults = null; // Store OpenAI intermediate results
// Direct route: if a response translator is registered for this exact
// target:source pair, use it instead of pivoting through OpenAI. Mirrors the
// request-side direct route (e.g. kiro:claude — KiroExecutor already emits
// OpenAI-shaped chunks, so this converts them straight to Claude SSE).
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
if (directFn) {
const converted = directFn(chunk, state);
return converted ? (Array.isArray(converted) ? converted : [converted]) : [];
}
// Step 1: target -> openai (if target is not openai)
if (targetFormat !== FORMATS.OPENAI) {
const toOpenAI = responseRegistry.get(`${targetFormat}:${FORMATS.OPENAI}`);
@@ -251,6 +269,7 @@ import "./request/openai-to-kiro.js";
import "./request/openai-to-cursor.js";
import "./request/openai-to-ollama.js";
import "./request/openai-to-commandcode.js";
import "./request/claude-to-kiro.js";
import "./response/claude-to-openai.js";
import "./response/openai-to-claude.js";
import "./response/gemini-to-openai.js";
@@ -260,3 +279,4 @@ import "./response/kiro-to-openai.js";
import "./response/cursor-to-openai.js";
import "./response/ollama-to-openai.js";
import "./response/commandcode-to-openai.js";
import "./response/kiro-to-claude.js";

View File

@@ -0,0 +1,463 @@
/**
* Claude → Kiro Request Translator (DIRECT route, no OpenAI pivot)
*
* Converts Anthropic Messages API requests straight to Kiro / AWS
* CodeWhisperer `GenerateAssistantResponse` payloads. This is the function the
* direct `claude:kiro` route in ../index.js uses; it is NOT reached through the
* claude→openai→kiro pivot.
*
* It reproduces the two 400-guards that live in openai-to-kiro.js so that a
* Claude client which omits the `tools` array on a follow-up turn (typical
* after client-side compaction) does not trip Kiro's schema validator and get
* "Improperly formed request" (HTTP 400):
*
* 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse
* every tool_use / tool_result block to plain text so no structured tool
* reference survives to trigger the "tools required" rule.
* 2. reconcileOrphanedToolResults — when tools ARE present, fold any
* tool_result whose tool_use_id has no matching tool_use back into the
* user text instead of leaving a dangling structured reference.
*
* It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and
* the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching
* buildKiroPayload.
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import {
resolveKiroModel,
isThinkingEnabled,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
} from "../../config/kiroConstants.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
/** Stringify a tool_use input as a readable line. */
function toolUseToText(name, input) {
let argStr;
try {
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
} catch {
argStr = "{}";
}
return `[Tool call: ${name || "unknown"}(${argStr})]`;
}
/** Render a Claude tool_result block's content as a readable line. */
function toolResultBlockToText(content) {
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.map((c) => (typeof c === "string" ? c : c?.text || ""))
.filter(Boolean)
.join("\n");
} else if (content) {
try {
text = JSON.stringify(content);
} catch {
text = "";
}
}
return `[Tool result: ${text}]`;
}
/**
* When the client sent no tools, rewrite every tool_use (assistant) and
* tool_result (user) content block into plain text. Keeps text + images.
* Returns a new messages array; never mutates the input.
*/
function flattenClaudeToolInteractions(messages) {
const out = [];
for (const msg of messages) {
if (!msg) continue;
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
const parts = [];
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.TEXT && block.text) {
parts.push(block.text);
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
parts.push(toolUseToText(block.name, block.input));
}
}
out.push({ ...msg, content: parts.join("\n") });
continue;
}
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
const newContent = msg.content.map((block) =>
block.type === CLAUDE_BLOCK.TOOL_RESULT
? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) }
: block
);
out.push({ ...msg, content: newContent });
continue;
}
out.push(msg);
}
return out;
}
/**
* Convert Claude messages to Kiro history + currentMessage.
* Kiro requires alternating user/assistant turns; consecutive same-role
* messages are merged.
*/
function convertClaudeMessagesToKiro(messages, tools, model) {
const history = [];
let currentMessage = null;
let pendingUserContent = [];
let pendingAssistantContent = [];
let pendingToolResults = [];
let pendingImages = [];
let currentRole = null;
let toolsInjected = false;
const clientProvidedTools = Array.isArray(tools) && tools.length > 0;
const buildToolSpecs = () =>
tools.map((t) => {
const name = t.name;
const description = t.description || `Tool: ${name}`;
const schema = t.input_schema || {};
const normalizedSchema =
Object.keys(schema).length === 0
? { type: "object", properties: {}, required: [] }
: { ...schema, required: schema.required ?? [] };
return {
toolSpecification: {
name,
description,
inputSchema: { json: normalizedSchema },
},
};
});
const flushPending = () => {
if (currentRole === ROLE.USER) {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const userMsg = { userInputMessage: { content, modelId: model } };
if (pendingImages.length > 0) {
userMsg.userInputMessage.images = pendingImages;
}
if (pendingToolResults.length > 0) {
userMsg.userInputMessage.userInputMessageContext = {
toolResults: pendingToolResults,
};
}
// Attach tools to the first user turn only.
if (clientProvidedTools && !toolsInjected) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs();
toolsInjected = true;
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
pendingToolResults = [];
pendingImages = [];
} else if (currentRole === ROLE.ASSISTANT) {
const content = pendingAssistantContent.join("\n\n").trim() || "...";
history.push({ assistantResponseMessage: { content } });
pendingAssistantContent = [];
}
};
for (const msg of messages) {
const role = msg.role;
if (role !== currentRole && currentRole !== null) flushPending();
currentRole = role;
if (role === ROLE.USER) {
if (typeof msg.content === "string") {
pendingUserContent.push(msg.content);
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.TEXT) {
pendingUserContent.push(block.text);
} else if (block.type === CLAUDE_BLOCK.IMAGE && block.source?.type === "base64") {
const mediaType = block.source.media_type || DEFAULT_IMAGE_MIME;
const format = mediaType.split("/")[1] || mediaType;
pendingImages.push({ format, source: { bytes: block.source.data } });
} else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) {
let resultContent = "";
if (typeof block.content === "string") {
resultContent = block.content;
} else if (Array.isArray(block.content)) {
resultContent =
block.content
.filter((c) => c.type === CLAUDE_BLOCK.TEXT)
.map((c) => c.text)
.join("\n") || JSON.stringify(block.content);
} else if (block.content) {
resultContent = JSON.stringify(block.content);
}
pendingToolResults.push({
toolUseId: block.tool_use_id,
status: "success",
content: [{ text: resultContent }],
});
}
}
}
} else if (role === ROLE.ASSISTANT) {
let textContent = "";
const toolUses = [];
if (typeof msg.content === "string") {
textContent = msg.content;
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.TEXT) {
textContent += block.text;
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
toolUses.push({
toolUseId: block.id,
name: block.name,
input: block.input || {},
});
}
}
}
if (textContent) pendingAssistantContent.push(textContent);
if (toolUses.length > 0) {
flushPending();
const lastMsg = history[history.length - 1];
if (lastMsg?.assistantResponseMessage) {
lastMsg.assistantResponseMessage.toolUses = toolUses;
}
currentRole = null;
}
}
}
if (currentRole !== null) flushPending();
// Pop the last user turn as currentMessage (skip trailing assistant turns).
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].userInputMessage) {
currentMessage = history.splice(i, 1)[0];
break;
}
}
// Grab tools from the first history user turn before cleanup strips them.
const firstHistoryTools =
history[0]?.userInputMessage?.userInputMessageContext?.tools;
history.forEach((item) => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if (
item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0
) {
delete item.userInputMessage.userInputMessageContext;
}
if (item.userInputMessage && !item.userInputMessage.modelId) {
item.userInputMessage.modelId = model;
}
});
// Merge consecutive user turns (Kiro requires alternating roles).
const mergedHistory = [];
for (const current of history) {
const prev = mergedHistory[mergedHistory.length - 1];
if (current.userInputMessage && prev?.userInputMessage) {
prev.userInputMessage.content += "\n\n" + current.userInputMessage.content;
const prevCtx = prev.userInputMessage.userInputMessageContext;
const curCtx = current.userInputMessage.userInputMessageContext;
if (curCtx) {
if (!prevCtx) {
prev.userInputMessage.userInputMessageContext = curCtx;
} else {
if (curCtx.toolResults?.length > 0) {
prevCtx.toolResults = [
...(prevCtx.toolResults || []),
...curCtx.toolResults,
];
}
if (curCtx.tools?.length > 0) {
prevCtx.tools = [...(prevCtx.tools || []), ...curCtx.tools];
}
}
}
} else {
mergedHistory.push(current);
}
}
if (!currentMessage) {
currentMessage = { userInputMessage: { content: "", modelId: model } };
}
// Inject tools into currentMessage after cleanup if not already present.
if (
firstHistoryTools?.length > 0 &&
!currentMessage.userInputMessage.userInputMessageContext?.tools
) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryTools;
}
return { history: mergedHistory, currentMessage };
}
/**
* Fold orphaned toolResults (those whose toolUseId has no matching toolUse in
* any assistant turn) back into the user text, removing the dangling
* structured reference that makes Kiro 400.
*/
function reconcileOrphanedToolResults(history, currentMessage) {
const validIds = new Set();
for (const h of history) {
const arm = h.assistantResponseMessage;
if (!arm) continue;
for (const tu of arm.toolUses || []) {
if (tu.toolUseId) validIds.add(tu.toolUseId);
}
}
const carriers = currentMessage ? [...history, currentMessage] : history;
for (const item of carriers) {
const uim = item.userInputMessage;
const ctx = uim?.userInputMessageContext;
if (!ctx?.toolResults?.length) continue;
const kept = [];
const salvaged = [];
for (const tr of ctx.toolResults) {
if (validIds.has(tr.toolUseId)) {
kept.push(tr);
} else {
const text = Array.isArray(tr.content)
? tr.content.map((c) => c?.text || "").join("\n")
: "";
salvaged.push(`[Tool result: ${text}]`);
}
}
if (salvaged.length === 0) continue;
const extra = salvaged.join("\n");
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
ctx.toolResults = kept;
if (kept.length === 0 && !ctx.tools?.length) {
delete uim.userInputMessageContext;
}
}
}
/**
* Build a Kiro payload directly from a Claude Messages API request body.
*/
export function claudeToKiroRequest(model, body, stream, credentials) {
let messages = Array.isArray(body.messages) ? body.messages : [];
const tools = Array.isArray(body.tools) ? body.tools : [];
const clientProvidedTools = tools.length > 0;
const maxTokens = body.max_tokens || 32000;
const temperature = body.temperature;
const topP = body.top_p;
const {
upstream: upstreamModel,
agentic,
thinking: modelImpliesThinking,
} = resolveKiroModel(model);
const thinkingEnabled =
modelImpliesThinking || isThinkingEnabled(body, null, model);
// Guard 1: no client tools → flatten all tool interactions to text.
if (!clientProvidedTools) {
messages = flattenClaudeToolInteractions(messages);
}
const { history, currentMessage } = convertClaudeMessagesToKiro(
messages,
tools,
upstreamModel
);
// Guard 2: tools present → reconcile dangling tool_results.
if (clientProvidedTools) {
reconcileOrphanedToolResults(history, currentMessage);
}
const profileArn = credentials?.providerSpecificData?.profileArn || "";
let finalContent = currentMessage?.userInputMessage?.content || "";
// System prompt → prepend to the user content.
if (body.system) {
let systemText = "";
if (typeof body.system === "string") {
systemText = body.system;
} else if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
}
if (systemText) finalContent = `${systemText}\n\n${finalContent}`;
}
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
const timestamp = new Date().toISOString();
const prefixParts = [];
if (thinkingEnabled) prefixParts.push(buildThinkingSystemPrefix());
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext:
currentMessage.userInputMessage.userInputMessageContext,
}),
...(currentMessage?.userInputMessage?.images && {
images: currentMessage.userInputMessage.images,
}),
},
},
history,
},
};
if (profileArn) payload.profileArn = profileArn;
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens;
if (temperature !== undefined) payload.inferenceConfig.temperature = temperature;
if (topP !== undefined) payload.inferenceConfig.topP = topP;
}
// Non-enumerable hint so the executor can route the upstream model id.
Object.defineProperty(payload, "_kiroUpstreamModel", {
value: upstreamModel,
enumerable: false,
});
return payload;
}
register(FORMATS.CLAUDE, FORMATS.KIRO, claudeToKiroRequest, null);

View File

@@ -524,8 +524,16 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
const profileArn = credentials?.providerSpecificData?.profileArn
|| resolveDefaultProfileArn(credentials?.providerSpecificData?.authMethod);
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
// account-specific. Injecting the shared builder-id/social *default* placeholder
// ARN makes CodeWhisperer reject the request with 403 "bearer token invalid"
// (the ARN doesn't belong to the key's account). So for api_key, only send a
// profileArn that was actually resolved for this connection — never the default.
// OAuth/social keep the default fallback (their tokens accept it).
const authMethod = credentials?.providerSpecificData?.authMethod;
const profileArn = authMethod === "api_key"
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";

View File

@@ -0,0 +1,261 @@
/**
* 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);