diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js
index 37a5f84e..aeacd2db 100644
--- a/open-sse/config/runtimeConfig.js
+++ b/open-sse/config/runtimeConfig.js
@@ -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;
diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js
index ec22b827..3034f725 100644
--- a/open-sse/executors/kiro.js
+++ b/open-sse/executors/kiro.js
@@ -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) {
diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js
index 5c092a02..fb78a227 100644
--- a/open-sse/providers/registry/kiro.js
+++ b/open-sse/providers/registry/kiro.js
@@ -87,5 +87,6 @@ export default {
},
features: {
usage: true,
+ usageApikey: true,
},
};
diff --git a/open-sse/services/usage/kiro.js b/open-sse/services/usage/kiro.js
index 9e6d06f5..fb221565 100644
--- a/open-sse/services/usage/kiro.js
+++ b/open-sse/services/usage/kiro.js
@@ -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);
},
diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js
index 5c9065d2..8c25d486 100644
--- a/open-sse/translator/index.js
+++ b/open-sse/translator/index.js
@@ -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";
diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js
new file mode 100644
index 00000000..3e2e6f76
--- /dev/null
+++ b/open-sse/translator/request/claude-to-kiro.js
@@ -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 `enabled` 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);
diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js
index 4399f703..b15fbeae 100644
--- a/open-sse/translator/request/openai-to-kiro.js
+++ b/open-sse/translator/request/openai-to-kiro.js
@@ -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 || "";
diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js
new file mode 100644
index 00000000..1c9ece5b
--- /dev/null
+++ b/open-sse/translator/response/kiro-to-claude.js
@@ -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);
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js
index e911ac90..dd134f33 100644
--- a/src/app/(dashboard)/dashboard/providers/page.js
+++ b/src/app/(dashboard)/dashboard/providers/page.js
@@ -166,8 +166,9 @@ export default function ProvidersPage() {
}, []);
const getProviderStats = (providerId, authType) => {
+ const authTypes = Array.isArray(authType) ? authType : [authType];
const providerConnections = connections.filter(
- (c) => c.provider === providerId && c.authType === authType,
+ (c) => c.provider === providerId && authTypes.includes(c.authType),
);
const getEffectiveStatus = (conn) => {
@@ -208,17 +209,15 @@ export default function ProvidersPage() {
return { connected, error, total, errorCode, errorTime, allDisabled };
};
- // Toggle all connections for a provider on/off
+ // Toggle all connections for a provider on/off. authType may be a single
+ // string or an array (kiro counts oauth + api_key/apikey together).
const handleToggleProvider = async (providerId, authType, newActive) => {
- const providerConns = connections.filter(
- (c) => c.provider === providerId && c.authType === authType,
- );
+ const authTypes = Array.isArray(authType) ? authType : [authType];
+ const matches = (c) =>
+ c.provider === providerId && authTypes.includes(c.authType);
+ const providerConns = connections.filter(matches);
setConnections((prev) =>
- prev.map((c) =>
- c.provider === providerId && c.authType === authType
- ? { ...c, isActive: newActive }
- : c,
- ),
+ prev.map((c) => (matches(c) ? { ...c, isActive: newActive } : c)),
);
await Promise.allSettled(
providerConns.map((c) =>
@@ -465,16 +464,26 @@ export default function ProvidersPage() {
- {freeEntries.map(([key, info]) => (
-
handleToggleProvider(key, "oauth", active)}
- />
- ))}
+ {freeEntries.map(([key, info]) => {
+ // Kiro accepts both OAuth and api-key connections; count/toggle both
+ // so the card total matches the provider detail page (#kiro-apikey).
+ // Kiro's headless api-key flow persists authType "api_key" (underscore),
+ // while generic apikey providers use "apikey" — include both spellings.
+ const freeAuthTypes =
+ key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth";
+ return (
+
+ handleToggleProvider(key, freeAuthTypes, active)
+ }
+ />
+ );
+ })}
{freeTierEntries.map(([key, info]) => (
:...).
+function kiroRegion(conn) {
+ const r = conn.providerSpecificData?.region;
+ if (r) return r;
+ const arn = conn.providerSpecificData?.profileArn;
+ const seg = typeof arn === "string" ? arn.split(":")[3] : "";
+ return seg || "";
+}
function getCodexResetCreditCount(quota) {
const value = quota?.raw?.resetCredits?.availableCount;
@@ -45,6 +75,7 @@ function getCodexResetCreditCount(quota) {
}
export default function ProviderLimits() {
+ const { copied, copy } = useCopyToClipboard();
const [connections, setConnections] = useState([]);
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
@@ -892,6 +923,46 @@ export default function ProviderLimits() {
Reset eligible: {resetCreditCount}
)}
+ {conn.provider === "kiro" && (
+
+
+ {kiroMethodLabel(conn)}
+
+ {kiroRegion(conn) && (
+
+ {kiroRegion(conn)}
+
+ )}
+
+ {isInactive ? "disabled" : conn.testStatus || "unknown"}
+
+ {conn.providerSpecificData?.profileArn && (
+
+ )}
+
+ )}
diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js
new file mode 100644
index 00000000..9e9fa152
--- /dev/null
+++ b/src/app/api/oauth/kiro/api-key/route.js
@@ -0,0 +1,63 @@
+import { NextResponse } from "next/server";
+import { KiroService } from "@/lib/oauth/services/kiro";
+import { createProviderConnection } from "@/models";
+
+/**
+ * POST /api/oauth/kiro/api-key
+ * Import a Kiro API key (headless auth). The key is a long-lived bearer
+ * credential — there is no refresh token. It is validated by listing
+ * CodeWhisperer profiles, then stored with authMethod="api_key".
+ */
+export async function POST(request) {
+ try {
+ const { apiKey, region } = await request.json();
+
+ if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
+ return NextResponse.json(
+ { error: "API key is required" },
+ { status: 400 }
+ );
+ }
+
+ const kiroService = new KiroService();
+
+ // Validate the key and resolve its profileArn via ListAvailableProfiles
+ const credential = await kiroService.validateApiKey(
+ apiKey,
+ region || "us-east-1"
+ );
+
+ // Extract email from JWT if the key happens to be a JWT (optional display)
+ const email = kiroService.extractEmailFromJWT(credential.accessToken);
+
+ // API keys never expire on a fixed schedule; persist a long horizon so the
+ // proactive refresh path (which requires a refreshToken anyway) is skipped.
+ const connection = await createProviderConnection({
+ provider: "kiro",
+ authType: "api_key",
+ accessToken: credential.accessToken,
+ refreshToken: null,
+ expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
+ email: email || null,
+ providerSpecificData: {
+ profileArn: credential.profileArn,
+ region: credential.region,
+ authMethod: "api_key",
+ provider: "API Key",
+ },
+ testStatus: "active",
+ });
+
+ return NextResponse.json({
+ success: true,
+ connection: {
+ id: connection.id,
+ provider: connection.provider,
+ email: connection.email,
+ },
+ });
+ } catch (error) {
+ console.log("Kiro API key import error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js
index 22bbb282..be5342c1 100644
--- a/src/app/api/providers/client/route.js
+++ b/src/app/api/providers/client/route.js
@@ -17,6 +17,7 @@ const SAFE_PSD_FIELDS = [
"connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy",
"githubLogin", "githubName", "githubEmail", "githubUserId",
"username", "firstName", "lastName", "authMethod", "authKind",
+ "profileArn",
];
const DEFAULT_PAGE_SIZE = 20;
diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js
index 4b775b18..8ccdc015 100644
--- a/src/app/api/usage/[connectionId]/route.js
+++ b/src/app/api/usage/[connectionId]/route.js
@@ -131,11 +131,14 @@ export async function GET(request, { params }) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
- // Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/...)
+ // Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...)
+ // Kiro's headless api-key flow persists authType "api_key" (underscore) while
+ // generic apikey providers persist "apikey" — accept both spellings here.
const isOAuth = connection.authType === "oauth";
+ const isApikeyAuth =
+ connection.authType === "apikey" || connection.authType === "api_key";
const isApikeyEligible =
- connection.authType === "apikey" &&
- USAGE_APIKEY_PROVIDERS.includes(connection.provider);
+ isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
if (!isOAuth && !isApikeyEligible) {
return Response.json({ message: "Usage not available for this connection" });
diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js
index 3c8f9905..d21c82aa 100644
--- a/src/lib/oauth/services/kiro.js
+++ b/src/lib/oauth/services/kiro.js
@@ -254,6 +254,67 @@ export class KiroService {
}
}
+ /**
+ * List available CodeWhisperer profiles for a token (or API key) and return
+ * the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so
+ * it must be fetched separately — the same call works for API-key auth.
+ * Accepts both `arn` and `profileArn` response field names (the API-key
+ * JSON-1.0 surface returns `arn`).
+ */
+ async listAvailableProfiles(accessToken, region = "us-east-1") {
+ const endpoint = `https://codewhisperer.${region}.amazonaws.com`;
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-amz-json-1.0",
+ "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
+ "Authorization": `Bearer ${accessToken}`,
+ "Accept": "application/json",
+ },
+ body: JSON.stringify({ maxResults: 10 }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to list profiles: ${error}`);
+ }
+
+ const data = await response.json();
+ const profiles = Array.isArray(data?.profiles) ? data.profiles : [];
+ const arnOf = (p) => p?.arn || p?.profileArn || null;
+ const match = profiles.find((p) => arnOf(p)?.split(":")[3] === region) || profiles[0];
+ return arnOf(match);
+ }
+
+ /**
+ * Validate an API-key credential by listing profiles with it. API keys are
+ * long-lived bearer tokens (no refresh), so the only way to validate one is
+ * to make an authenticated CodeWhisperer call. Returns a credential object
+ * ready to persist as a "kiro" connection with authMethod="api_key".
+ */
+ async validateApiKey(apiKey, region = "us-east-1") {
+ if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
+ throw new Error("API key is required");
+ }
+ const trimmed = apiKey.trim();
+
+ let profileArn = null;
+ try {
+ profileArn = await this.listAvailableProfiles(trimmed, region);
+ } catch (error) {
+ throw new Error(`API key validation failed: ${error.message}`);
+ }
+
+ return {
+ accessToken: trimmed,
+ refreshToken: null,
+ profileArn,
+ region,
+ authMethod: "api_key",
+ };
+ }
+
/**
* List available models from CodeWhisperer API
*/
diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js
index 6f567c6d..6d38c1e6 100644
--- a/src/shared/components/KiroAuthModal.js
+++ b/src/shared/components/KiroAuthModal.js
@@ -13,6 +13,8 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
const [idcStartUrl, setIdcStartUrl] = useState("");
const [idcRegion, setIdcRegion] = useState("us-east-1");
const [refreshToken, setRefreshToken] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1");
const [error, setError] = useState(null);
const [importing, setImporting] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
@@ -96,6 +98,40 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion });
};
+ const handleApiKeyImport = async () => {
+ if (!apiKey.trim()) {
+ setError("Please enter an API key");
+ return;
+ }
+
+ setImporting(true);
+ setError(null);
+
+ try {
+ const res = await fetch("/api/oauth/kiro/api-key", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ apiKey: apiKey.trim(),
+ region: apiKeyRegion.trim() || "us-east-1",
+ }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || "Import failed");
+ }
+
+ // Success - notify parent to refresh connections
+ onMethodSelect("api-key");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setImporting(false);
+ }
+ };
+
const handleSocialLogin = (provider) => {
onMethodSelect("social", { provider });
};
@@ -142,6 +178,22 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
+ {/* AWS API Key */}
+
+
{/* Google Social Login - HIDDEN */}