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
+58
-11
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user