diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 56544c450f..48b59b518a 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -586,14 +586,26 @@ export const REGISTRY: Record = { 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" }, ], }, diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 1eb87198dc..ff3957aa38 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -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>; tools?: Array>; }; }; + _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, + 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; }; }; @@ -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, }), diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 25c0f27487..29a0cf6a64 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -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>, + 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 = { + 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 } + ); } }