feat(kiro): headless auth via kiro-cli SQLite, image support, model fixes (#2129)

- Add kiro-cli SQLite auto-import for enterprise SSO + headless environments
- Add image support (OpenAI + Anthropic formats → Kiro native)
- Move long tool descriptions to system prompt to prevent 400 errors
- Sync model list with live API: add auto-kiro, claude-sonnet-4, deepseek-3.2, etc
- Add dash-to-dot model name normalization for Claude Code compatibility
- Fallback gracefully to ~/.aws/sso/cache for social auth

Co-authored-by: christlau <christlau@users.noreply.github.com>
This commit is contained in:
christlau
2026-05-10 14:48:03 -03:00
committed by diegosouzapw
parent ee228e6657
commit f8322b3bd7
3 changed files with 316 additions and 70 deletions

View File

@@ -586,14 +586,26 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev",
},
models: [
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "auto-kiro", name: "Auto (Kiro picks best model)" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
//{ id: "?", name: "DeepSeek V3.2" },
//{ id: "?", name: "MiniMax M2.5" },
//{ id: "?", name: "GLM-5" },
{ id: "claude-3.7-sonnet", name: "Claude 3.7 Sonnet" },
// Dash aliases — Claude Code sends dashes, Kiro API uses dots
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
// Non-Claude models on Kiro subscription
{ id: "deepseek-3.2", name: "DeepSeek V3.2" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-5", name: "GLM-5" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
],
},

View File

@@ -53,6 +53,7 @@ function convertMessages(messages, tools, model) {
let pendingUserContent = [];
let pendingAssistantContent = [];
let pendingToolResults = [];
let pendingImages: Array<{ format: string; source: { bytes: string } }> = [];
let currentRole = null;
const flushPending = () => {
@@ -62,11 +63,13 @@ function convertMessages(messages, tools, model) {
userInputMessage: {
content: string;
modelId: string;
images?: Array<{ format: string; source: { bytes: string } }>;
userInputMessageContext?: {
toolResults?: Array<Record<string, unknown>>;
tools?: Array<Record<string, unknown>>;
};
};
_toolDocs?: string;
} = {
userInputMessage: {
content: content,
@@ -80,11 +83,20 @@ function convertMessages(messages, tools, model) {
};
}
// Attach images to userInputMessage (NOT userInputMessageContext)
if (pendingImages.length > 0) {
userMsg.userInputMessage.images = pendingImages;
}
// Add tools to first user message
if (tools && tools.length > 0 && history.length === 0) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
// Kiro API rejects requests with tool descriptions > ~10000 chars.
// Move long descriptions to system prompt (same approach as kiro-gateway).
const TOOL_DESC_MAX = 10000;
const toolDocs: string[] = [];
userMsg.userInputMessage.userInputMessageContext.tools = tools.map((t) => {
const name = t.function?.name || t.name;
let description = t.function?.description || t.description || "";
@@ -93,6 +105,11 @@ function convertMessages(messages, tools, model) {
description = `Tool: ${name}`;
}
if (description.length > TOOL_DESC_MAX) {
toolDocs.push(`## Tool: ${name}\n\n${description}`);
description = `[Full documentation in system prompt under '## Tool: ${name}']`;
}
return {
toolSpecification: {
name,
@@ -105,12 +122,17 @@ function convertMessages(messages, tools, model) {
},
};
});
// Attach tool docs to message so buildKiroPayload can prepend to content
if (toolDocs.length > 0) {
userMsg._toolDocs = toolDocs.join("\n\n---\n\n");
}
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
pendingToolResults = [];
pendingImages = [];
} else if (currentRole === "assistant") {
const content = pendingAssistantContent.join("\n\n").trim() || "...";
const assistantMsg = {
@@ -149,6 +171,24 @@ function convertMessages(messages, tools, model) {
.map((c) => c.text || "");
content = textParts.join("\n");
// Extract images (OpenAI image_url and Anthropic image formats)
for (const block of msg.content) {
if (block.type === "image_url") {
const url: string = block.image_url?.url || "";
if (url.startsWith("data:")) {
// data:image/jpeg;base64,<data>
const [header, bytes] = url.split(",", 2);
const mediaType = header.split(";")[0].replace("data:", ""); // e.g. "image/jpeg"
const format = mediaType.split("/")[1] || "jpeg";
if (bytes) pendingImages.push({ format, source: { bytes } });
}
} else if (block.type === "image" && block.source?.type === "base64") {
const format = (block.source.media_type || "image/jpeg").split("/")[1] || "jpeg";
if (block.source.data)
pendingImages.push({ format, source: { bytes: block.source.data } });
}
}
// Check for tool_result blocks
const toolResultBlocks = msg.content.filter((c) => c.type === "tool_result");
if (toolResultBlocks.length > 0) {
@@ -338,13 +378,19 @@ function convertMessages(messages, tools, model) {
* Build Kiro payload from OpenAI format
*/
export function buildKiroPayload(model, body, stream, credentials) {
// Normalize model name: Claude Code sends dashes (claude-sonnet-4-6),
// Kiro API expects dots (claude-sonnet-4.6). Convert trailing version segment.
const normalizedModel = model.replace(
/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d+)$/,
"$1.$2"
);
const messages = body.messages || [];
const tools = body.tools || [];
const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000;
const temperature = body.temperature;
const topP = body.top_p;
const { history, currentMessage } = convertMessages(messages, tools, model);
const { history, currentMessage } = convertMessages(messages, tools, normalizedModel);
const profileArn = credentials?.providerSpecificData?.profileArn || "";
@@ -352,6 +398,12 @@ export function buildKiroPayload(model, body, stream, credentials) {
const timestamp = new Date().toISOString();
finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`;
// Prepend tool documentation for tools with long descriptions (moved from toolSpecification)
const toolDocs = (currentMessage as { _toolDocs?: string } | null)?._toolDocs;
if (toolDocs) {
finalContent = `# Tool Documentation\n\n${toolDocs}\n\n---\n\n${finalContent}`;
}
const payload: {
conversationState: {
chatTriggerType: string;
@@ -361,6 +413,7 @@ export function buildKiroPayload(model, body, stream, credentials) {
content: string;
modelId: string;
origin: string;
images?: Array<{ format: string; source: { bytes: string } }>;
userInputMessageContext?: Record<string, unknown>;
};
};
@@ -379,8 +432,11 @@ export function buildKiroPayload(model, body, stream, credentials) {
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: model,
modelId: normalizedModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.images?.length && {
images: currentMessage.userInputMessage.images,
}),
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext,
}),

View File

@@ -1,14 +1,22 @@
import { NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { createProviderConnection, isCloudEnabled, resolveProxyForProvider } from "@/models";
import { syncToCloud } from "@/lib/cloudSync";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { KiroService } from "@/lib/oauth/services/kiro";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
/**
* GET /api/oauth/kiro/auto-import
* Auto-detect and extract Kiro refresh token from AWS SSO cache.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
* Auto-import Kiro credentials from kiro-cli's SQLite database.
* Supports both personal Builder ID and enterprise SSO (IDC/profileArn).
*
* Falls back to ~/.aws/sso/cache if kiro-cli SQLite is not found.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
*/
export async function GET(request: Request) {
if (await isAuthRequired(request)) {
@@ -17,79 +25,249 @@ export async function GET(request: Request) {
}
}
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
// Try kiro-cli SQLite first
const sqliteResult = await tryKiroCliSqlite();
if (sqliteResult.found) {
return await saveAndRespond(sqliteResult, targetProvider, request);
}
// Fall back to ~/.aws/sso/cache (social auth / manual token)
const cacheResult = await tryAwsSsoCache(targetProvider);
if (cacheResult.found) {
return await saveAndRespond(cacheResult, targetProvider, request);
}
return NextResponse.json({
found: false,
error:
"Kiro credentials not found. " +
"Run `kiro-cli login --use-device-flow` then retry, " +
"or use the Import Token option in the dashboard.",
triedPaths: [sqliteResult.triedPath, cacheResult.triedPath].filter(Boolean),
});
}
// ── kiro-cli SQLite reader ────────────────────────────────────────────────────
async function tryKiroCliSqlite(): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: string;
clientId?: string;
clientSecret?: string;
region?: string;
profileArn?: string;
source?: string;
}> {
const dbPath = join(homedir(), ".local/share/kiro-cli/data.sqlite3");
let Database: any;
try {
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
const providerLabel = targetProvider === "amazon-q" ? "Amazon Q" : "Kiro";
const cachePath = join(homedir(), ".aws/sso/cache");
Database = (await import("better-sqlite3")).default;
} catch {
return { found: false, triedPath: dbPath };
}
// Try to read cache directory
let files;
try {
files = await readdir(cachePath);
} catch (error) {
return NextResponse.json({
found: false,
error: `AWS SSO cache not found. Please login to ${providerLabel} first.`,
});
}
// Look for kiro-auth-token.json or any .json file with refreshToken
let refreshToken = null;
let foundFile = null;
// First try kiro-auth-token.json
const preferredTokenFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
if (files.includes(preferredTokenFile)) {
try {
const content = await readFile(join(cachePath, preferredTokenFile), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = preferredTokenFile;
}
} catch (error) {
// Continue to search other files
}
}
// If not found, search all .json files
if (!refreshToken) {
for (const file of files) {
if (!file.endsWith(".json")) continue;
let db: any;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
} catch {
return { found: false, triedPath: dbPath };
}
try {
// Read OIDC token (access + refresh token)
const tokenKeys = ["kirocli:odic:token", "kirocli:oidc:token"];
let tokenData: any = null;
for (const key of tokenKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
// Look for Kiro refresh token (starts with aorAAAAAG)
if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) {
refreshToken = data.refreshToken;
foundFile = file;
break;
}
} catch (error) {
// Skip invalid JSON files
continue;
tokenData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
if (!refreshToken) {
return NextResponse.json({
found: false,
error: `${providerLabel} token not found in AWS SSO cache. Please login to ${providerLabel} first.`,
});
if (!tokenData?.refresh_token) {
return { found: false, triedPath: dbPath };
}
// Read device registration (client_id + client_secret)
const regKeys = ["kirocli:odic:device-registration", "kirocli:oidc:device-registration"];
let regData: any = null;
for (const key of regKeys) {
const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as
| { value: string }
| undefined;
if (row?.value) {
try {
regData = JSON.parse(row.value);
break;
} catch {
// continue
}
}
}
// Read profileArn from state table (enterprise SSO / IDC)
let profileArn: string | undefined;
try {
const profileRow = db
.prepare("SELECT value FROM state WHERE key = 'api.codewhisperer.profile'")
.get() as { value: string } | undefined;
if (profileRow?.value) {
const profileData = JSON.parse(profileRow.value);
profileArn = profileData.arn || profileData.profileArn;
}
} catch {
// state table may not exist for personal Builder ID accounts
}
const region = tokenData.region || regData?.region || "us-east-1";
const expiresAt = tokenData.expires_at
? new Date(tokenData.expires_at).toISOString()
: new Date(Date.now() + 3600 * 1000).toISOString();
return {
found: true,
source: "kiro-cli-sqlite",
refreshToken: tokenData.refresh_token,
accessToken: tokenData.access_token,
expiresAt,
clientId: regData?.client_id,
clientSecret: regData?.client_secret,
region,
profileArn,
};
} finally {
db.close();
}
}
// ── ~/.aws/sso/cache fallback ─────────────────────────────────────────────────
async function tryAwsSsoCache(targetProvider: string): Promise<{
found: boolean;
triedPath?: string;
refreshToken?: string;
source?: string;
}> {
const { readFile, readdir } = await import("fs/promises");
const cachePath = join(homedir(), ".aws/sso/cache");
const preferredFile =
targetProvider === "amazon-q" ? "amazon-q-auth-token.json" : "kiro-auth-token.json";
let files: string[];
try {
files = await readdir(cachePath);
} catch {
return { found: false, triedPath: cachePath };
}
// Try preferred file first, then scan all
const ordered = [
preferredFile,
...files.filter((f) => f !== preferredFile && f.endsWith(".json")),
];
for (const file of ordered) {
try {
const content = await readFile(join(cachePath, file), "utf-8");
const data = JSON.parse(content);
if (data.refreshToken?.startsWith("aorAAAAAG")) {
return { found: true, refreshToken: data.refreshToken, source: file };
}
} catch {
// skip
}
}
return { found: false, triedPath: cachePath };
}
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
async function saveAndRespond(
result: Awaited<ReturnType<typeof tryKiroCliSqlite>>,
targetProvider: string,
request: Request
) {
try {
const kiroService = new KiroService();
const proxy = await resolveProxyForProvider(targetProvider);
// If we have a refresh token but no valid access token, refresh now
let accessToken = result.accessToken;
let refreshToken = result.refreshToken!;
let expiresAt = result.expiresAt;
let profileArn = result.profileArn;
const providerSpecificData: Record<string, any> = {
authMethod: result.source === "kiro-cli-sqlite" ? "kiro-cli" : "imported",
provider: result.source === "kiro-cli-sqlite" ? "kiro-cli SQLite" : "AWS SSO Cache",
};
if (result.clientId) providerSpecificData.clientId = result.clientId;
if (result.clientSecret) providerSpecificData.clientSecret = result.clientSecret;
if (result.region) providerSpecificData.region = result.region;
if (profileArn) providerSpecificData.profileArn = profileArn;
// Refresh token to get a fresh access token and confirm it works
const refreshed = await runWithProxyContext(proxy, () =>
kiroService.refreshToken(refreshToken, providerSpecificData)
);
accessToken = refreshed.accessToken;
refreshToken = refreshed.refreshToken || refreshToken;
expiresAt = new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString();
// profileArn may come back from social auth refresh
if (refreshed.profileArn && !profileArn) {
profileArn = refreshed.profileArn;
providerSpecificData.profileArn = profileArn;
}
const email = kiroService.extractEmailFromJWT(accessToken);
await createProviderConnection({
provider: targetProvider,
authType: "oauth",
accessToken,
refreshToken,
expiresAt,
email: email || null,
providerSpecificData,
testStatus: "active",
} as any);
if (isCloudEnabled()) {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId).catch(() => {});
}
return NextResponse.json({
found: true,
refreshToken,
source: foundFile,
source: result.source,
email: email || null,
profileArn: profileArn || null,
region: result.region || null,
message: "Kiro credentials imported successfully.",
});
} catch (error) {
console.log("Kiro auto-import error:", error);
return NextResponse.json({ found: false, error: error.message }, { status: 500 });
} catch (error: any) {
console.error("[kiro auto-import] save error:", error);
return NextResponse.json(
{ found: false, error: `Import failed: ${error.message}` },
{ status: 500 }
);
}
}