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
co-authored by Claude Opus 4.8 Cursor
parent 3de6ce157c
commit 706e6513c9
20 changed files with 1437 additions and 57 deletions
+15 -3
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;
+48 -1
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);
@@ -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) {
+1
View File
@@ -87,5 +87,6 @@ export default {
},
features: {
usage: true,
usageApikey: true,
},
};
+18 -3
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);
},
+20
View File
@@ -76,6 +76,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// 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}`);
@@ -94,6 +101,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
}
}
}
}
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
applyThinking(targetFormat, model, result, provider, thinkingIntent);
@@ -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";
@@ -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);
+10 -2
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 || "";
@@ -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);
+23 -14
View File
@@ -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() {
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{freeEntries.map(([key, info]) => (
{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 (
<ProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
stats={getProviderStats(key, freeAuthTypes)}
authType="free"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
onToggle={(active) =>
handleToggleProvider(key, freeAuthTypes, active)
}
/>
))}
);
})}
{freeTierEntries.map(([key, info]) => (
<ApiKeyProviderCard
key={key}
@@ -37,6 +37,36 @@ import {
import Card from "@/shared/components/Card";
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// Maps the stored providerSpecificData.authMethod to a human label for Kiro.
// Values come from the Kiro connect flows: builder-id/idc (device code),
// google/github (social), imported (refresh-token paste), api_key (headless).
const KIRO_METHOD_LABELS = {
"builder-id": "AWS Builder ID",
idc: "IAM Identity Center",
google: "Google",
github: "GitHub",
imported: "Imported Token",
api_key: "API Key",
};
function kiroMethodLabel(conn) {
const m = conn.providerSpecificData?.authMethod;
if (m && KIRO_METHOD_LABELS[m]) return KIRO_METHOD_LABELS[m];
return conn.authType === "api_key" ? "API Key" : "OAuth";
}
// Region is stored for builder-id/idc/api_key flows; social and imported flows
// omit it, so fall back to the region segment of the profileArn
// (arn:aws:codewhisperer:<region>:...).
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}
</p>
)}
{conn.provider === "kiro" && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="rounded-full bg-brand-500/10 px-2 py-0.5 text-[10px] font-semibold text-brand-600 dark:text-brand-300">
{kiroMethodLabel(conn)}
</span>
{kiroRegion(conn) && (
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-semibold text-blue-600 dark:text-blue-400">
{kiroRegion(conn)}
</span>
)}
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
isInactive
? "bg-surface-2 text-text-muted"
: conn.testStatus === "active" || conn.testStatus === "success"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: conn.testStatus === "error" || conn.testStatus === "expired" || conn.testStatus === "unavailable"
? "bg-red-500/10 text-red-600 dark:text-red-400"
: "bg-surface-2 text-text-muted"
}`}
>
{isInactive ? "disabled" : conn.testStatus || "unknown"}
</span>
{conn.providerSpecificData?.profileArn && (
<button
type="button"
onClick={() => copy(conn.providerSpecificData.profileArn, conn.id)}
title={conn.providerSpecificData.profileArn}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border-subtle px-2 py-0.5 text-[10px] text-text-muted transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-[12px]">
{copied === conn.id ? "check" : "content_copy"}
</span>
<code className="truncate font-mono">
{conn.providerSpecificData.profileArn}
</code>
</button>
)}
</div>
)}
</div>
</div>
+63
View File
@@ -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 });
}
}
+1
View File
@@ -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;
+6 -3
View File
@@ -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" });
+61
View File
@@ -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
*/
+109
View File
@@ -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 }) {
</div>
</button>
{/* AWS API Key */}
<button
onClick={() => handleMethodSelect("api-key")}
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">key</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">API Key</h3>
<p className="text-sm text-text-muted">
Use a long-lived Kiro/CodeWhisperer API key (headless auth).
</p>
</div>
</div>
</button>
{/* Google Social Login - HIDDEN */}
<button
onClick={() => handleMethodSelect("social-google")}
@@ -240,6 +292,63 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
</div>
)}
{/* API Key */}
{selectedMethod === "api-key" && (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
<p className="text-sm text-blue-800 dark:text-blue-200">
Paste a long-lived Kiro/CodeWhisperer API key. It is validated
against AWS and stored directly as a bearer credential (no refresh).
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">
API Key <span className="text-red-500">*</span>
</label>
<Input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your Kiro API key..."
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
AWS Region
</label>
<Input
value={apiKeyRegion}
onChange={(e) => setApiKeyRegion(e.target.value)}
placeholder="us-east-1"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
AWS region for the key (default: us-east-1)
</p>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2">
<Button onClick={handleApiKeyImport} fullWidth disabled={importing || !apiKey.trim()}>
{importing ? "Validating..." : "Add API Key"}
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
</Button>
</div>
</div>
)}
{/* Social Login Info (Google) */}
{selectedMethod === "social-google" && (
<div className="space-y-4">
+2 -2
View File
@@ -27,8 +27,8 @@ export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onCl
// Use social login with manual callback
setAuthMethod("social");
setSocialProvider(config.provider);
} else if (method === "import") {
// Import handled in KiroAuthModal, just close
} else if (method === "import" || method === "api-key") {
// Import / API-key handled in KiroAuthModal, just close
onSuccess?.();
}
}, [onSuccess]);
+167
View File
@@ -0,0 +1,167 @@
// Claude → Kiro (direct route) request translation + Kiro → Claude response.
// Verifies the direct claude:kiro / kiro:claude routes added to bypass the
// OpenAI pivot, and that the "Improperly formed request" 400-guards survive.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const C2K = (body) =>
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro");
describe("Claude → Kiro (direct route)", () => {
it("produces a Kiro conversationState payload", () => {
const out = C2K({ messages: [{ role: "user", content: "hello" }] });
expect(out.conversationState).toBeTruthy();
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello");
});
it("guard 1: with no tools, a dangling tool_result is flattened to text (no structured ref)", () => {
// Client omitted `tools` but kept a tool_result after compaction.
const out = C2K({
messages: [
{ role: "user", content: "go" },
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "f", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "result" }] },
],
});
// No userInputMessageContext.tools/toolResults anywhere → won't trip the
// "tools required" validator.
const cur = out.conversationState.currentMessage.userInputMessage;
expect(cur.userInputMessageContext?.toolResults).toBeFalsy();
const everyHistoryClean = out.conversationState.history.every(
(h) => !h.userInputMessage?.userInputMessageContext?.toolResults
);
expect(everyHistoryClean).toBe(true);
});
it("guard 2: with tools, an orphaned tool_result is folded into user text", () => {
const out = C2K({
tools: [{ name: "f", description: "fn", input_schema: { type: "object", properties: {} } }],
messages: [
{ role: "user", content: "go" },
// tool_result references a tool_use that never appears → orphan
{ role: "user", content: [{ type: "tool_result", tool_use_id: "ghost", content: "salvage me" }] },
],
});
const cur = out.conversationState.currentMessage.userInputMessage;
// The orphan content survives as text, not as a dangling structured ref.
expect(cur.content).toContain("salvage me");
expect(cur.userInputMessageContext?.toolResults?.length ?? 0).toBe(0);
});
it("injects thinking_mode tag when model implies thinking", () => {
const out = translateRequest(
FORMATS.CLAUDE,
FORMATS.KIRO,
"claude-sonnet-4.5-thinking",
{ messages: [{ role: "user", content: "hi" }] },
true,
null,
"kiro"
);
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
"<thinking_mode>enabled</thinking_mode>"
);
});
});
describe("Kiro → Claude (direct route, OpenAI-shaped chunks from executor)", () => {
// KiroExecutor emits chat.completion.chunk objects; translateResponse must
// convert them to Claude SSE events.
const R = (chunk, state) => translateResponse(FORMATS.KIRO, FORMATS.CLAUDE, chunk, state);
it("first text chunk emits message_start + content_block_start + text_delta", () => {
const state = {};
const events = R(
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
model: "claude-sonnet-4.5",
choices: [{ index: 0, delta: { role: "assistant", content: "Hi" }, finish_reason: null }],
},
state
);
const types = events.map((e) => e.type);
expect(types).toContain("message_start");
expect(types).toContain("content_block_start");
expect(types).toContain("content_block_delta");
const delta = events.find((e) => e.type === "content_block_delta");
expect(delta.delta).toEqual({ type: "text_delta", text: "Hi" });
});
it("finish chunk emits message_delta + message_stop with stop_reason", () => {
const state = {};
R(
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
model: "m",
choices: [{ index: 0, delta: { content: "x" }, finish_reason: null }],
},
state
);
const events = R(
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
model: "m",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 3 },
},
state
);
const md = events.find((e) => e.type === "message_delta");
expect(md.delta.stop_reason).toBe("end_turn");
expect(md.usage).toEqual({ input_tokens: 5, output_tokens: 3 });
expect(events.some((e) => e.type === "message_stop")).toBe(true);
});
it("reasoning_content maps to a thinking block", () => {
const state = {};
const events = R(
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
model: "m",
choices: [{ index: 0, delta: { reasoning_content: "pondering" }, finish_reason: null }],
},
state
);
const start = events.find((e) => e.type === "content_block_start");
expect(start.content_block.type).toBe("thinking");
const delta = events.find((e) => e.type === "content_block_delta");
expect(delta.delta).toEqual({ type: "thinking_delta", thinking: "pondering" });
});
it("tool_calls map to a tool_use block with buffered input_json_delta", () => {
const state = {};
R(
{
id: "c", object: "chat.completion.chunk", model: "m",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "tu1", type: "function", function: { name: "search", arguments: "" } }] }, finish_reason: null }],
},
state
);
R(
{
id: "c", object: "chat.completion.chunk", model: "m",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '{"q":"x"}' } }] }, finish_reason: null }],
},
state
);
const events = R(
{
id: "c", object: "chat.completion.chunk", model: "m",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
state
);
const jsonDelta = events.find(
(e) => e.type === "content_block_delta" && e.delta.type === "input_json_delta"
);
expect(jsonDelta.delta.partial_json).toBe('{"q":"x"}');
const md = events.find((e) => e.type === "message_delta");
expect(md.delta.stop_reason).toBe("tool_use");
});
});
+2
View File
@@ -11,6 +11,7 @@ import "../../open-sse/translator/request/openai-to-kiro.js";
import "../../open-sse/translator/request/openai-to-cursor.js";
import "../../open-sse/translator/request/openai-to-ollama.js";
import "../../open-sse/translator/request/openai-to-commandcode.js";
import "../../open-sse/translator/request/claude-to-kiro.js";
import "../../open-sse/translator/response/claude-to-openai.js";
import "../../open-sse/translator/response/openai-to-claude.js";
import "../../open-sse/translator/response/gemini-to-openai.js";
@@ -20,3 +21,4 @@ import "../../open-sse/translator/response/kiro-to-openai.js";
import "../../open-sse/translator/response/cursor-to-openai.js";
import "../../open-sse/translator/response/ollama-to-openai.js";
import "../../open-sse/translator/response/commandcode-to-openai.js";
import "../../open-sse/translator/response/kiro-to-claude.js";
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { KiroService } from "../../src/lib/oauth/services/kiro.js";
/**
* Regression tests for Kiro API-key auth.
*
* KiroService.validateApiKey resolves a profileArn with the key (via
* CodeWhisperer ListAvailableProfiles) and returns a credential shaped for
* persistence with authMethod="api_key". The response profile field name
* varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles.
*
* Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by
* fetchKiroProfileArn in providers.js and is covered there — not here.
*/
describe("kiro API-key auth (KiroService.validateApiKey)", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it("validates an API key and resolves a credential with profileArn", async () => {
const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY";
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ profiles: [{ arn: expectedArn }] }),
});
const svc = new KiroService();
const cred = await svc.validateApiKey(" my-secret-key ");
expect(cred).toEqual({
accessToken: "my-secret-key",
refreshToken: null,
profileArn: expectedArn,
region: "us-east-1",
authMethod: "api_key",
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com");
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
expect(init.headers["x-amz-target"]).toBe(
"AmazonCodeWhispererService.ListAvailableProfiles"
);
});
it("rejects an empty API key without a network call", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
const svc = new KiroService();
await expect(svc.validateApiKey(" ")).rejects.toThrow("API key is required");
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces a validation error when the key is rejected", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
text: async () => "Unauthorized",
});
const svc = new KiroService();
await expect(svc.validateApiKey("bad-key")).rejects.toThrow(
/API key validation failed/
);
});
});
+4
View File
@@ -9,6 +9,10 @@ export default defineConfig({
environment: "node",
globals: true,
include: ["**/*.test.js"],
// Don't scan into git worktrees nested under .claude/ — they carry their
// own copies of the test files but lack an installed node_modules (open-sse,
// etc.), which makes provider imports fail during collection.
exclude: ["**/node_modules/**", "**/.claude/**", "**/dist/**"],
// Allow many it.concurrent cases (real provider smoke runs ~50 providers in parallel)
maxConcurrency: 60,
// Suppress noisy console output from handlers under test