mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
committed by
decolua
co-authored by
Claude Opus 4.8
Cursor
parent
3de6ce157c
commit
706e6513c9
@@ -31,11 +31,23 @@ export const MEMORY_CONFIG = {
|
|||||||
proxyDispatchersMaxSize: 20,
|
proxyDispatchersMaxSize: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stream stall timeout: abort if no chunk received within this duration
|
// Parse a positive integer env override, falling back to a default.
|
||||||
export const STREAM_STALL_TIMEOUT_MS = 60 * 1000;
|
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
|
// 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
|
// Default token limits
|
||||||
export const DEFAULT_MAX_TOKENS = 64000;
|
export const DEFAULT_MAX_TOKENS = 64000;
|
||||||
|
|||||||
+58
-11
@@ -20,13 +20,50 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
"Amz-Sdk-Invocation-Id": uuidv4()
|
"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}`;
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
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) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
@@ -38,6 +75,8 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
* BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev →
|
* BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev →
|
||||||
* codewhisperer → q) advancing to the next host on 429 (shouldRetry) and on
|
* 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}`.
|
* 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
|
* 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
|
* 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.
|
* 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({
|
const transformStream = new TransformStream({
|
||||||
async transform(chunk, controller) {
|
async transform(chunk, controller) {
|
||||||
|
// Track output so we can emit a keepalive if this frame yields no chunk.
|
||||||
|
const enqueueCountBefore = chunkIndex;
|
||||||
// Append to buffer
|
// Append to buffer
|
||||||
const newBuffer = new Uint8Array(buffer.length + chunk.length);
|
const newBuffer = new Uint8Array(buffer.length + chunk.length);
|
||||||
newBuffer.set(buffer);
|
newBuffer.set(buffer);
|
||||||
@@ -97,7 +138,7 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
if (!event) continue;
|
if (!event) continue;
|
||||||
|
|
||||||
const eventType = event.headers[":event-type"] || "";
|
const eventType = event.headers[":event-type"] || "";
|
||||||
|
|
||||||
// Track total content length for token estimation
|
// Track total content length for token estimation
|
||||||
if (!state.totalContentLength) state.totalContentLength = 0;
|
if (!state.totalContentLength) state.totalContentLength = 0;
|
||||||
if (!state.contextUsagePercentage) state.contextUsagePercentage = 0;
|
if (!state.contextUsagePercentage) state.contextUsagePercentage = 0;
|
||||||
@@ -106,7 +147,7 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
if (eventType === "assistantResponseEvent" && event.payload?.content) {
|
if (eventType === "assistantResponseEvent" && event.payload?.content) {
|
||||||
const content = event.payload.content;
|
const content = event.payload.content;
|
||||||
state.totalContentLength += content.length;
|
state.totalContentLength += content.length;
|
||||||
|
|
||||||
const chunk = {
|
const chunk = {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
@@ -293,7 +334,7 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
if (metrics && typeof metrics === 'object') {
|
if (metrics && typeof metrics === 'object') {
|
||||||
const inputTokens = metrics.inputTokens || 0;
|
const inputTokens = metrics.inputTokens || 0;
|
||||||
const outputTokens = metrics.outputTokens || 0;
|
const outputTokens = metrics.outputTokens || 0;
|
||||||
|
|
||||||
if (inputTokens > 0 || outputTokens > 0) {
|
if (inputTokens > 0 || outputTokens > 0) {
|
||||||
state.usage = {
|
state.usage = {
|
||||||
prompt_tokens: inputTokens,
|
prompt_tokens: inputTokens,
|
||||||
@@ -307,27 +348,27 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
// Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent
|
// Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent
|
||||||
if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) {
|
if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) {
|
||||||
state.finishEmitted = true;
|
state.finishEmitted = true;
|
||||||
|
|
||||||
// Estimate tokens if not available from events
|
// Estimate tokens if not available from events
|
||||||
if (!state.usage) {
|
if (!state.usage) {
|
||||||
// Estimate output tokens from content length
|
// Estimate output tokens from content length
|
||||||
const estimatedOutputTokens = state.totalContentLength > 0
|
const estimatedOutputTokens = state.totalContentLength > 0
|
||||||
? Math.max(1, Math.floor(state.totalContentLength / 4))
|
? Math.max(1, Math.floor(state.totalContentLength / 4))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Estimate input tokens from contextUsagePercentage
|
// Estimate input tokens from contextUsagePercentage
|
||||||
// Kiro models typically have 200k context window
|
// Kiro models typically have 200k context window
|
||||||
const estimatedInputTokens = state.contextUsagePercentage > 0
|
const estimatedInputTokens = state.contextUsagePercentage > 0
|
||||||
? Math.floor(state.contextUsagePercentage * 200000 / 100)
|
? Math.floor(state.contextUsagePercentage * 200000 / 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
state.usage = {
|
state.usage = {
|
||||||
prompt_tokens: estimatedInputTokens,
|
prompt_tokens: estimatedInputTokens,
|
||||||
completion_tokens: estimatedOutputTokens,
|
completion_tokens: estimatedOutputTokens,
|
||||||
total_tokens: estimatedInputTokens + estimatedOutputTokens
|
total_tokens: estimatedInputTokens + estimatedOutputTokens
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
@@ -339,12 +380,12 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
|
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include usage in final chunk if available
|
// Include usage in final chunk if available
|
||||||
if (state.usage) {
|
if (state.usage) {
|
||||||
finishChunk.usage = state.usage;
|
finishChunk.usage = state.usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
|
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +393,12 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
if (iterations >= maxIterations) {
|
if (iterations >= maxIterations) {
|
||||||
console.warn("[Kiro] Max iterations reached in event parsing");
|
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) {
|
flush(controller) {
|
||||||
|
|||||||
@@ -87,5 +87,6 @@ export default {
|
|||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
usage: true,
|
usage: true,
|
||||||
|
usageApikey: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,19 @@ function parseKiroQuotaData(data) {
|
|||||||
|
|
||||||
export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||||
const authMethod = providerSpecificData?.authMethod || "builder-id";
|
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({
|
const getUsageParams = new URLSearchParams({
|
||||||
isEmailRequired: "true",
|
isEmailRequired: "true",
|
||||||
@@ -71,6 +83,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
"x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
||||||
"user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
"user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
||||||
|
...apiKeyHeaders,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
proxyOptions
|
proxyOptions
|
||||||
@@ -85,10 +98,11 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
|
|||||||
"Content-Type": "application/x-amz-json-1.0",
|
"Content-Type": "application/x-amz-json-1.0",
|
||||||
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
...apiKeyHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
origin: "AI_EDITOR",
|
origin: "AI_EDITOR",
|
||||||
profileArn,
|
...(profileArn ? { profileArn } : {}),
|
||||||
resourceType: "AGENTIC_REQUEST",
|
resourceType: "AGENTIC_REQUEST",
|
||||||
}),
|
}),
|
||||||
}, proxyOptions),
|
}, proxyOptions),
|
||||||
@@ -98,7 +112,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
origin: "AI_EDITOR",
|
origin: "AI_EDITOR",
|
||||||
profileArn,
|
...(profileArn ? { profileArn } : {}),
|
||||||
resourceType: "AGENTIC_REQUEST",
|
resourceType: "AGENTIC_REQUEST",
|
||||||
});
|
});
|
||||||
return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, {
|
return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, {
|
||||||
@@ -106,6 +120,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio
|
|||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
...apiKeyHeaders,
|
||||||
},
|
},
|
||||||
}, proxyOptions);
|
}, proxyOptions);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,21 +76,29 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
|||||||
|
|
||||||
// If same format, skip translation steps
|
// If same format, skip translation steps
|
||||||
if (sourceFormat !== targetFormat) {
|
if (sourceFormat !== targetFormat) {
|
||||||
// Step 1: source -> openai (if source is not openai)
|
// Direct route: if a translator is registered for this exact source:target
|
||||||
if (sourceFormat !== FORMATS.OPENAI) {
|
// pair, use it instead of pivoting through OpenAI. This is lossless for
|
||||||
const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`);
|
// pairs like claude:kiro (avoids the claude->openai->kiro double-hop).
|
||||||
if (toOpenAI) {
|
const directFn = requestRegistry.get(`${sourceFormat}:${targetFormat}`);
|
||||||
result = toOpenAI(model, result, stream, credentials);
|
if (directFn) {
|
||||||
// Log OpenAI intermediate format
|
result = directFn(model, result, stream, credentials);
|
||||||
reqLogger?.logOpenAIRequest?.(result);
|
} 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)
|
// Step 2: openai -> target (if target is not openai)
|
||||||
if (targetFormat !== FORMATS.OPENAI) {
|
if (targetFormat !== FORMATS.OPENAI) {
|
||||||
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
|
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
|
||||||
if (fromOpenAI) {
|
if (fromOpenAI) {
|
||||||
result = fromOpenAI(model, result, stream, credentials);
|
result = fromOpenAI(model, result, stream, credentials);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,16 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
|||||||
let results = [chunk];
|
let results = [chunk];
|
||||||
let openaiResults = null; // Store OpenAI intermediate results
|
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)
|
// Step 1: target -> openai (if target is not openai)
|
||||||
if (targetFormat !== FORMATS.OPENAI) {
|
if (targetFormat !== FORMATS.OPENAI) {
|
||||||
const toOpenAI = responseRegistry.get(`${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-cursor.js";
|
||||||
import "./request/openai-to-ollama.js";
|
import "./request/openai-to-ollama.js";
|
||||||
import "./request/openai-to-commandcode.js";
|
import "./request/openai-to-commandcode.js";
|
||||||
|
import "./request/claude-to-kiro.js";
|
||||||
import "./response/claude-to-openai.js";
|
import "./response/claude-to-openai.js";
|
||||||
import "./response/openai-to-claude.js";
|
import "./response/openai-to-claude.js";
|
||||||
import "./response/gemini-to-openai.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/cursor-to-openai.js";
|
||||||
import "./response/ollama-to-openai.js";
|
import "./response/ollama-to-openai.js";
|
||||||
import "./response/commandcode-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);
|
||||||
@@ -524,8 +524,16 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
|||||||
|
|
||||||
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
|
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
|
||||||
|
|
||||||
const profileArn = credentials?.providerSpecificData?.profileArn
|
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
|
||||||
|| resolveDefaultProfileArn(credentials?.providerSpecificData?.authMethod);
|
// 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 || "";
|
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);
|
||||||
@@ -166,8 +166,9 @@ export default function ProvidersPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getProviderStats = (providerId, authType) => {
|
const getProviderStats = (providerId, authType) => {
|
||||||
|
const authTypes = Array.isArray(authType) ? authType : [authType];
|
||||||
const providerConnections = connections.filter(
|
const providerConnections = connections.filter(
|
||||||
(c) => c.provider === providerId && c.authType === authType,
|
(c) => c.provider === providerId && authTypes.includes(c.authType),
|
||||||
);
|
);
|
||||||
|
|
||||||
const getEffectiveStatus = (conn) => {
|
const getEffectiveStatus = (conn) => {
|
||||||
@@ -208,17 +209,15 @@ export default function ProvidersPage() {
|
|||||||
return { connected, error, total, errorCode, errorTime, allDisabled };
|
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 handleToggleProvider = async (providerId, authType, newActive) => {
|
||||||
const providerConns = connections.filter(
|
const authTypes = Array.isArray(authType) ? authType : [authType];
|
||||||
(c) => c.provider === providerId && c.authType === authType,
|
const matches = (c) =>
|
||||||
);
|
c.provider === providerId && authTypes.includes(c.authType);
|
||||||
|
const providerConns = connections.filter(matches);
|
||||||
setConnections((prev) =>
|
setConnections((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) => (matches(c) ? { ...c, isActive: newActive } : c)),
|
||||||
c.provider === providerId && c.authType === authType
|
|
||||||
? { ...c, isActive: newActive }
|
|
||||||
: c,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
providerConns.map((c) =>
|
providerConns.map((c) =>
|
||||||
@@ -465,16 +464,26 @@ export default function ProvidersPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
<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]) => {
|
||||||
<ProviderCard
|
// Kiro accepts both OAuth and api-key connections; count/toggle both
|
||||||
key={key}
|
// so the card total matches the provider detail page (#kiro-apikey).
|
||||||
providerId={key}
|
// Kiro's headless api-key flow persists authType "api_key" (underscore),
|
||||||
provider={info}
|
// while generic apikey providers use "apikey" — include both spellings.
|
||||||
stats={getProviderStats(key, "oauth")}
|
const freeAuthTypes =
|
||||||
authType="free"
|
key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth";
|
||||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
return (
|
||||||
/>
|
<ProviderCard
|
||||||
))}
|
key={key}
|
||||||
|
providerId={key}
|
||||||
|
provider={info}
|
||||||
|
stats={getProviderStats(key, freeAuthTypes)}
|
||||||
|
authType="free"
|
||||||
|
onToggle={(active) =>
|
||||||
|
handleToggleProvider(key, freeAuthTypes, active)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{freeTierEntries.map(([key, info]) => (
|
{freeTierEntries.map(([key, info]) => (
|
||||||
<ApiKeyProviderCard
|
<ApiKeyProviderCard
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -37,6 +37,36 @@ import {
|
|||||||
import Card from "@/shared/components/Card";
|
import Card from "@/shared/components/Card";
|
||||||
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
|
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
|
||||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
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) {
|
function getCodexResetCreditCount(quota) {
|
||||||
const value = quota?.raw?.resetCredits?.availableCount;
|
const value = quota?.raw?.resetCredits?.availableCount;
|
||||||
@@ -45,6 +75,7 @@ function getCodexResetCreditCount(quota) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProviderLimits() {
|
export default function ProviderLimits() {
|
||||||
|
const { copied, copy } = useCopyToClipboard();
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [quotaData, setQuotaData] = useState({});
|
const [quotaData, setQuotaData] = useState({});
|
||||||
const [loading, setLoading] = useState({});
|
const [loading, setLoading] = useState({});
|
||||||
@@ -892,6 +923,46 @@ export default function ProviderLimits() {
|
|||||||
Reset eligible: {resetCreditCount}
|
Reset eligible: {resetCreditCount}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const SAFE_PSD_FIELDS = [
|
|||||||
"connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy",
|
"connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy",
|
||||||
"githubLogin", "githubName", "githubEmail", "githubUserId",
|
"githubLogin", "githubName", "githubEmail", "githubUserId",
|
||||||
"username", "firstName", "lastName", "authMethod", "authKind",
|
"username", "firstName", "lastName", "authMethod", "authKind",
|
||||||
|
"profileArn",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|||||||
@@ -131,11 +131,14 @@ export async function GET(request, { params }) {
|
|||||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
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 isOAuth = connection.authType === "oauth";
|
||||||
|
const isApikeyAuth =
|
||||||
|
connection.authType === "apikey" || connection.authType === "api_key";
|
||||||
const isApikeyEligible =
|
const isApikeyEligible =
|
||||||
connection.authType === "apikey" &&
|
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||||
USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
|
||||||
|
|
||||||
if (!isOAuth && !isApikeyEligible) {
|
if (!isOAuth && !isApikeyEligible) {
|
||||||
return Response.json({ message: "Usage not available for this connection" });
|
return Response.json({ message: "Usage not available for this connection" });
|
||||||
|
|||||||
@@ -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
|
* List available models from CodeWhisperer API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
|||||||
const [idcStartUrl, setIdcStartUrl] = useState("");
|
const [idcStartUrl, setIdcStartUrl] = useState("");
|
||||||
const [idcRegion, setIdcRegion] = useState("us-east-1");
|
const [idcRegion, setIdcRegion] = useState("us-east-1");
|
||||||
const [refreshToken, setRefreshToken] = useState("");
|
const [refreshToken, setRefreshToken] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [autoDetecting, setAutoDetecting] = 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 });
|
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) => {
|
const handleSocialLogin = (provider) => {
|
||||||
onMethodSelect("social", { provider });
|
onMethodSelect("social", { provider });
|
||||||
};
|
};
|
||||||
@@ -142,6 +178,22 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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 */}
|
{/* Google Social Login - HIDDEN */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMethodSelect("social-google")}
|
onClick={() => handleMethodSelect("social-google")}
|
||||||
@@ -240,6 +292,63 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
|||||||
</div>
|
</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) */}
|
{/* Social Login Info (Google) */}
|
||||||
{selectedMethod === "social-google" && (
|
{selectedMethod === "social-google" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onCl
|
|||||||
// Use social login with manual callback
|
// Use social login with manual callback
|
||||||
setAuthMethod("social");
|
setAuthMethod("social");
|
||||||
setSocialProvider(config.provider);
|
setSocialProvider(config.provider);
|
||||||
} else if (method === "import") {
|
} else if (method === "import" || method === "api-key") {
|
||||||
// Import handled in KiroAuthModal, just close
|
// Import / API-key handled in KiroAuthModal, just close
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
}
|
}
|
||||||
}, [onSuccess]);
|
}, [onSuccess]);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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-cursor.js";
|
||||||
import "../../open-sse/translator/request/openai-to-ollama.js";
|
import "../../open-sse/translator/request/openai-to-ollama.js";
|
||||||
import "../../open-sse/translator/request/openai-to-commandcode.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/claude-to-openai.js";
|
||||||
import "../../open-sse/translator/response/openai-to-claude.js";
|
import "../../open-sse/translator/response/openai-to-claude.js";
|
||||||
import "../../open-sse/translator/response/gemini-to-openai.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/cursor-to-openai.js";
|
||||||
import "../../open-sse/translator/response/ollama-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/commandcode-to-openai.js";
|
||||||
|
import "../../open-sse/translator/response/kiro-to-claude.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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,10 @@ export default defineConfig({
|
|||||||
environment: "node",
|
environment: "node",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["**/*.test.js"],
|
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)
|
// Allow many it.concurrent cases (real provider smoke runs ~50 providers in parallel)
|
||||||
maxConcurrency: 60,
|
maxConcurrency: 60,
|
||||||
// Suppress noisy console output from handlers under test
|
// Suppress noisy console output from handlers under test
|
||||||
|
|||||||
Reference in New Issue
Block a user