From 706e6513c94803ac46a8c1c21ca8ac6775912e3a Mon Sep 17 00:00:00 2001 From: thienpv Date: Wed, 17 Jun 2026 10:01:30 +0700 Subject: [PATCH] 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 Co-authored-by: Cursor --- open-sse/config/runtimeConfig.js | 18 +- open-sse/executors/kiro.js | 69 ++- open-sse/providers/registry/kiro.js | 1 + open-sse/services/usage/kiro.js | 21 +- open-sse/translator/index.js | 46 +- open-sse/translator/request/claude-to-kiro.js | 463 ++++++++++++++++++ open-sse/translator/request/openai-to-kiro.js | 12 +- .../translator/response/kiro-to-claude.js | 261 ++++++++++ .../(dashboard)/dashboard/providers/page.js | 49 +- .../usage/components/ProviderLimits/index.js | 71 +++ src/app/api/oauth/kiro/api-key/route.js | 63 +++ src/app/api/providers/client/route.js | 1 + src/app/api/usage/[connectionId]/route.js | 9 +- src/lib/oauth/services/kiro.js | 61 +++ src/shared/components/KiroAuthModal.js | 109 +++++ src/shared/components/KiroOAuthWrapper.js | 4 +- tests/translator/claude-kiro-direct.test.js | 167 +++++++ tests/translator/registerAll.js | 2 + tests/unit/kiro-profile-arn.test.js | 63 +++ tests/vitest.config.js | 4 + 20 files changed, 1437 insertions(+), 57 deletions(-) create mode 100644 open-sse/translator/request/claude-to-kiro.js create mode 100644 open-sse/translator/response/kiro-to-claude.js create mode 100644 src/app/api/oauth/kiro/api-key/route.js create mode 100644 tests/translator/claude-kiro-direct.test.js create mode 100644 tests/unit/kiro-profile-arn.test.js 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 */} + + + + )} + {/* Social Login Info (Google) */} {selectedMethod === "social-google" && (
diff --git a/src/shared/components/KiroOAuthWrapper.js b/src/shared/components/KiroOAuthWrapper.js index 93e45e3d..ec50f3a5 100644 --- a/src/shared/components/KiroOAuthWrapper.js +++ b/src/shared/components/KiroOAuthWrapper.js @@ -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]); diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js new file mode 100644 index 00000000..be98eafc --- /dev/null +++ b/tests/translator/claude-kiro-direct.test.js @@ -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( + "enabled" + ); + }); +}); + +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"); + }); +}); diff --git a/tests/translator/registerAll.js b/tests/translator/registerAll.js index 75793437..85b21b7f 100644 --- a/tests/translator/registerAll.js +++ b/tests/translator/registerAll.js @@ -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"; diff --git a/tests/unit/kiro-profile-arn.test.js b/tests/unit/kiro-profile-arn.test.js new file mode 100644 index 00000000..1925582b --- /dev/null +++ b/tests/unit/kiro-profile-arn.test.js @@ -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/ + ); + }); +}); diff --git a/tests/vitest.config.js b/tests/vitest.config.js index d341b917..d53d85e0 100644 --- a/tests/vitest.config.js +++ b/tests/vitest.config.js @@ -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