mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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>
262 lines
7.9 KiB
JavaScript
262 lines
7.9 KiB
JavaScript
/**
|
|
* 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);
|