mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user