Feat Kiro OAuth, Fix Codex

This commit is contained in:
decolua
2026-01-15 18:29:47 +07:00
parent c208f244ee
commit 26b61e5fbb
25 changed files with 1856 additions and 78 deletions
File diff suppressed because one or more lines are too long
+15 -1
View File
@@ -37,7 +37,7 @@ export const PROVIDERS = {
},
codex: {
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
format: "codex",
format: "openai-responses", // Use OpenAI Responses API format (reuse translator)
headers: {
"Version": "0.21.0",
"Openai-Beta": "responses=experimental",
@@ -135,6 +135,20 @@ export const PROVIDERS = {
"Accept": "application/json",
"Content-Type": "application/json"
}
},
kiro: {
baseUrl: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
format: "kiro",
headers: {
"Content-Type": "application/json",
"Accept": "application/vnd.amazon.eventstream",
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0"
},
// Kiro OAuth endpoints
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev"
}
};
+6
View File
@@ -69,6 +69,11 @@ export const PROVIDER_MODELS = {
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
],
kr: [ // Kiro AI
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
],
// API Key Providers (alias = id)
openai: [
@@ -145,6 +150,7 @@ export const PROVIDER_ID_TO_ALIAS = {
iflow: "if",
antigravity: "ag",
github: "gh",
kiro: "kr",
openai: "openai",
anthropic: "anthropic",
gemini: "gemini",
+28
View File
@@ -0,0 +1,28 @@
import { BaseExecutor } from "./base.js";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
import { PROVIDERS } from "../config/constants.js";
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
*/
export class CodexExecutor extends BaseExecutor {
constructor() {
super("codex", PROVIDERS.codex);
}
/**
* Transform request before sending - inject default instructions if missing
*/
transformRequest(model, body, stream, credentials) {
// If no instructions provided, inject default Codex instructions
if (!body.instructions || body.instructions.trim() === "") {
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
}
// Ensure store is false (Codex requirement)
body.store = false;
return body;
}
}
+13 -1
View File
@@ -51,7 +51,8 @@ export class DefaultExecutor extends BaseExecutor {
codex: () => this.refreshWithForm(OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access" }),
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }),
iflow: () => this.refreshIflow(credentials.refreshToken),
gemini: () => this.refreshGoogle(credentials.refreshToken)
gemini: () => this.refreshGoogle(credentials.refreshToken),
kiro: () => this.refreshKiro(credentials.refreshToken)
};
const refresher = refreshers[this.provider];
@@ -111,6 +112,17 @@ export class DefaultExecutor extends BaseExecutor {
const tokens = await response.json();
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
}
async refreshKiro(refreshToken) {
const response = await fetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "kiro-cli/1.0.0" },
body: JSON.stringify({ refreshToken })
});
if (!response.ok) return null;
const tokens = await response.json();
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn };
}
}
export default DefaultExecutor;
+7 -1
View File
@@ -1,12 +1,16 @@
import { AntigravityExecutor } from "./antigravity.js";
import { GeminiCLIExecutor } from "./gemini-cli.js";
import { GithubExecutor } from "./github.js";
import { KiroExecutor } from "./kiro.js";
import { CodexExecutor } from "./codex.js";
import { DefaultExecutor } from "./default.js";
const executors = {
antigravity: new AntigravityExecutor(),
"gemini-cli": new GeminiCLIExecutor(),
github: new GithubExecutor()
github: new GithubExecutor(),
kiro: new KiroExecutor(),
codex: new CodexExecutor()
};
const defaultCache = new Map();
@@ -25,4 +29,6 @@ export { BaseExecutor } from "./base.js";
export { AntigravityExecutor } from "./antigravity.js";
export { GeminiCLIExecutor } from "./gemini-cli.js";
export { GithubExecutor } from "./github.js";
export { KiroExecutor } from "./kiro.js";
export { CodexExecutor } from "./codex.js";
export { DefaultExecutor } from "./default.js";
+421
View File
@@ -0,0 +1,421 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/constants.js";
import { v4 as uuidv4 } from "uuid";
/**
* KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer)
* Uses AWS CodeWhisperer streaming API with AWS EventStream binary format
*/
export class KiroExecutor extends BaseExecutor {
constructor() {
super("kiro", PROVIDERS.kiro);
}
buildHeaders(credentials, stream = true) {
const headers = {
...this.config.headers,
"Amz-Sdk-Request": "attempt=1; max=3",
"Amz-Sdk-Invocation-Id": uuidv4()
};
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
return headers;
}
transformRequest(model, body, stream, credentials) {
return body;
}
/**
* Custom execute for Kiro - handles AWS EventStream binary response
*/
async execute({ model, body, stream, credentials, signal, log }) {
const url = this.buildUrl(model, stream, 0);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
});
if (!response.ok) {
return { response, url, headers, transformedBody };
}
// For Kiro, we need to transform the binary EventStream to SSE
// Create a TransformStream to convert binary to SSE text
const transformedResponse = this.transformEventStreamToSSE(response, model);
return { response: transformedResponse, url, headers, transformedBody };
}
/**
* Transform AWS EventStream binary response to SSE text stream
*/
transformEventStreamToSSE(response, model) {
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const responseId = `chatcmpl-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const state = {
endDetected: false,
finishEmitted: false,
hasToolCalls: false,
toolCallIndex: 0,
seenToolIds: new Map() // Map toolUseId -> index
};
const stream = new ReadableStream({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
// Emit finish_reason chunk if not already sent
if (!state.finishEmitted) {
state.finishEmitted = true;
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
// Send final done message
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
return;
}
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
// Parse events from buffer
while (buffer.length >= 16) {
const view = new DataView(buffer.buffer, buffer.byteOffset);
const totalLength = view.getUint32(0, false);
if (totalLength < 16 || buffer.length < totalLength) break;
// Extract event
const eventData = buffer.slice(0, totalLength);
buffer = buffer.slice(totalLength);
// Parse event headers and payload
const event = parseEventFrame(eventData);
if (!event) continue;
const eventType = event.headers[":event-type"] || "";
// Handle assistantResponseEvent
if (eventType === "assistantResponseEvent" && event.payload?.content) {
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: chunkIndex === 0
? { role: "assistant", content: event.payload.content }
: { content: event.payload.content },
finish_reason: null
}]
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Handle codeEvent
if (eventType === "codeEvent" && event.payload?.content) {
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: { content: event.payload.content },
finish_reason: null
}]
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Handle toolUseEvent
if (eventType === "toolUseEvent" && event.payload) {
console.log("[KIRO DEBUG] toolUseEvent payload:", JSON.stringify(event.payload, null, 2));
state.hasToolCalls = true; // Track that we have tool calls
const toolUse = event.payload;
// AWS Kiro sends toolUse as object or array
// If it's an array, process each tool separately
const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse];
for (const singleToolUse of toolUses) {
const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`;
const toolName = singleToolUse.name || "";
const toolInput = singleToolUse.input; // Can be undefined, string, or object
// Get or assign tool call index
let toolIndex;
const isNewTool = !state.seenToolIds.has(toolCallId);
if (isNewTool) {
// NEW TOOL: Create start chunk
toolIndex = state.toolCallIndex++;
state.seenToolIds.set(toolCallId, toolIndex);
const startChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {
...(chunkIndex === 0 ? { role: "assistant" } : {}),
tool_calls: [{
index: toolIndex,
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: ""
}
}]
},
finish_reason: null
}]
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`));
} else {
// EXISTING TOOL: Get its index
toolIndex = state.seenToolIds.get(toolCallId);
}
// Emit arguments chunk if input exists
// AWS Kiro streams input as: undefined (first event) → string chunks
if (toolInput !== undefined) {
let argumentsStr;
if (typeof toolInput === 'string') {
// AWS Kiro sends partial JSON as STRING
argumentsStr = toolInput;
} else if (typeof toolInput === 'object') {
// Fallback: if it's an object, stringify it
argumentsStr = JSON.stringify(toolInput);
} else {
// Skip if not string or object
continue;
}
const argsChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {
tool_calls: [{
index: toolIndex,
function: {
arguments: argumentsStr
}
}]
},
finish_reason: null
}]
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`));
}
}
}
// Handle messageStopEvent
if (eventType === "messageStopEvent") {
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
state.finishEmitted = true;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Detect end of stream: meteringEvent + contextUsageEvent usually come last
// Kiro doesn't always send messageStopEvent, so we need to detect completion
if ((eventType === "meteringEvent" || eventType === "contextUsageEvent") && !state.endDetected) {
state.endDetected = true;
// Schedule finish chunk emission after a short delay
setTimeout(() => {
if (!state.finishEmitted) {
state.finishEmitted = true;
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}]
};
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
}, 100); // 100ms delay to check for more events
}
}
} catch (error) {
controller.error(error);
}
},
cancel() {
reader.cancel();
}
});
// Create new response with SSE headers
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
});
}
async refreshCredentials(credentials, log) {
if (!credentials.refreshToken) return null;
try {
const response = await fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "kiro-cli/1.0.0"
},
body: JSON.stringify({
refreshToken: credentials.refreshToken
})
});
if (!response.ok) {
log?.error?.("TOKEN", `Kiro refresh failed: ${response.status}`);
return null;
}
const data = await response.json();
const result = {
accessToken: data.accessToken,
refreshToken: data.refreshToken || credentials.refreshToken,
expiresIn: data.expiresIn || 3600
};
log?.info?.("TOKEN", "Kiro token refreshed");
return result;
} catch (error) {
log?.error?.("TOKEN", `Kiro refresh error: ${error.message}`);
return null;
}
}
}
/**
* Parse AWS EventStream frame
*/
function parseEventFrame(data) {
try {
const view = new DataView(data.buffer, data.byteOffset);
const headersLength = view.getUint32(4, false);
// Parse headers
const headers = {};
let offset = 12; // After prelude
const headerEnd = 12 + headersLength;
while (offset < headerEnd && offset < data.length) {
const nameLen = data[offset];
offset++;
if (offset + nameLen > data.length) break;
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
offset += nameLen;
const headerType = data[offset];
offset++;
if (headerType === 7) { // String type
const valueLen = (data[offset] << 8) | data[offset + 1];
offset += 2;
if (offset + valueLen > data.length) break;
const value = new TextDecoder().decode(data.slice(offset, offset + valueLen));
offset += valueLen;
headers[name] = value;
} else {
break;
}
}
// Parse payload
const payloadStart = 12 + headersLength;
const payloadEnd = data.length - 4; // Exclude message CRC
let payload = null;
if (payloadEnd > payloadStart) {
const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd));
// Skip empty or whitespace-only payloads
if (!payloadStr || !payloadStr.trim()) {
return { headers, payload: null };
}
try {
payload = JSON.parse(payloadStr);
} catch (parseError) {
// Log parse error for debugging
console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`);
payload = { raw: payloadStr };
}
}
return { headers, payload };
} catch {
return null;
}
}
export default KiroExecutor;
-9
View File
@@ -96,15 +96,6 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// 1. Log raw request from client
reqLogger.logRawRequest(body);
// 1a. Log format detection info
reqLogger.logFormatInfo({
sourceFormat,
targetFormat,
provider,
model,
stream
});
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
+2 -1
View File
@@ -21,7 +21,8 @@ export function getQuotaCooldown(backoffLevel = 0) {
export function checkFallbackError(status, errorText, backoffLevel = 0) {
// Check error message FIRST - specific patterns take priority over status codes
if (errorText) {
const lowerError = errorText.toLowerCase();
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
const lowerError = errorStr.toLowerCase();
// "Request not allowed" - short cooldown (5s), takes priority over status code
if (lowerError.includes("request not allowed")) {
+1
View File
@@ -7,6 +7,7 @@ const ALIAS_TO_PROVIDER_ID = {
if: "iflow",
ag: "antigravity",
gh: "github",
kr: "kiro",
// API Key providers (alias = id)
openai: "openai",
anthropic: "anthropic",
+90
View File
@@ -240,6 +240,96 @@ export async function refreshCodexToken(refreshToken, log) {
};
}
/**
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
*/
export async function refreshKiroToken(refreshToken, providerSpecificData, log) {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint = isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientId,
clientSecret: clientSecret,
refreshToken: refreshToken,
grantType: "refresh_token",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
}
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
const response = await fetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
refreshToken: refreshToken,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
}
/**
* Specialized refresh for iFlow OAuth tokens
*/
+2 -1
View File
@@ -7,6 +7,7 @@ export const FORMATS = {
GEMINI: "gemini",
GEMINI_CLI: "gemini-cli",
CODEX: "codex",
ANTIGRAVITY: "antigravity"
ANTIGRAVITY: "antigravity",
KIRO: "kiro"
};
+2
View File
@@ -163,10 +163,12 @@ export async function initTranslators() {
await import("./request/gemini-to-openai.js");
await import("./request/openai-to-gemini.js");
await import("./request/openai-responses.js");
await import("./request/openai-to-kiro.js");
// Response translators
await import("./response/claude-to-openai.js");
await import("./response/openai-to-claude.js");
await import("./response/gemini-to-openai.js");
await import("./response/openai-responses.js");
await import("./response/kiro-to-openai.js");
}
@@ -131,6 +131,103 @@ function openaiResponsesToOpenAIRequest(model, body, stream, credentials) {
return result;
}
// Register
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
function openaiToOpenAIResponsesRequest(model, body, stream, credentials) {
const result = {
model,
input: [],
stream: true,
store: false
};
// Extract system message as instructions
let hasSystemMessage = false;
const messages = body.messages || [];
for (const msg of messages) {
if (msg.role === "system") {
// Use first system message as instructions
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue; // Skip system messages in input
}
// Convert user/assistant messages to input items
if (msg.role === "user" || msg.role === "assistant") {
const contentType = msg.role === "user" ? "input_text" : "output_text";
const content = typeof msg.content === "string"
? [{ type: contentType, text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map(c => {
if (c.type === "text") return { type: contentType, text: c.text };
if (c.type === "image_url") return { type: contentType, text: "[Image content]" };
return c;
})
: [];
result.input.push({
type: "message",
role: msg.role,
content
});
}
// Convert tool calls
if (msg.role === "assistant" && msg.tool_calls) {
for (const tc of msg.tool_calls) {
result.input.push({
type: "function_call",
call_id: tc.id,
name: tc.function?.name || "",
arguments: tc.function?.arguments || "{}"
});
}
}
// Convert tool results
if (msg.role === "tool") {
result.input.push({
type: "function_call_output",
call_id: msg.tool_call_id,
output: msg.content
});
}
}
// If no system message, leave instructions empty (will be filled by executor)
if (!hasSystemMessage) {
result.instructions = "";
}
// Convert tools format
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map(tool => {
if (tool.type === "function") {
return {
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict
};
}
return tool;
});
}
// Pass through other relevant fields
if (body.temperature !== undefined) result.temperature = body.temperature;
if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
if (body.top_p !== undefined) result.top_p = body.top_p;
return result;
}
// Register both directions
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);
@@ -0,0 +1,317 @@
/**
* OpenAI to Kiro Request Translator
* Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
/**
* Convert OpenAI messages to Kiro format
*/
function convertMessages(messages, tools, model) {
let history = [];
let currentMessage = null;
let systemPrompt = "";
// Collect tool results first (they come as separate messages with role: "tool")
const toolResultsMap = new Map(); // Map tool_call_id -> content
for (const msg of messages) {
if (msg.role === "tool" && msg.tool_call_id) {
const content = typeof msg.content === "string" ? msg.content :
(Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : "");
toolResultsMap.set(msg.tool_call_id, content);
}
}
for (const msg of messages) {
const role = msg.role;
// Skip tool messages - already processed above
if (role === "tool") {
continue;
}
const content = typeof msg.content === "string" ? msg.content :
(Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : "");
if (role === "system") {
systemPrompt += (systemPrompt ? "\n" : "") + content;
continue;
}
if (role === "user") {
const userMsg = {
userInputMessage: {
content: content,
modelId: "", // Will be set later
origin: "AI_EDITOR"
}
};
// Add tools to first user message context
if (tools && tools.length > 0 && history.length === 0) {
userMsg.userInputMessage.userInputMessageContext = {
tools: tools.map(t => {
const name = t.function?.name || t.name;
let description = t.function?.description || t.description || "";
// CRITICAL: Kiro API requires non-empty description
if (!description.trim()) {
description = `Tool: ${name}`;
}
// Truncate long descriptions (Kiro max is ~5000 chars based on testing)
// Keep it reasonable but allow more detail than 2000 chars
// const maxDescLen = 5000;
// if (description.length > maxDescLen) {
// // Smart truncation: keep first 80% and add marker
// description = description.slice(0, maxDescLen - 100) + "\n\n[Note: Full description truncated for API limits. Tool functionality remains intact.]";
// }
return {
toolSpecification: {
name,
description,
inputSchema: {
json: t.function?.parameters || t.parameters || {}
}
}
};
})
};
}
currentMessage = userMsg;
history.push(userMsg);
}
if (role === "assistant") {
const assistantMsg = {
assistantResponseMessage: {
content: content
}
};
// Handle tool calls
if (msg.tool_calls && msg.tool_calls.length > 0) {
assistantMsg.assistantResponseMessage.toolUses = msg.tool_calls.map(tc => ({
toolUseId: tc.id || uuidv4(),
name: tc.function?.name || tc.name,
input: typeof tc.function?.arguments === "string"
? JSON.parse(tc.function.arguments)
: (tc.function?.arguments || tc.arguments || {})
}));
// Collect tool results for this assistant message's tool calls
const toolResults = [];
for (const tc of msg.tool_calls) {
const toolResult = toolResultsMap.get(tc.id);
if (toolResult !== undefined) {
toolResults.push({
content: [{ text: toolResult }],
status: "success",
toolUseId: tc.id
});
}
}
// Add tool results to the NEXT user message if they exist
if (toolResults.length > 0) {
// Store for next user message
assistantMsg._pendingToolResults = toolResults;
}
}
history.push(assistantMsg);
}
}
// Apply pending tool results to user messages
for (let i = 0; i < history.length; i++) {
if (history[i].assistantResponseMessage?._pendingToolResults) {
const toolResults = history[i].assistantResponseMessage._pendingToolResults;
delete history[i].assistantResponseMessage._pendingToolResults;
// Find next user message
for (let j = i + 1; j < history.length; j++) {
if (history[j].userInputMessage) {
if (!history[j].userInputMessage.userInputMessageContext) {
history[j].userInputMessage.userInputMessageContext = {};
}
history[j].userInputMessage.userInputMessageContext.toolResults = toolResults;
break;
}
}
}
}
// Also check currentMessage for pending tool results
if (history.length > 0 && history[history.length - 1].assistantResponseMessage?._pendingToolResults) {
const toolResults = history[history.length - 1].assistantResponseMessage._pendingToolResults;
delete history[history.length - 1].assistantResponseMessage._pendingToolResults;
if (currentMessage?.userInputMessage) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.toolResults = toolResults;
}
}
// Pop last message as currentMessage if it's user message
if (history.length > 0 && history[history.length - 1].userInputMessage) {
currentMessage = history.pop();
}
// Move tools from history to currentMessage if needed
const firstHistoryItem = history[0];
if (firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools &&
!currentMessage?.userInputMessage?.userInputMessageContext?.tools) {
// Move tools to currentMessage
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryItem.userInputMessage.userInputMessageContext.tools;
console.log(`[Kiro Translator] Moved ${currentMessage.userInputMessage.userInputMessageContext.tools.length} tools to currentMessage`);
}
// CRITICAL: Clean up history for Kiro API compatibility
// Kiro API has strict limitations on history content:
// 1. NO toolUses in assistant messages (causes 400 Bad Request)
// 2. NO toolResults in user messages (causes 400 Bad Request)
// 3. NO tools definitions in history (only in currentMessage)
// 4. NO empty userInputMessageContext objects
// 5. modelId must NOT be empty string
// 6. NO consecutive user messages (must alternate user/assistant)
history.forEach(item => {
// Remove toolUses from assistant messages (Kiro doesn't support tool history)
if (item.assistantResponseMessage?.toolUses) {
delete item.assistantResponseMessage.toolUses;
}
// Remove tools from user messages (only currentMessage should have tools)
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
// Remove toolResults from user messages (Kiro doesn't support passing tool results via history)
if (item.userInputMessage?.userInputMessageContext?.toolResults) {
delete item.userInputMessage.userInputMessageContext.toolResults;
}
// Remove empty userInputMessageContext
if (item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
delete item.userInputMessage.userInputMessageContext;
}
// Ensure modelId is not empty (use model from params if empty)
if (item.userInputMessage && !item.userInputMessage.modelId) {
item.userInputMessage.modelId = model;
}
});
// CRITICAL: Merge consecutive user messages
// Kiro API requires alternating user/assistant pattern in history
const mergedHistory = [];
for (let i = 0; i < history.length; i++) {
const current = history[i];
// If current is user message and previous is also user message, merge them
if (current.userInputMessage &&
mergedHistory.length > 0 &&
mergedHistory[mergedHistory.length - 1].userInputMessage) {
// Merge content into previous user message
const prev = mergedHistory[mergedHistory.length - 1];
prev.userInputMessage.content += "\n\n" + current.userInputMessage.content;
console.log(`[Kiro Translator] Merged consecutive user messages in history`);
} else {
// Add normally
mergedHistory.push(current);
}
}
history = mergedHistory;
// Log payload size warning if system prompt is very long
const systemPromptSize = systemPrompt.length;
if (systemPromptSize > 10000) {
console.warn(`[Kiro Translator] WARNING: System prompt is ${systemPromptSize} chars. Total payload may be large.`);
}
return { history, currentMessage, systemPrompt };
}
/**
* Build Kiro payload from OpenAI format
*/
function buildKiroPayload(model, body, stream, credentials) {
const messages = body.messages || [];
const tools = body.tools || [];
const maxTokens = body.max_tokens || 32000;
const temperature = body.temperature;
const topP = body.top_p;
const { history, currentMessage, systemPrompt } = convertMessages(messages, tools, model);
// Get profileArn from credentials
const profileArn = credentials?.providerSpecificData?.profileArn || "";
// Inject system prompt into current message content
let finalContent = currentMessage?.userInputMessage?.content || "";
if (systemPrompt) {
// Log warning if system prompt is very long (may cause Kiro API to reject request)
if (systemPrompt.length > 10000) {
console.warn(`[Kiro Translator] WARNING: System prompt is very long (${systemPrompt.length} chars). Kiro API may reject requests with total content >20KB. Consider reducing system prompt length.`);
}
finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`;
}
// Add timestamp context
const timestamp = new Date().toISOString();
finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`;
// Log final content size for debugging
if (finalContent.length > 20000) {
console.warn(`[Kiro Translator] WARNING: Final content size is ${finalContent.length} chars. Kiro API typically rejects requests >20-30KB.`);
}
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: model,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
})
}
},
history: history
}
};
// Only add profileArn if available
if (profileArn) {
payload.profileArn = profileArn;
}
// Add inference config if specified
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;
}
return payload;
}
// Register translator
register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null);
export { buildKiroPayload };
@@ -0,0 +1,185 @@
/**
* Kiro to OpenAI Response Translator
* Converts Kiro/AWS CodeWhisperer streaming events to OpenAI SSE format
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Parse Kiro SSE event and convert to OpenAI format
* Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc.
*/
function convertKiroToOpenAI(chunk, state) {
if (!chunk) return null;
// If chunk is already in OpenAI format (from executor transform), return as-is
if (chunk.object === "chat.completion.chunk" && chunk.choices) {
return chunk;
}
console.log("chunk", chunk);
// Handle string chunk (raw SSE data)
let data = chunk;
if (typeof chunk === "string") {
// Parse SSE format: event:xxx\ndata:xxx
const lines = chunk.split("\n");
let eventType = "";
let eventData = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith(":event-type:")) {
eventType = line.slice(12).trim();
} else if (line.startsWith("data:")) {
eventData = line.slice(5).trim();
} else if (line.startsWith(":content-type:")) {
// Skip content-type header
} else if (line.trim() && !line.startsWith(":")) {
// Raw JSON data
eventData = line.trim();
}
}
if (!eventData) return null;
try {
data = JSON.parse(eventData);
data._eventType = eventType;
} catch {
// Not JSON, might be raw text
data = { text: eventData, _eventType: eventType };
}
}
// Initialize state if needed
if (!state.responseId) {
state.responseId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.chunkIndex = 0;
}
const eventType = data._eventType || data.event || "";
// Handle different Kiro event types
if (eventType === "assistantResponseEvent" || data.assistantResponseEvent) {
const content = data.assistantResponseEvent?.content || data.content || "";
if (!content) return null;
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
content: content
},
finish_reason: null
}]
};
state.chunkIndex++;
return openaiChunk;
}
// Handle reasoning/thinking events
if (eventType === "reasoningContentEvent" || data.reasoningContentEvent) {
const content = data.reasoningContentEvent?.content || data.content || "";
if (!content) return null;
// Convert to thinking block format (Claude-style)
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
content: `<thinking>${content}</thinking>`
},
finish_reason: null
}]
};
state.chunkIndex++;
return openaiChunk;
}
// Handle tool use events
if (eventType === "toolUseEvent" || data.toolUseEvent) {
const toolUse = data.toolUseEvent || data;
const toolCallId = toolUse.toolUseId || `call_${Date.now()}`;
const toolName = toolUse.name || "";
const toolInput = toolUse.input || {};
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
tool_calls: [{
index: 0,
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(toolInput)
}
}]
},
finish_reason: null
}]
};
state.chunkIndex++;
return openaiChunk;
}
// Handle completion/done events
if (eventType === "messageStopEvent" || eventType === "done" || data.messageStopEvent) {
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [{
index: 0,
delta: {},
finish_reason: "stop"
}]
};
return openaiChunk;
}
// Handle usage events
if (eventType === "usageEvent" || data.usageEvent) {
const usage = data.usageEvent || data;
state.usage = {
prompt_tokens: usage.inputTokens || 0,
completion_tokens: usage.outputTokens || 0,
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0)
};
return null;
}
// Unknown event type - skip
return null;
}
// Register translator
register(FORMATS.KIRO, FORMATS.OPENAI, null, convertKiroToOpenAI);
export { convertKiroToOpenAI };
@@ -0,0 +1,205 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Prefix for Claude OAuth tool names (must match request translator)
const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_";
// Helper: stop thinking block if started
function stopThinkingBlock(state, results) {
if (!state.thinkingBlockStarted) return;
results.push({
type: "content_block_stop",
index: state.thinkingBlockIndex
});
state.thinkingBlockStarted = false;
}
// Helper: stop text block if started
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;
}
// Convert OpenAI stream chunk to Claude format
function openaiToClaudeResponse(chunk, state) {
if (!chunk || !chunk.choices?.[0]) return null;
const results = [];
const choice = chunk.choices[0];
const delta = choice.delta;
// First chunk - ALWAYS send message_start first
if (!state.messageStartSent) {
state.messageStartSent = true;
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) {
state.messageId = chunk.extend_fields?.requestId ||
chunk.extend_fields?.traceId ||
`msg_${Date.now()}`;
}
state.model = chunk.model || "unknown";
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 }
}
});
}
// Handle reasoning_content (thinking) - GLM, DeepSeek, etc.
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 }
});
}
// Handle regular 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 - accumulate arguments instead of emitting immediately
if (delta?.tool_calls) {
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++;
// Strip prefix from tool name for response
let toolName = tc.function?.name || "";
if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
}
// Initialize accumulator for this tool
state.toolCalls.set(idx, {
id: tc.id,
name: toolName,
blockIndex: toolBlockIndex,
arguments: "", // Accumulate arguments here
startEmitted: false // Track if content_block_start sent
});
}
// Accumulate arguments instead of emitting immediately
if (tc.function?.arguments) {
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
toolInfo.arguments += tc.function.arguments;
}
}
}
}
// Finish - emit all accumulated tools in correct order
if (choice.finish_reason) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
// STEP 1: Emit all content_block_start for tools (like CLIProxyAPIPlus)
const sortedTools = Array.from(state.toolCalls.entries()).sort((a, b) => a[0] - b[0]);
for (const [, toolInfo] of sortedTools) {
if (!toolInfo.startEmitted) {
results.push({
type: "content_block_start",
index: toolInfo.blockIndex,
content_block: {
type: "tool_use",
id: toolInfo.id,
name: toolInfo.name,
input: {}
}
});
toolInfo.startEmitted = true;
}
}
// STEP 2: Emit input_json_delta + content_block_stop for each tool
for (const [, toolInfo] of sortedTools) {
if (toolInfo.arguments) {
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: toolInfo.arguments }
});
}
results.push({
type: "content_block_stop",
index: toolInfo.blockIndex
});
}
results.push({
type: "message_delta",
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
usage: { output_tokens: 0 }
});
results.push({ type: "message_stop" });
}
return results.length > 0 ? results : null;
}
// Convert OpenAI finish_reason to Claude stop_reason
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";
}
}
// Register
register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse);
+32 -38
View File
@@ -68,22 +68,25 @@ function writeJsonFile(sessionPath, filename, data) {
}
}
// Mask sensitive data in headers
// Mask sensitive data in headers (DISABLED - keep full token for testing)
function maskSensitiveHeaders(headers) {
if (!headers) return {};
const masked = { ...headers };
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"];
return { ...headers };
for (const key of Object.keys(masked)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
const value = masked[key];
if (value && value.length > 20) {
masked[key] = value.slice(0, 10) + "..." + value.slice(-5);
}
}
}
return masked;
// Old masking code (disabled):
// const masked = { ...headers };
// const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"];
//
// for (const key of Object.keys(masked)) {
// const lowerKey = key.toLowerCase();
// if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
// const value = masked[key];
// if (value && value.length > 20) {
// masked[key] = value.slice(0, 10) + "..." + value.slice(-5);
// }
// }
// }
// return masked;
}
// No-op logger when logging is disabled
@@ -92,7 +95,6 @@ function createNoOpLogger() {
sessionPath: null,
logClientRawRequest() {},
logRawRequest() {},
logFormatInfo() {},
logConvertedRequest() {},
logRawResponse() {},
logConvertedResponse() {},
@@ -121,9 +123,9 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) {
return {
get sessionPath() { return sessionPath; },
// 0. Log client raw request (before any conversion)
// 1. Log client raw request (before any conversion)
logClientRawRequest(endpoint, body, headers = {}) {
writeJsonFile(sessionPath, "0_client_raw_request.json", {
writeJsonFile(sessionPath, "1_client_raw_request.json", {
timestamp: new Date().toISOString(),
endpoint,
headers: maskSensitiveHeaders(headers),
@@ -131,26 +133,18 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) {
});
},
// 1. Log raw request from client (after initial conversion like responsesApi)
// 2. Log raw request from client (after initial conversion like responsesApi)
logRawRequest(body, headers = {}) {
writeJsonFile(sessionPath, "1_raw_request.json", {
writeJsonFile(sessionPath, "2_raw_request.json", {
timestamp: new Date().toISOString(),
headers: maskSensitiveHeaders(headers),
body
});
},
// 1a. Log format detection info
logFormatInfo(info) {
writeJsonFile(sessionPath, "1a_format_info.json", {
timestamp: new Date().toISOString(),
...info
});
},
// 2. Log converted request to send to provider
// 3. Log converted request to send to provider
logConvertedRequest(url, headers, body) {
writeJsonFile(sessionPath, "2_converted_request.json", {
writeJsonFile(sessionPath, "3_converted_request.json", {
timestamp: new Date().toISOString(),
url,
headers: maskSensitiveHeaders(headers),
@@ -158,9 +152,9 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) {
});
},
// 3. Log provider response (for non-streaming or error)
// 4. Log provider response (for non-streaming or error)
logProviderResponse(status, statusText, headers, body) {
const filename = "3_provider_response.json";
const filename = "4_provider_response.json";
writeJsonFile(sessionPath, filename, {
timestamp: new Date().toISOString(),
status,
@@ -170,39 +164,39 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) {
});
},
// 3. Append streaming chunk to provider response
// 4. Append streaming chunk to provider response
appendProviderChunk(chunk) {
if (!fs || !sessionPath) return;
try {
const filePath = path.join(sessionPath, "3_provider_response.txt");
const filePath = path.join(sessionPath, "4_provider_response.txt");
fs.appendFileSync(filePath, chunk);
} catch (err) {
// Ignore append errors
}
},
// 4. Log converted response to client (for non-streaming)
// 5. Log converted response to client (for non-streaming)
logConvertedResponse(body) {
writeJsonFile(sessionPath, "4_converted_response.json", {
writeJsonFile(sessionPath, "5_converted_response.json", {
timestamp: new Date().toISOString(),
body
});
},
// 4. Append streaming chunk to converted response
// 5. Append streaming chunk to converted response
appendConvertedChunk(chunk) {
if (!fs || !sessionPath) return;
try {
const filePath = path.join(sessionPath, "4_converted_response.txt");
const filePath = path.join(sessionPath, "5_converted_response.txt");
fs.appendFileSync(filePath, chunk);
} catch (err) {
// Ignore append errors
}
},
// 5. Log error
// 6. Log error
logError(error, requestBody = null) {
writeJsonFile(sessionPath, "5_error.json", {
writeJsonFile(sessionPath, "6_error.json", {
timestamp: new Date().toISOString(),
error: error?.message || String(error),
stack: error?.stack,
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+11 -17
View File
@@ -38,7 +38,8 @@ export async function GET(request, { params }) {
// For providers that don't use PKCE (like GitHub), don't pass codeChallenge
let deviceData;
if (provider === "github") {
if (provider === "github" || provider === "kiro") {
// GitHub and Kiro don't use PKCE for device code
deviceData = await requestDeviceCode(provider);
} else {
// Qwen and other providers use PKCE
@@ -101,16 +102,19 @@ export async function POST(request, { params }) {
}
if (action === "poll") {
const { deviceCode, codeVerifier } = body;
const { deviceCode, codeVerifier, extraData } = body;
if (!deviceCode) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
// For providers that don't use PKCE (like GitHub), don't pass codeVerifier
// For providers that don't use PKCE (like GitHub, Kiro), don't pass codeVerifier
let result;
if (provider === "github") {
result = await pollForToken(provider, deviceCode);
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
} else {
// Qwen and other providers use PKCE
if (!codeVerifier) {
@@ -143,24 +147,14 @@ export async function POST(request, { params }) {
});
}
// Still pending or error
if (!result.pending) {
// Save error to database for actual errors (not pending)
await createProviderConnection({
provider,
authType: "oauth",
testStatus: "error",
lastError: result.errorDescription,
errorCode: result.error,
lastErrorAt: new Date().toISOString(),
});
}
// Still pending or error - don't create connection for pending states
const isPending = result.pending || result.error === "authorization_pending" || result.error === "slow_down";
return NextResponse.json({
success: false,
error: result.error,
errorDescription: result.errorDescription,
pending: result.pending || result.error === "authorization_pending",
pending: isPending,
});
}
+19
View File
@@ -113,6 +113,24 @@ export const GITHUB_CONFIG = {
editorPluginVersion: "copilot-chat/0.26.7",
};
// Kiro OAuth Configuration (AWS SSO OIDC Device Code Flow)
export const KIRO_CONFIG = {
// AWS SSO OIDC endpoints for Builder ID
ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com",
registerClientUrl: "https://oidc.us-east-1.amazonaws.com/client/register",
deviceAuthUrl: "https://oidc.us-east-1.amazonaws.com/device_authorization",
tokenUrl: "https://oidc.us-east-1.amazonaws.com/token",
refreshTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
// AWS Builder ID start URL
startUrl: "https://view.awsapps.com/start",
// Client registration params
clientName: "kiro-cli",
clientType: "public",
scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"],
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -126,4 +144,5 @@ export const PROVIDERS = {
ANTIGRAVITY: "antigravity",
OPENAI: "openai",
GITHUB: "github",
KIRO: "kiro",
};
+126 -2
View File
@@ -12,6 +12,7 @@ import {
IFLOW_CONFIG,
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
KIRO_CONFIG,
} from "./constants/oauth";
// Provider configurations
@@ -536,6 +537,125 @@ const PROVIDERS = {
},
}),
},
kiro: {
config: KIRO_CONFIG,
flowType: "device_code",
// Kiro uses AWS SSO OIDC - requires client registration first
requestDeviceCode: async (config) => {
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(config.registerClientUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(config.deviceAuthUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl: config.startUrl,
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
// Return combined data for polling
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
// Store client credentials for token exchange
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// AWS SSO OIDC returns camelCase
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
// Store client credentials for refresh
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
},
}),
},
};
/**
@@ -614,14 +734,18 @@ export async function requestDeviceCode(providerName, codeChallenge) {
/**
* Poll for token (for device_code flow)
* @param {string} providerName - Provider name
* @param {string} deviceCode - Device code from requestDeviceCode
* @param {string} codeVerifier - PKCE code verifier (optional for some providers)
* @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro)
*/
export async function pollForToken(providerName, deviceCode, codeVerifier) {
export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier);
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
if (result.ok) {
// For device code flows, success is only when we have an access token
+7 -6
View File
@@ -147,8 +147,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
try {
setError(null);
// Device code flow (GitHub, Qwen)
if (provider === "github" || provider === "qwen") {
// Device code flow (GitHub, Qwen, Kiro)
if (provider === "github" || provider === "qwen" || provider === "kiro") {
setIsDeviceCode(true);
setStep("waiting");
@@ -162,8 +162,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const verifyUrl = data.verification_uri_complete || data.verification_uri;
if (verifyUrl) window.open(verifyUrl, "_blank");
// Start polling
startPolling(data.device_code, data.codeVerifier, data.interval || 5);
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData = provider === "kiro" ? { _clientId: data._clientId, _clientSecret: data._clientSecret } : null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
}
@@ -209,7 +210,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
};
// Poll for device code token
const startPolling = async (deviceCode, codeVerifier, interval) => {
const startPolling = async (deviceCode, codeVerifier, interval, extraData) => {
setPolling(true);
const maxAttempts = 60;
@@ -220,7 +221,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const res = await fetch(`/api/oauth/${provider}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode, codeVerifier }),
body: JSON.stringify({ deviceCode, codeVerifier, extraData }),
});
const data = await res.json();
+1
View File
@@ -9,6 +9,7 @@ export const OAUTH_PROVIDERS = {
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
};
export const APIKEY_PROVIDERS = {
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* Test sending request from converted file directly to provider
* Usage:
* node testFromFile.js <file-path>
* node testFromFile.js data/claude-to-kiro/3_converted_request.json
*/
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log("");
console.log("🧪 Test From File - Send converted request to provider");
console.log("");
console.log("Usage:");
console.log(" node testFromFile.js <file-path>");
console.log("");
console.log("Examples:");
console.log(" node testFromFile.js data/claude-to-kiro/3_converted_request.json");
console.log(" node testFromFile.js ../logs/openai_codex_xxx/3_converted_request.json");
console.log("");
console.log("File format:");
console.log(" {");
console.log(" \"url\": \"https://api.provider.com/...\",");
console.log(" \"headers\": { ... },");
console.log(" \"body\": { ... }");
console.log(" }");
console.log("");
process.exit(0);
}
const filePath = args[0];
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);
if (!fs.existsSync(fullPath)) {
console.error(`❌ File not found: ${fullPath}`);
process.exit(1);
}
// Load request data
let data;
try {
data = JSON.parse(fs.readFileSync(fullPath, "utf8"));
} catch (err) {
console.error(`❌ Failed to parse JSON: ${err.message}`);
process.exit(1);
}
const { url, headers, body } = data;
if (!url || !headers || !body) {
console.error("❌ Invalid file format. Expected: { url, headers, body }");
process.exit(1);
}
// Display request info
console.log("\n🚀 Sending Request from File\n");
console.log(`📁 File: ${filePath}`);
console.log(`🌐 URL: ${url}`);
console.log(`📋 Headers:`);
Object.entries(headers).forEach(([k, v]) => {
if (k.toLowerCase().includes("auth") || k.toLowerCase().includes("key") || k.toLowerCase().includes("bearer")) {
const str = String(v);
if (str.length > 20) {
console.log(` ${k}: ${str.slice(0, 20)}...`);
} else {
console.log(` ${k}: ${str}`);
}
} else {
console.log(` ${k}: ${v}`);
}
});
console.log(`\n📊 Request Body:`);
console.log(` Model: ${body.model || "N/A"}`);
console.log(` Messages: ${body.messages?.length || 0}`);
console.log(` Tools: ${body.tools?.length || 0}`);
console.log(` Stream: ${body.stream || false}`);
// Send request
(async () => {
try {
console.log("\n🚀 Sending request...");
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body)
});
console.log(`\n📥 Response: ${response.status} ${response.statusText}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`\n❌ Error response:\n${errorText}`);
process.exit(1);
}
const isStreaming = body.stream || response.headers.get("content-type")?.includes("text/event-stream");
if (isStreaming) {
console.log("\n📡 Streaming response...\n");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let chunkCount = 0;
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
process.stdout.write(line + "\n");
chunkCount++;
}
}
}
// Process any remaining data
if (buffer.trim()) {
process.stdout.write(buffer + "\n");
}
console.log(`\n\n✅ Received ${chunkCount} chunks`);
} else {
const responseData = await response.json();
console.log("\n📦 Response:");
console.log(JSON.stringify(responseData, null, 2));
}
} catch (err) {
console.error("\n❌ Request failed:", err.message);
if (process.env.DEBUG) {
console.error(err.stack);
}
process.exit(1);
}
})();