@magpie-community/opencode-kiro-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +92 -0
  2. package/index.mjs +1292 -0
  3. package/package.json +11 -0
package/index.mjs ADDED
@@ -0,0 +1,1292 @@
1
+ // Kiro's subscriptions (kiro.dev) as an OpenCode provider plugin.
2
+ //
3
+ // An account is signed in the way the Kiro IDE signs in: Kiro's sign-in
4
+ // page (app.kiro.dev) offers Google, GitHub, AWS Builder ID and IAM
5
+ // Identity Center, and sends the browser back to one of the ports the IDE
6
+ // listens on. Google and GitHub come back with a code Kiro's auth service
7
+ // trades for tokens; Builder ID and Identity Center come back with where to
8
+ // sign in at AWS, which is then done with a client registered for it.
9
+ // kiro-cli's or the IDE's own sign-in can be used instead: it is read where
10
+ // they keep it each time, refreshed by kiro-cli first, and a token
11
+ // refreshed here is written back, so they go on with it. A Kiro API key
12
+ // (ksk_…) signs in too.
13
+ //
14
+ // Kiro has no API OpenCode speaks: the models are declared on Anthropic's
15
+ // Messages, and the fetch here sends each request whole to Kiro's own API
16
+ // (runtime.<region>.kiro.dev/generateAssistantResponse, the one kiro-cli
17
+ // talks to) as a conversation, and turns the AWS event stream it answers
18
+ // with back into Messages' events.
19
+ import { createServer } from "node:http"
20
+ import { createHash, randomBytes, randomUUID } from "node:crypto"
21
+ import { execFile } from "node:child_process"
22
+ import { existsSync, statSync } from "node:fs"
23
+ import { readFile, rename, writeFile } from "node:fs/promises"
24
+ import { homedir, hostname } from "node:os"
25
+ import { delimiter, join } from "node:path"
26
+
27
+ const ID = "kiro"
28
+ const MESSAGES = "@ai-sdk/anthropic"
29
+ const BASE = "https://runtime.us-east-1.kiro.dev" // what OpenCode is given; the fetch sends each request to the account's region
30
+
31
+ const PORTAL = "https://app.kiro.dev"
32
+ const AUTH_SERVICE = "https://prod.us-east-1.auth.desktop.kiro.dev"
33
+ const oidc = (region) => `https://oidc.${region}.amazonaws.com`
34
+ const runtime = (region) => `https://runtime.${region}.kiro.dev`
35
+ const management = (region) => `https://management.${region}.kiro.dev`
36
+ const refreshURL = (region) => `https://prod.${region}.auth.desktop.kiro.dev/refreshToken`
37
+ // the ports Kiro's sign-in page sends the browser back to, the IDE's
38
+ const CALLBACK_PORTS = [3128, 4649, 6588, 8008, 9091, 49153, 50153, 51153, 52153, 53153]
39
+ // what the IDE asks AWS for
40
+ const SCOPES = ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations",
41
+ "codewhisperer:transformations", "codewhisperer:taskassist"]
42
+ // how the IDE names itself to Kiro's auth service, and when refreshing
43
+ const IDE_UA = "KiroIDE-1.1.70-" + createHash("sha256").update("magpie:" + hostname()).digest("hex")
44
+ const DESKTOP_UA = "Kiro-Desktop/0.2.13 (darwin; arm64)"
45
+ const SIGN_IN_TIMEOUT = 10 * 60 * 1000
46
+
47
+ const env = (k) => process.env[k] ?? ""
48
+ const home = () => env("HOME") || homedir()
49
+
50
+ // ---- where Kiro's own apps keep their sign-in -----------------------------------
51
+
52
+ function cliDB() {
53
+ if (process.platform === "win32") return join(env("APPDATA") || join(home(), "AppData", "Roaming"), "kiro-cli", "data.sqlite3")
54
+ if (process.platform === "darwin") return join(home(), "Library", "Application Support", "kiro-cli", "data.sqlite3")
55
+ return join(home(), ".local", "share", "kiro-cli", "data.sqlite3")
56
+ }
57
+ const ideDir = () => join(home(), ".aws", "sso", "cache")
58
+
59
+ // kiroExecutable finds kiro-cli, which refreshes its own sign-in.
60
+ function kiroExecutable() {
61
+ for (const d of env("PATH").split(delimiter)) {
62
+ const p = join(d, process.platform === "win32" ? "kiro-cli.exe" : "kiro-cli")
63
+ if (d && isFile(p)) return p
64
+ }
65
+ for (const p of [join(home(), ".local", "bin", "kiro-cli"), "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli",
66
+ "/usr/local/bin/kiro-cli", "/opt/homebrew/bin/kiro-cli"])
67
+ if (isFile(p)) return p
68
+ return ""
69
+ }
70
+ function isFile(p) {
71
+ try {
72
+ return statSync(p).isFile()
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ // openDB opens kiro-cli's SQLite database, with Bun's driver or Node's.
79
+ async function openDB(path, readonly) {
80
+ try {
81
+ const { Database } = await import("bun:sqlite")
82
+ return new Database(path, readonly ? { readonly: true } : { readwrite: true })
83
+ } catch (e) {
84
+ if (typeof Bun !== "undefined") throw e
85
+ }
86
+ const { DatabaseSync } = await import("node:sqlite")
87
+ return new DatabaseSync(path, { readOnly: readonly })
88
+ }
89
+
90
+ const time = (s) => {
91
+ const t = Date.parse(s ?? "")
92
+ return Number.isFinite(t) ? t : 0
93
+ }
94
+
95
+ // readCLI reads kiro-cli's sign-in: with Google or GitHub, with AWS (Builder
96
+ // ID, IAM Identity Center), or with a company's own identity provider.
97
+ async function readCLI() {
98
+ const path = cliDB()
99
+ if (!existsSync(path)) return null
100
+ let db
101
+ try {
102
+ db = await openDB(path, true)
103
+ const get = (key) => {
104
+ const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key)
105
+ if (!row?.value) return null
106
+ try {
107
+ return JSON.parse(row.value)
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+ for (const kind of ["social", "odic", "external-idp"]) {
113
+ const key = `kirocli:${kind}:token`
114
+ const m = get(key)
115
+ if (!m?.access_token) continue
116
+ const c = { access: m.access_token, refresh: m.refresh_token ?? "", expires: time(m.expires_at), region: m.region || "us-east-1",
117
+ profile: m.profile_arn ?? "", dbKey: key }
118
+ if (kind === "social") c.method = "social"
119
+ else if (kind === "odic") {
120
+ c.method = "idc"
121
+ const reg = get("kirocli:odic:device-registration")
122
+ if (reg) [c.clientId, c.clientSecret] = [reg.client_id ?? "", reg.client_secret ?? ""]
123
+ } else {
124
+ c.method = "external-idp"
125
+ c.clientId = m.client_id ?? ""
126
+ c.tokenURL = m.token_endpoint || (m.issuer_url ? m.issuer_url.replace(/\/$/, "") + "/v1/token" : "")
127
+ }
128
+ return c
129
+ }
130
+ } catch {
131
+ } finally {
132
+ db?.close()
133
+ }
134
+ return null
135
+ }
136
+
137
+ // readIDE reads the Kiro IDE's sign-in.
138
+ async function readIDE() {
139
+ const path = join(ideDir(), "kiro-auth-token.json")
140
+ let t
141
+ try {
142
+ t = JSON.parse(await readFile(path, "utf8"))
143
+ } catch {
144
+ return null
145
+ }
146
+ if (!t?.accessToken) return null
147
+ const c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: time(t.expiresAt), region: t.region || "us-east-1",
148
+ profile: t.profileArn ?? "", method: "idc", idePath: path }
149
+ if (t.clientIdHash) {
150
+ try {
151
+ const reg = JSON.parse(await readFile(join(ideDir(), t.clientIdHash + ".json"), "utf8"))
152
+ ;[c.clientId, c.clientSecret] = [reg.clientId ?? "", reg.clientSecret ?? ""]
153
+ } catch {}
154
+ }
155
+ if (String(t.authMethod).toLowerCase() === "social" || !c.clientId) c.method = "social"
156
+ return c
157
+ }
158
+
159
+ // saveBack writes a refreshed token back where it was read from, so
160
+ // kiro-cli or the IDE goes on with it (a refresh may replace the refresh
161
+ // token).
162
+ async function saveBack(c) {
163
+ const expires = new Date(c.expires).toISOString()
164
+ if (c.dbKey) {
165
+ let db
166
+ try {
167
+ db = await openDB(cliDB(), false)
168
+ db.exec("PRAGMA busy_timeout = 3000")
169
+ const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(c.dbKey)
170
+ if (!row?.value) return
171
+ const m = JSON.parse(row.value)
172
+ Object.assign(m, { access_token: c.access, refresh_token: c.refresh, expires_at: expires })
173
+ if (c.profile && c.method === "social") m.profile_arn = c.profile
174
+ db.prepare("UPDATE auth_kv SET value = ? WHERE key = ?").run(JSON.stringify(m), c.dbKey)
175
+ } catch {
176
+ } finally {
177
+ db?.close()
178
+ }
179
+ } else if (c.idePath) {
180
+ try {
181
+ const m = JSON.parse(await readFile(c.idePath, "utf8"))
182
+ Object.assign(m, { accessToken: c.access, refreshToken: c.refresh, expiresAt: expires })
183
+ await writeFile(c.idePath + ".magpie-tmp", JSON.stringify(m, null, 2), { mode: 0o600 })
184
+ await rename(c.idePath + ".magpie-tmp", c.idePath)
185
+ } catch {}
186
+ }
187
+ }
188
+
189
+ // ---- tokens --------------------------------------------------------------------
190
+
191
+ const EXPIRED = "Kiro's sign-in has expired; sign in again"
192
+ const fresh = (c) => c.method === "apikey" || !c.expires || c.expires - Date.now() > 2 * 60 * 1000
193
+
194
+ async function post(url, contentType, body, headers = {}) {
195
+ let res
196
+ try {
197
+ res = await fetch(url, { method: "POST", headers: { "Content-Type": contentType, Accept: "application/json", ...headers }, body,
198
+ signal: AbortSignal.timeout(30_000) })
199
+ } catch (e) {
200
+ throw new Error("refreshing Kiro's sign-in: " + e.message)
201
+ }
202
+ const text = await res.text()
203
+ if (!res.ok) {
204
+ if ([400, 401, 403].includes(res.status)) throw new Error(EXPIRED)
205
+ throw new Error(`refreshing Kiro's sign-in: ${res.status}`)
206
+ }
207
+ return JSON.parse(text)
208
+ }
209
+
210
+ // refresh refreshes a sign-in's token the way its owner would: kiro-cli's
211
+ // by kiro-cli first.
212
+ async function refresh(c) {
213
+ if (c.dbKey) {
214
+ const bin = kiroExecutable()
215
+ if (bin) {
216
+ await new Promise((r) => execFile(bin, ["debug", "refresh-auth-token"], { timeout: 20_000 }, () => r()))
217
+ const n = await readCLI()
218
+ if (n && n.dbKey === c.dbKey && n.access !== c.access && fresh(n)) return n
219
+ }
220
+ }
221
+ if (!c.refresh) throw new Error(EXPIRED)
222
+ let out
223
+ switch (c.method) {
224
+ case "social": {
225
+ const r = await post(refreshURL(c.region), "application/json", JSON.stringify({ refreshToken: c.refresh }), { "User-Agent": DESKTOP_UA })
226
+ out = { access: r.accessToken, refresh: r.refreshToken, expiresIn: r.expiresIn }
227
+ if (r.profileArn) c.profile = r.profileArn
228
+ break
229
+ }
230
+ case "idc": {
231
+ const r = await post(oidc(c.region) + "/token", "application/json",
232
+ JSON.stringify({ clientId: c.clientId, clientSecret: c.clientSecret, refreshToken: c.refresh, grantType: "refresh_token" }))
233
+ out = { access: r.accessToken, refresh: r.refreshToken, expiresIn: r.expiresIn }
234
+ break
235
+ }
236
+ case "external-idp": {
237
+ if (!c.tokenURL) throw new Error(EXPIRED + " with `kiro-cli login`")
238
+ const form = new URLSearchParams({ grant_type: "refresh_token", client_id: c.clientId ?? "", refresh_token: c.refresh })
239
+ const r = await post(c.tokenURL, "application/x-www-form-urlencoded", form.toString())
240
+ out = { access: r.access_token, refresh: r.refresh_token, expiresIn: r.expires_in }
241
+ break
242
+ }
243
+ default:
244
+ throw new Error(EXPIRED)
245
+ }
246
+ if (!out.access) throw new Error(EXPIRED)
247
+ const n = { ...c, access: out.access, expires: Date.now() + (out.expiresIn > 0 ? out.expiresIn : 3600) * 1000 }
248
+ if (out.refresh) n.refresh = out.refresh
249
+ await saveBack(n)
250
+ return n
251
+ }
252
+
253
+ // headers say who is calling: the token, and what kind it is.
254
+ function authHeaders(a) {
255
+ const h = { Authorization: `Bearer ${a.token}` }
256
+ if (a.tokenType) h.tokentype = a.tokenType
257
+ return h
258
+ }
259
+ const tokenType = (c) => (c.method === "apikey" ? "API_KEY" : c.method === "external-idp" ? "EXTERNAL_IDP" : "")
260
+
261
+ // region is where the account's API is: the region its profile is in, else
262
+ // the nearer of Kiro's two to where it signed in.
263
+ function regionOf(profile, signedIn) {
264
+ const parts = String(profile ?? "").split(":")
265
+ if (parts.length > 4 && parts[3]) return parts[3]
266
+ return String(signedIn ?? "").startsWith("eu-") ? "eu-central-1" : "us-east-1"
267
+ }
268
+
269
+ class KiroError extends Error {
270
+ constructor(status, body) {
271
+ let msg = ""
272
+ try {
273
+ msg = JSON.parse(body)?.message ?? ""
274
+ } catch {}
275
+ super(msg ? `Kiro: ${msg} (${status})` : `Kiro: HTTP ${status}`)
276
+ this.status = status
277
+ }
278
+ }
279
+
280
+ // managementCall calls Kiro's management API: GET with a query, or POST
281
+ // with a JSON body.
282
+ async function managementCall(region, a, method, query, body) {
283
+ let url = `${management(region)}/${method}`
284
+ if (!body && query) url += "?" + new URLSearchParams(query)
285
+ const res = await fetch(url, {
286
+ method: body ? "POST" : "GET",
287
+ headers: { ...authHeaders(a), Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
288
+ body: body ? JSON.stringify(body) : undefined,
289
+ signal: AbortSignal.timeout(20_000),
290
+ })
291
+ const text = await res.text()
292
+ if (!res.ok) throw new KiroError(res.status, text)
293
+ return JSON.parse(text)
294
+ }
295
+
296
+ // profileOf finds the profile a sign-in that doesn't name one uses: an API
297
+ // key's own, or the first Kiro lists in either of its regions.
298
+ async function profileOf(c) {
299
+ if (c.method === "apikey") {
300
+ const res = await fetch(management("us-east-1") + "/", {
301
+ method: "POST",
302
+ headers: { ...authHeaders({ token: c.access, tokenType: "API_KEY" }), "Content-Type": "application/x-amz-json-1.0",
303
+ "X-Amz-Target": "AmazonCodeWhispererService.GetProfile" },
304
+ body: "{}",
305
+ signal: AbortSignal.timeout(20_000),
306
+ })
307
+ if (!res.ok) throw new Error(`Kiro didn't take the API key: ${res.status}`)
308
+ const arn = (await res.json().catch(() => null))?.profile?.arn
309
+ if (!arn) throw new Error("Kiro didn't say which profile the API key is for")
310
+ return arn
311
+ }
312
+ let last
313
+ for (const region of ["us-east-1", "eu-central-1"]) {
314
+ try {
315
+ const out = await managementCall(region, { token: c.access, tokenType: tokenType(c) }, "List-Available-Profiles", null, {})
316
+ const arn = (out?.profiles ?? []).find((p) => p?.arn)?.arn
317
+ if (arn) return arn
318
+ } catch (e) {
319
+ last = e
320
+ }
321
+ }
322
+ throw last ?? new Error("Kiro has no profile for this sign-in")
323
+ }
324
+
325
+ // usageLimits is Get-Usage-Limits: the plan, the account's email, credits.
326
+ function usageLimits(a, email) {
327
+ return managementCall(a.region, a, "Get-Usage-Limits",
328
+ { origin: "KIRO_CLI", profileArn: a.profile, resourceType: "CREDIT", isEmailRequired: String(email) })
329
+ }
330
+
331
+ // planName is the subscription's name as Kiro gives it ("KIRO FREE"), said
332
+ // "Kiro Free".
333
+ const planName = (title) =>
334
+ String(title ?? "").toLowerCase().split(/\s+/).filter(Boolean).map((w) => (w === "kiro" ? "Kiro" : w[0].toUpperCase() + w.slice(1))).join(" ")
335
+
336
+ // ---- the account, as the plugin holds it -------------------------------------------
337
+
338
+ // credOf is the sign-in a stored auth names: a key, one signed in here, or
339
+ // kiro-cli's or the IDE's, read where they keep it now.
340
+ async function credOf(auth) {
341
+ if (auth?.type === "api" && auth.key) return { access: auth.key, method: "apikey", region: "us-east-1" }
342
+ if (auth?.type !== "oauth") return null
343
+ if (auth.source === "kiro-cli") return readCLI()
344
+ if (auth.source === "kiro-ide") return readIDE()
345
+ if (auth.source === "kiro") return (await readCLI()) ?? (await readIDE())
346
+ if (!auth.access) return null
347
+ return { access: auth.access, refresh: auth.refresh ?? "", expires: auth.expires ?? 0, method: auth.method || "social",
348
+ region: auth.region || "us-east-1", profile: auth.profileArn ?? "", clientId: auth.clientId ?? "", clientSecret: auth.clientSecret ?? "",
349
+ own: true }
350
+ }
351
+
352
+ // Account holds the credentials in use, refreshing them when near their
353
+ // end or when Kiro turned one down; one refresh at a time, as a refresh
354
+ // may replace the refresh token.
355
+ function account(client) {
356
+ let held = null // { id, c }
357
+ let lock = Promise.resolve()
358
+ const locked = (fn) => {
359
+ const run = lock.then(fn, fn)
360
+ lock = run.catch(() => {})
361
+ return run
362
+ }
363
+ const save = async (auth, c) => {
364
+ if (!c.own || !client?.auth?.set) return
365
+ const { type: _t, ...rest } = auth
366
+ await client.auth.set({ path: { id: ID }, body: { ...rest, type: "oauth", access: c.access, refresh: c.refresh, expires: c.expires,
367
+ profileArn: c.profile, region: c.region } }).catch(() => {})
368
+ }
369
+ return (auth, stale = false) =>
370
+ locked(async () => {
371
+ const id = auth?.type === "api" ? "key:" + auth.key : "oauth:" + (auth?.source ?? "own")
372
+ // what its owner holds now: it may have refreshed it, signed out, or
373
+ // signed in to another account since
374
+ const read = await credOf(auth)
375
+ if (!read) {
376
+ held = null
377
+ throw new Error(auth?.source ? "Kiro isn't signed in; sign in with `kiro-cli login` or the Kiro IDE" : "Kiro isn't signed in; sign in again")
378
+ }
379
+ let c = held?.id === id ? held.c : null
380
+ if (!c || read.access !== c.access || !fresh(c) || stale) {
381
+ if (!read.profile && c && read.access === c.access) read.profile = c.profile
382
+ if (stale && c && read.access !== c.access && fresh(read)) stale = false // the owner refreshed it already
383
+ c = read
384
+ if (!fresh(c) || stale) {
385
+ if (c.method === "apikey") throw new Error("Kiro turned down the API key")
386
+ c = await refresh(c)
387
+ await save(auth, c)
388
+ }
389
+ }
390
+ if (!c.profile) {
391
+ c.profile = await profileOf(c)
392
+ if (!auth?.profileArn) await save(auth, c)
393
+ }
394
+ held = { id, c }
395
+ return { token: c.access, tokenType: tokenType(c), profile: c.profile, region: regionOf(c.profile, c.region) }
396
+ })
397
+ }
398
+
399
+ // ---- models ----------------------------------------------------------------------
400
+
401
+ const windows = new Map() // a model's context, as Kiro last listed it
402
+
403
+ const thinks = (id) => /claude/i.test(id) || id === "auto"
404
+ const BUDGETS = { low: 10000, medium: 20000, high: 30000, max: 50000 }
405
+
406
+ function configModel(m) {
407
+ return {
408
+ name: m.name,
409
+ limit: { context: m.context ?? 0, output: m.output ?? 0 },
410
+ ...(m.images ? { attachment: true, modalities: { input: ["text", "image"], output: ["text"] } } : {}),
411
+ ...(thinks(m.id)
412
+ ? { reasoning: true, variants: Object.fromEntries(Object.entries(BUDGETS).map(([k, b]) => [k, { thinking: { type: "enabled", budgetTokens: b } }])) }
413
+ : {}),
414
+ tool_call: true,
415
+ }
416
+ }
417
+
418
+ function runtimeModel(m) {
419
+ const input = { text: true, image: !!m.images, audio: false, video: false, pdf: false }
420
+ return {
421
+ id: m.id,
422
+ providerID: ID,
423
+ name: m.name ?? m.id,
424
+ api: { id: m.id, url: BASE, npm: MESSAGES },
425
+ status: "active",
426
+ headers: {},
427
+ options: {},
428
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
429
+ limit: { context: m.context ?? 0, output: m.output ?? 0 },
430
+ capabilities: {
431
+ temperature: true,
432
+ reasoning: thinks(m.id),
433
+ attachment: !!m.images,
434
+ toolcall: true,
435
+ input,
436
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
437
+ interleaved: false,
438
+ },
439
+ release_date: "",
440
+ variants: thinks(m.id) ? Object.fromEntries(Object.entries(BUDGETS).map(([k, b]) => [k, { thinking: { type: "enabled", budgetTokens: b } }])) : {},
441
+ }
442
+ }
443
+
444
+ // Kiro's own list is asked for once signed in; until then, Auto, which
445
+ // picks a model for each request.
446
+ const MODELS = [{ id: "auto", name: "Auto" }]
447
+
448
+ // listModels is List-Available-Models, the default first.
449
+ async function listModels(a) {
450
+ const out = await managementCall(a.region, a, "List-Available-Models", { origin: "KIRO_CLI", profileArn: a.profile })
451
+ const list = []
452
+ for (const m of out?.models ?? []) {
453
+ if (!m?.modelId) continue
454
+ let name = m.modelName
455
+ if (!name || name === m.modelId) name = m.modelId === "auto" ? "Auto" : m.modelId
456
+ const model = { id: m.modelId, name, context: m.tokenLimits?.maxInputTokens ?? 0, output: m.tokenLimits?.maxOutputTokens ?? 0,
457
+ images: (m.supportedInputTypes ?? []).some((t) => String(t).toUpperCase() === "IMAGE") }
458
+ if (m.modelId === out?.defaultModel?.modelId) list.unshift(model)
459
+ else list.push(model)
460
+ }
461
+ if (!list.length) throw new Error("Kiro listed no models")
462
+ for (const m of list) windows.set(m.id, m.context)
463
+ return list
464
+ }
465
+
466
+ // ---- the request: Messages → a Kiro conversation ---------------------------------------
467
+
468
+ const PROCEED = "Please proceed with the task." // Kiro takes no empty message
469
+ const NO_RESULT = "Tool use was interrupted and did not produce a result."
470
+ const RESULT_LIMIT = 250000 // a tool's output is cut at this, as Kiro's own agent cuts it
471
+ const TOOL_ID = /^[a-zA-Z0-9_.:-]{1,64}$/
472
+
473
+ // toolID is a tool call's id as Kiro takes one: another vendor's becomes
474
+ // one of Kiro's own, the same each time.
475
+ const toolID = (id) => (TOOL_ID.test(id ?? "") ? id : "t_" + createHash("sha256").update(String(id ?? "")).digest("base64url").slice(0, 32))
476
+
477
+ function imageFormat(mediaType) {
478
+ const t = String(mediaType ?? "").toLowerCase()
479
+ const f = t.replace(/^image\//, "")
480
+ if (f === "jpg") return "jpeg"
481
+ if (!f || f === t) return "png"
482
+ return f
483
+ }
484
+
485
+ const textOf = (c) =>
486
+ typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n") : ""
487
+ const joinNonEmpty = (a, b) => (a && b ? a + "\n\n" + b : a + b)
488
+
489
+ function documentText(p) {
490
+ const title = p.title ? `${p.title}\n` : ""
491
+ if (p.source?.type === "text") return title + (p.source.data ?? "")
492
+ if (p.source?.type === "content") return title + textOf(p.source.content)
493
+ return `${title}[a ${p.source?.media_type ?? "document"} attachment]`
494
+ }
495
+
496
+ // thinking is whether the model is asked to think aloud, and for how
497
+ // long: when the caller asked for it, of a Claude model (or Auto).
498
+ function thinking(req, model) {
499
+ const effort = req.output_config?.effort ?? ""
500
+ const on = req.thinking?.type === "enabled" || req.thinking?.type === "adaptive" || (effort && effort !== "low")
501
+ if (!on || !thinks(String(model).toLowerCase())) return 0
502
+ const budget = { low: 10000, high: 30000, xhigh: 50000, max: 50000 }[effort]
503
+ if (budget) return budget
504
+ if (!effort && req.thinking?.budget_tokens > 0) return req.thinking.budget_tokens
505
+ return 20000
506
+ }
507
+
508
+ // buildKiro is the body of a generateAssistantResponse call.
509
+ function buildKiro(req, model, profile, budget) {
510
+ const entries = []
511
+ const user = () => ({ content: "", modelId: model, origin: "KIRO_CLI" })
512
+ for (const m of req.messages ?? []) {
513
+ const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
514
+ const texts = []
515
+ if (m.role === "assistant") {
516
+ const a = { content: "", toolUses: [] }
517
+ for (const p of parts) {
518
+ if (p?.type === "text" && p.text) texts.push(p.text)
519
+ else if (p?.type === "tool_use") {
520
+ const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
521
+ a.toolUses.push({ name: p.name, toolUseId: toolID(p.id), input })
522
+ }
523
+ }
524
+ a.content = texts.join("\n\n")
525
+ if (!a.content && !a.toolUses.length) continue // a turn that only thought
526
+ const prev = entries.at(-1)?.assistantResponseMessage
527
+ if (prev) {
528
+ prev.content = joinNonEmpty(prev.content, a.content)
529
+ prev.toolUses.push(...a.toolUses)
530
+ } else entries.push({ assistantResponseMessage: a })
531
+ continue
532
+ }
533
+ const u = user()
534
+ u.images = []
535
+ const results = []
536
+ for (const p of parts) {
537
+ if (p?.type === "text") {
538
+ if (p.text) texts.push(p.text)
539
+ } else if (p?.type === "document") texts.push(documentText(p))
540
+ else if (p?.type === "image") {
541
+ if (p.source?.type === "base64" && p.source.data) u.images.push({ format: imageFormat(p.source.media_type), source: { bytes: p.source.data } })
542
+ } else if (p?.type === "tool_result") {
543
+ let out = textOf(p.content)
544
+ if (out.length > RESULT_LIMIT) out = out.slice(0, RESULT_LIMIT) + "\n… (cut)"
545
+ results.push({ toolUseId: toolID(p.tool_use_id), status: p.is_error ? "error" : "success", content: [{ text: out || "(no output)" }] })
546
+ }
547
+ }
548
+ u.content = texts.join("\n\n")
549
+ if (results.length) u.userInputMessageContext = { toolResults: results }
550
+ const prev = entries.at(-1)?.userInputMessage
551
+ if (prev) {
552
+ prev.content = joinNonEmpty(prev.content, u.content)
553
+ prev.images.push(...u.images)
554
+ if (results.length) (prev.userInputMessageContext ??= { toolResults: [] }).toolResults.push(...results)
555
+ } else entries.push({ userInputMessage: u })
556
+ }
557
+ // the conversation opens with something the user said: tool results with
558
+ // no call before them go, and a reply the caller began with
559
+ while (entries.length) {
560
+ const u = entries[0].userInputMessage
561
+ if (u) {
562
+ if (u.userInputMessageContext && !u.content && !u.images.length) {
563
+ entries.shift()
564
+ continue
565
+ }
566
+ delete u.userInputMessageContext
567
+ break
568
+ }
569
+ entries.shift()
570
+ }
571
+ // and ends with it, the message being answered
572
+ if (!entries.at(-1)?.userInputMessage) entries.push({ userInputMessage: { ...user(), images: [] } })
573
+ // each call is answered in the message after it, and only calls are
574
+ entries.forEach((e, i) => {
575
+ const u = e.userInputMessage
576
+ if (!u) return
577
+ const calls = entries[i - 1]?.assistantResponseMessage?.toolUses ?? []
578
+ const results = u.userInputMessageContext?.toolResults ?? []
579
+ const answered = new Set()
580
+ const kept = []
581
+ for (const r of results)
582
+ if (!answered.has(r.toolUseId) && calls.some((c) => c.toolUseId === r.toolUseId)) {
583
+ answered.add(r.toolUseId)
584
+ kept.push(r)
585
+ }
586
+ for (const c of calls)
587
+ if (!answered.has(c.toolUseId)) {
588
+ answered.add(c.toolUseId)
589
+ kept.push({ toolUseId: c.toolUseId, status: "error", content: [{ text: NO_RESULT }] })
590
+ }
591
+ delete u.userInputMessageContext
592
+ if (kept.length) u.userInputMessageContext = { toolResults: kept }
593
+ if (!u.content && !u.userInputMessageContext) u.content = PROCEED
594
+ })
595
+ // only the latest images are sent again, as Kiro's own agent does
596
+ let latest = -1
597
+ for (let i = entries.length - 1; i >= 0; i--)
598
+ if (entries[i].userInputMessage?.images.length) {
599
+ latest = i
600
+ break
601
+ }
602
+ entries.forEach((e, i) => {
603
+ const u = e.userInputMessage
604
+ if (u && (i !== latest || !u.images.length)) delete u.images
605
+ })
606
+ for (const e of entries) if (e.assistantResponseMessage && !e.assistantResponseMessage.toolUses.length) delete e.assistantResponseMessage.toolUses
607
+ // the instructions go at the head of the first message
608
+ let system = typeof req.system === "string" ? req.system : textOf(req.system)
609
+ if (budget) system = `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>` + (system ? "\n" + system : "")
610
+ if (system) entries[0].userInputMessage.content = system + "\n\n" + entries[0].userInputMessage.content
611
+
612
+ // the caller's tools, and any the conversation used that it no longer
613
+ // offers — Kiro rejects a history naming a tool it wasn't given
614
+ const current = entries.at(-1).userInputMessage
615
+ const tools = []
616
+ const offered = new Set()
617
+ const EMPTY = { type: "object", properties: {} }
618
+ for (const t of req.tools ?? []) {
619
+ if (!t?.name || (t.type && t.type !== "custom" && !t.input_schema)) continue // server tools Kiro hasn't
620
+ offered.add(t.name)
621
+ tools.push({ toolSpecification: { name: t.name, description: t.description || t.name, inputSchema: { json: t.input_schema ?? EMPTY } } })
622
+ }
623
+ for (const e of entries)
624
+ for (const c of e.assistantResponseMessage?.toolUses ?? [])
625
+ if (!offered.has(c.name)) {
626
+ offered.add(c.name)
627
+ tools.push({ toolSpecification: { name: c.name, description: "Tool", inputSchema: { json: EMPTY } } })
628
+ }
629
+ if (tools.length) (current.userInputMessageContext ??= {}).tools = tools
630
+
631
+ const state = { chatTriggerType: "MANUAL", agentTaskType: "vibe", conversationId: randomUUID(), currentMessage: { userInputMessage: current } }
632
+ if (entries.length > 1) state.history = entries.slice(0, -1)
633
+ const out = { conversationState: state, agentMode: "vibe" }
634
+ if (profile) out.profileArn = profile
635
+ return JSON.stringify(out)
636
+ }
637
+
638
+ function sendKiro(a, body, signal) {
639
+ const ua = `aws-sdk-rust/1.0.0 ua/2.1 os/other lang/rust api/codewhispererstreaming#1.28.3 m/E app/AmazonQ-For-CLI md/appVersion-1.28.3-${randomUUID().replaceAll("-", "")}`
640
+ return fetch(runtime(a.region) + "/generateAssistantResponse", {
641
+ method: "POST",
642
+ headers: {
643
+ ...authHeaders(a),
644
+ "Content-Type": "application/json",
645
+ Accept: "application/vnd.amazon.eventstream",
646
+ "x-amzn-codewhisperer-optout": "true",
647
+ "amz-sdk-invocation-id": randomUUID(),
648
+ "amz-sdk-request": "attempt=1; max=1",
649
+ "x-amzn-kiro-agent-mode": "vibe",
650
+ "x-amz-user-agent": ua,
651
+ "User-Agent": ua,
652
+ },
653
+ body,
654
+ signal,
655
+ })
656
+ }
657
+
658
+ // failure is the status and message for an error Kiro answered with:
659
+ // {"message": "...", "reason": "..."}.
660
+ function failure(status, body) {
661
+ let msg = String(body ?? "").trim()
662
+ try {
663
+ const e = JSON.parse(body)
664
+ if (e?.message) msg = e.message + (e.reason ? ` (${e.reason})` : "")
665
+ } catch {}
666
+ msg ||= `HTTP ${status}`
667
+ if (msg.includes("CONTENT_LENGTH_EXCEEDS_THRESHOLD") || msg.toLowerCase().includes("input is too long"))
668
+ return { status: 400, message: "input is too long for the model's context: " + msg }
669
+ if (msg.includes("INSUFFICIENT_MODEL_CAPACITY")) return { status: 503, message: msg }
670
+ if (msg.includes("MONTHLY_REQUEST_COUNT") || msg.includes("USAGE_LIMIT")) return { status: 429, message: "usage limit reached: " + msg }
671
+ return { status, message: msg }
672
+ }
673
+
674
+ const ERROR_TYPES = { 400: "invalid_request_error", 401: "authentication_error", 402: "billing_error", 403: "permission_error",
675
+ 404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error", 503: "overloaded_error", 529: "overloaded_error" }
676
+ const errorType = (status) => ERROR_TYPES[status] ?? "api_error"
677
+ const errorResponse = (status, message) =>
678
+ new Response(JSON.stringify({ type: "error", error: { type: errorType(status), message: "Kiro: " + message } }), {
679
+ status,
680
+ headers: { "Content-Type": "application/json" },
681
+ })
682
+
683
+ // ---- the reply: an AWS event stream → Messages ---------------------------------------
684
+
685
+ // frames reads an AWS event stream: each message its length, its headers'
686
+ // length and a checksum, the headers, the payload, a checksum of the whole.
687
+ async function* frames(body) {
688
+ let buf = new Uint8Array(0)
689
+ const reader = body.getReader()
690
+ try {
691
+ for (;;) {
692
+ while (buf.length >= 12) {
693
+ const v = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
694
+ const total = v.getUint32(0)
695
+ const hlen = v.getUint32(4)
696
+ if (total < 16 || total > 16 << 20 || hlen > total - 16) throw new Error("a malformed event stream")
697
+ if (buf.length < total) break
698
+ const rest = buf.subarray(12, total)
699
+ yield { headers: frameHeaders(rest.subarray(0, hlen)), payload: rest.subarray(hlen, rest.length - 4) }
700
+ buf = buf.slice(total)
701
+ }
702
+ const { value, done } = await reader.read()
703
+ if (done) {
704
+ if (buf.length) throw new Error("unexpected EOF")
705
+ return
706
+ }
707
+ const n = new Uint8Array(buf.length + value.length)
708
+ n.set(buf)
709
+ n.set(value, buf.length)
710
+ buf = n
711
+ }
712
+ } finally {
713
+ reader.releaseLock?.()
714
+ }
715
+ }
716
+
717
+ function frameHeaders(h) {
718
+ const out = {}
719
+ const dec = new TextDecoder()
720
+ const v = new DataView(h.buffer, h.byteOffset, h.byteLength)
721
+ let i = 0
722
+ while (i < h.length) {
723
+ const n = h[i]
724
+ if (h.length < i + 2 + n) break
725
+ const name = dec.decode(h.subarray(i + 1, i + 1 + n))
726
+ const type = h[i + 1 + n]
727
+ i += 2 + n
728
+ const size = { 0: 0, 1: 0, 2: 1, 3: 2, 4: 4, 5: 8, 8: 8, 9: 16 }[type]
729
+ if (type === 6 || type === 7) {
730
+ if (h.length < i + 2) break
731
+ const l = v.getUint16(i)
732
+ if (h.length < i + 2 + l) break
733
+ if (type === 7) out[name] = dec.decode(h.subarray(i + 2, i + 2 + l))
734
+ i += 2 + l
735
+ continue
736
+ }
737
+ if (size === undefined || h.length < i + size) break
738
+ i += size
739
+ }
740
+ return out
741
+ }
742
+
743
+ // exception is what an error in the stream says.
744
+ function exception(kind, payload) {
745
+ let e = {}
746
+ try {
747
+ e = JSON.parse(new TextDecoder().decode(payload))
748
+ } catch {}
749
+ let msg = e.message || e.Message || new TextDecoder().decode(payload).trim()
750
+ if (e.reason) msg += ` (${e.reason})`
751
+ if (String(kind).toLowerCase().includes("throttl")) return "rate limited: " + msg
752
+ return kind ? `${kind}: ${msg}` : msg
753
+ }
754
+
755
+ // ThinkParser splits a reply that opens with <thinking>…</thinking> into its
756
+ // thinking and the text after, however the tags fall across pieces.
757
+ const OPEN = "<thinking>"
758
+ const CLOSE = "</thinking>"
759
+ function partialSuffix(s, tag) {
760
+ for (let n = Math.min(tag.length - 1, s.length); n > 0; n--) if (s.endsWith(tag.slice(0, n))) return n
761
+ return 0
762
+ }
763
+ class ThinkParser {
764
+ state = 0 // 0: not yet known, 1: thinking, 2: text
765
+ buf = ""
766
+ said = false
767
+ feed(s) {
768
+ this.buf += s
769
+ const out = []
770
+ for (;;) {
771
+ if (this.state === 0) {
772
+ const t = this.buf.replace(/^[ \t\r\n]+/, "")
773
+ if (t.startsWith(OPEN)) {
774
+ ;[this.buf, this.state] = [t.slice(OPEN.length), 1]
775
+ continue
776
+ }
777
+ if (t.length < OPEN.length && OPEN.startsWith(t)) return out // may yet be the tag
778
+ this.state = 2
779
+ continue
780
+ }
781
+ if (this.state === 1) {
782
+ if (!this.said) {
783
+ this.buf = this.buf.replace(/^[\r\n]+/, "")
784
+ if (!this.buf) return out
785
+ }
786
+ const i = this.buf.indexOf(CLOSE)
787
+ if (i >= 0) {
788
+ if (i > 0) {
789
+ out.push({ kind: "think", text: this.buf.slice(0, i) })
790
+ this.said = true
791
+ }
792
+ ;[this.buf, this.state] = [this.buf.slice(i + CLOSE.length).replace(/^[\r\n]+/, ""), 2]
793
+ continue
794
+ }
795
+ // hold back what may be the start of the closing tag
796
+ const n = this.buf.length - partialSuffix(this.buf, CLOSE)
797
+ if (n > 0) {
798
+ out.push({ kind: "think", text: this.buf.slice(0, n) })
799
+ ;[this.buf, this.said] = [this.buf.slice(n), true]
800
+ }
801
+ return out
802
+ }
803
+ if (this.buf) out.push({ kind: "text", text: this.buf })
804
+ this.buf = ""
805
+ return out
806
+ }
807
+ }
808
+ end() {
809
+ if (!this.buf) return []
810
+ const ev = { kind: this.state === 1 ? "think" : "text", text: this.buf }
811
+ this.buf = ""
812
+ if (this.state === 0) this.state = 2
813
+ return [ev]
814
+ }
815
+ }
816
+
817
+ // events turns Kiro's reply into events: text, thinking, tool calls (the
818
+ // arguments in pieces), then the usage and why it stopped — or an error.
819
+ async function* events(body, model, budget) {
820
+ const think = new ThinkParser()
821
+ let tool = ""
822
+ let tools = 0
823
+ let stop = ""
824
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
825
+ let pct = 0
826
+ let said = 0
827
+ let failed = ""
828
+ const dec = new TextDecoder()
829
+ try {
830
+ for await (const f of frames(body)) {
831
+ const type = f.headers[":message-type"]
832
+ if (type === "exception" || type === "error") {
833
+ failed = exception(f.headers[":exception-type"] || f.headers[":error-code"], f.payload)
834
+ break
835
+ }
836
+ let m
837
+ try {
838
+ m = JSON.parse(dec.decode(f.payload))
839
+ } catch {
840
+ continue
841
+ }
842
+ const kind = f.headers[":event-type"]
843
+ if (kind === "assistantResponseEvent") {
844
+ const text = typeof m.content === "string" ? m.content : ""
845
+ said += text.length
846
+ if (budget) yield* think.feed(text)
847
+ else if (text) yield { kind: "text", text }
848
+ } else if (kind === "reasoningContentEvent") {
849
+ if (m.text) {
850
+ said += m.text.length
851
+ yield { kind: "think", text: m.text }
852
+ } else if (m.signature) yield { kind: "sig", text: m.signature }
853
+ } else if (kind === "toolUseEvent") {
854
+ if (m.toolUseId && m.toolUseId !== tool) {
855
+ yield* think.end() // the text before a call is all said
856
+ tool = m.toolUseId
857
+ tools++
858
+ yield { kind: "toolStart", id: m.toolUseId, name: m.name ?? "" }
859
+ }
860
+ if (m.input !== undefined && m.input !== null) {
861
+ if (typeof m.input === "string") {
862
+ if (m.input) yield { kind: "toolArgs", text: m.input }
863
+ said += m.input.length
864
+ } else {
865
+ const s = JSON.stringify(m.input)
866
+ if (s !== "{}") yield { kind: "toolArgs", text: s }
867
+ said += s.length
868
+ }
869
+ }
870
+ } else if (kind === "metadataEvent" || kind === "messageMetadataEvent") {
871
+ if (m.stopReason) stop = m.stopReason
872
+ const tu = m.tokenUsage
873
+ if (tu && typeof tu === "object") {
874
+ usage.input += tu.uncachedInputTokens || tu.inputTokens || 0
875
+ usage.output += tu.outputTokens || 0
876
+ usage.cacheRead += tu.cacheReadInputTokens || 0
877
+ usage.cacheWrite += tu.cacheWriteInputTokens || 0
878
+ if (tu.contextUsagePercentage > 0) pct = tu.contextUsagePercentage
879
+ }
880
+ } else if (kind === "contextUsageEvent") {
881
+ if (m.contextUsagePercentage > 0) pct = m.contextUsagePercentage
882
+ } else if (["error", "throttlingError", "validationError", "serviceUnavailableError", "internalServerException"].includes(kind)) {
883
+ failed = exception(kind, f.payload)
884
+ break
885
+ }
886
+ }
887
+ } catch (e) {
888
+ if (e?.name !== "AbortError") failed = "the reply broke off: " + e.message
889
+ }
890
+ yield* think.end()
891
+ if (failed) {
892
+ yield { kind: "error", text: failed }
893
+ return
894
+ }
895
+ const window = windows.get(model) ?? 0
896
+ // Kiro says how full the context is rather than how many tokens
897
+ if (!usage.input && pct > 0 && window > 0) usage.input = Math.floor((pct / 100) * window)
898
+ if (!usage.output) usage.output = Math.floor((said + 3) / 4)
899
+ let reason = "end_turn"
900
+ if (tools > 0) reason = "tool_use"
901
+ else if (/^max_tokens$/i.test(stop)) reason = "max_tokens"
902
+ else if (/^content_filtered$/i.test(stop)) reason = "refusal"
903
+ yield { kind: "stop", reason, usage }
904
+ }
905
+
906
+ const anthropicUsage = (u) => ({
907
+ input_tokens: u.input,
908
+ output_tokens: u.output,
909
+ cache_read_input_tokens: u.cacheRead,
910
+ cache_creation_input_tokens: u.cacheWrite,
911
+ })
912
+
913
+ // reply answers the Messages request from Kiro's events: a stream of
914
+ // Messages' events, or one message.
915
+ async function reply(it, model, stream) {
916
+ const id = "msg_" + randomBytes(12).toString("hex")
917
+ if (!stream) {
918
+ const content = []
919
+ let last = null
920
+ const add = (block) => {
921
+ content.push(block)
922
+ last = block
923
+ }
924
+ for await (const e of it) {
925
+ if (e.kind === "text") {
926
+ if (last?.type === "text") last.text += e.text
927
+ else add({ type: "text", text: e.text })
928
+ } else if (e.kind === "think") {
929
+ if (last?.type === "thinking") last.thinking += e.text
930
+ else add({ type: "thinking", thinking: e.text, signature: "" })
931
+ } else if (e.kind === "sig") {
932
+ if (last?.type === "thinking") last.signature += e.text
933
+ } else if (e.kind === "toolStart") add({ type: "tool_use", id: e.id, name: e.name, input: "" })
934
+ else if (e.kind === "toolArgs") {
935
+ if (last?.type === "tool_use") last.input += e.text
936
+ } else if (e.kind === "error") return errorResponse(502, e.text)
937
+ else if (e.kind === "stop") {
938
+ for (const b of content)
939
+ if (b.type === "tool_use") {
940
+ try {
941
+ b.input = b.input ? JSON.parse(b.input) : {}
942
+ } catch {
943
+ b.input = {}
944
+ }
945
+ }
946
+ return Response.json({ id, type: "message", role: "assistant", model, content, stop_reason: e.reason, stop_sequence: null,
947
+ usage: anthropicUsage(e.usage) })
948
+ }
949
+ }
950
+ return errorResponse(502, "the reply ended before it was complete")
951
+ }
952
+
953
+ const enc = new TextEncoder()
954
+ const sse = (type, data) => enc.encode(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`)
955
+ let index = -1
956
+ let open = "" // the kind of block open
957
+ let started = false
958
+ const body = new ReadableStream({
959
+ async pull(ctl) {
960
+ if (!started) {
961
+ started = true
962
+ ctl.enqueue(sse("message_start", { message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null,
963
+ stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } }))
964
+ return
965
+ }
966
+ const close = () => {
967
+ if (open) ctl.enqueue(sse("content_block_stop", { index }))
968
+ open = ""
969
+ }
970
+ const begin = (kind, block) => {
971
+ close()
972
+ index++
973
+ open = kind
974
+ ctl.enqueue(sse("content_block_start", { index, content_block: block }))
975
+ }
976
+ const r = await it.next()
977
+ if (r.done) return ctl.close()
978
+ const e = r.value
979
+ switch (e.kind) {
980
+ case "text":
981
+ if (open !== "text") begin("text", { type: "text", text: "" })
982
+ ctl.enqueue(sse("content_block_delta", { index, delta: { type: "text_delta", text: e.text } }))
983
+ break
984
+ case "think":
985
+ if (open !== "thinking") begin("thinking", { type: "thinking", thinking: "", signature: "" })
986
+ ctl.enqueue(sse("content_block_delta", { index, delta: { type: "thinking_delta", thinking: e.text } }))
987
+ break
988
+ case "sig":
989
+ if (open === "thinking") ctl.enqueue(sse("content_block_delta", { index, delta: { type: "signature_delta", signature: e.text } }))
990
+ break
991
+ case "toolStart":
992
+ begin("tool", { type: "tool_use", id: e.id, name: e.name, input: {} })
993
+ break
994
+ case "toolArgs":
995
+ if (open === "tool") ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: e.text } }))
996
+ break
997
+ case "error":
998
+ close()
999
+ ctl.enqueue(sse("error", { error: { type: "api_error", message: "Kiro: " + e.text } }))
1000
+ return ctl.close()
1001
+ case "stop":
1002
+ close()
1003
+ ctl.enqueue(sse("message_delta", { delta: { stop_reason: e.reason, stop_sequence: null }, usage: anthropicUsage(e.usage) }))
1004
+ ctl.enqueue(sse("message_stop", {}))
1005
+ return ctl.close()
1006
+ }
1007
+ },
1008
+ cancel() {
1009
+ it.return?.()
1010
+ },
1011
+ })
1012
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
1013
+ }
1014
+
1015
+ // generate answers a Messages request through Kiro's API: once more,
1016
+ // refreshed, when Kiro turns the token down (403).
1017
+ async function generate(creds, auth, req, signal) {
1018
+ const model = String(req.model ?? "auto")
1019
+ const budget = thinking(req, model)
1020
+ let a
1021
+ try {
1022
+ a = await creds(auth)
1023
+ } catch (e) {
1024
+ return errorResponse(401, e.message)
1025
+ }
1026
+ let res
1027
+ try {
1028
+ res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1029
+ } catch (e) {
1030
+ if (e?.name === "AbortError") throw e
1031
+ return errorResponse(502, e.message)
1032
+ }
1033
+ if (res.status === 403) {
1034
+ await res.body?.cancel()
1035
+ try {
1036
+ a = await creds(auth, true)
1037
+ } catch (e) {
1038
+ return errorResponse(401, e.message)
1039
+ }
1040
+ try {
1041
+ res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1042
+ } catch (e) {
1043
+ if (e?.name === "AbortError") throw e
1044
+ return errorResponse(502, e.message)
1045
+ }
1046
+ }
1047
+ if (!res.ok) {
1048
+ const f = failure(res.status, (await res.text()).slice(0, 1 << 20))
1049
+ return errorResponse(f.status, f.message)
1050
+ }
1051
+ return reply(events(res.body, model, budget), model, req.stream === true)
1052
+ }
1053
+
1054
+ // ---- signing in ----------------------------------------------------------------------
1055
+
1056
+ function page(ok, title, text) {
1057
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c])
1058
+ return `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title>
1059
+ <style>body{font:15px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;color:#222;background:#fafafa}
1060
+ @media (prefers-color-scheme:dark){body{color:#eee;background:#161616}}main{text-align:center;max-width:28rem;padding:1rem}
1061
+ .d{font-size:2rem;color:${ok ? "#2a9d5c" : "#c0392b"}}</style>
1062
+ <main><div class="d">${ok ? "✓" : "✕"}</div><h1>${esc(title)}</h1><p>${esc(text)}</p></main>`
1063
+ }
1064
+
1065
+ // signInPost posts JSON to Kiro's auth service or AWS's sign-in.
1066
+ async function signInPost(url, body) {
1067
+ const res = await fetch(url, {
1068
+ method: "POST",
1069
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": IDE_UA },
1070
+ body: JSON.stringify(body),
1071
+ signal: AbortSignal.timeout(30_000),
1072
+ })
1073
+ const text = await res.text()
1074
+ if (!res.ok) {
1075
+ let e = {}
1076
+ try {
1077
+ e = JSON.parse(text)
1078
+ } catch {}
1079
+ throw new Error(`the sign-in was refused (${res.status}): ${e.error_description || e.message || e.error || res.statusText}`)
1080
+ }
1081
+ return JSON.parse(text)
1082
+ }
1083
+
1084
+ const expiry = (s) => Date.now() + (s > 0 ? s : 3600) * 1000
1085
+
1086
+ // whoIs names a new sign-in with the email Kiro has for it, and its plan.
1087
+ async function whoIs(c) {
1088
+ try {
1089
+ const profile = c.profile || (await profileOf(c))
1090
+ const a = { token: c.access, tokenType: tokenType(c), profile, region: regionOf(profile, c.region) }
1091
+ const l = await usageLimits(a, true)
1092
+ return { profile, email: l?.userInfo?.email ?? "", plan: planName(l?.subscriptionInfo?.subscriptionTitle) }
1093
+ } catch {
1094
+ return { profile: c.profile ?? "", email: "", plan: "" }
1095
+ }
1096
+ }
1097
+
1098
+ async function listen() {
1099
+ for (const port of CALLBACK_PORTS) {
1100
+ const server = createServer()
1101
+ const ok = await new Promise((resolve) => {
1102
+ server.once("error", () => resolve(false))
1103
+ server.listen(port, "127.0.0.1", () => resolve(true))
1104
+ })
1105
+ if (ok) return { server, port }
1106
+ }
1107
+ throw new Error("the ports Kiro's sign-in comes back to are all busy; close the Kiro IDE's sign-in and try again")
1108
+ }
1109
+
1110
+ // browserSignIn is the IDE's: Kiro's sign-in page, back to a port here.
1111
+ async function browserSignIn() {
1112
+ const { server, port } = await listen()
1113
+ const state = randomBytes(24).toString("base64url")
1114
+ const verifier = randomBytes(48).toString("base64url")
1115
+ const redirect = `http://localhost:${port}`
1116
+ const aws = {} // once Kiro's page has sent the browser on to AWS
1117
+ const awsRedirect = `http://127.0.0.1:${port}/oauth/callback`
1118
+ let waiting = true
1119
+ let settle
1120
+ const done = new Promise((r) => (settle = r))
1121
+ const finish = (result) => {
1122
+ if (!waiting) return
1123
+ waiting = false
1124
+ settle(result)
1125
+ }
1126
+
1127
+ server.on("request", async (req, res) => {
1128
+ const url = new URL(req.url ?? "/", redirect)
1129
+ const html = (code, body) => {
1130
+ res.writeHead(code, { "Content-Type": "text/html; charset=utf-8" })
1131
+ res.end(body)
1132
+ }
1133
+ if (url.pathname !== "/oauth/callback" && url.pathname !== "/signin/callback") return res.writeHead(404).end()
1134
+ const q = url.searchParams
1135
+ if (!waiting) return html(200, page(false, "This sign-in is over", "Start it again."))
1136
+ const fail = (msg) => {
1137
+ finish({ type: "failed" })
1138
+ html(200, page(false, "Sign-in didn't finish", msg))
1139
+ }
1140
+ if (q.get("error")) return fail(q.get("error_description") || q.get("error"))
1141
+ let c
1142
+ try {
1143
+ if (aws.state && q.get("state") === aws.state) {
1144
+ // back from AWS
1145
+ if (!q.get("code")) return fail("AWS sent back no code")
1146
+ const t = await signInPost(oidc(aws.region) + "/token", { clientId: aws.clientId, clientSecret: aws.clientSecret,
1147
+ grantType: "authorization_code", redirectUri: awsRedirect, code: q.get("code"), codeVerifier: aws.verifier })
1148
+ if (!t.accessToken) throw new Error("AWS sent back no token")
1149
+ c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "idc", loginProvider: aws.provider,
1150
+ region: aws.region, clientId: aws.clientId, clientSecret: aws.clientSecret, profile: "" }
1151
+ } else if (q.get("state") !== state) {
1152
+ // not ours: someone else's page, or a stale tab
1153
+ return html(200, page(false, "This link isn't from this sign-in", "Start it again."))
1154
+ } else {
1155
+ const opt = q.get("login_option") ?? ""
1156
+ if (opt === "google" || opt === "github") {
1157
+ const t = await signInPost(AUTH_SERVICE + "/oauth/token", { code: q.get("code"), code_verifier: verifier,
1158
+ redirect_uri: redirect + url.pathname + "?login_option=" + opt })
1159
+ if (!t.accessToken) throw new Error("Kiro sent back no token")
1160
+ c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "social",
1161
+ loginProvider: opt === "google" ? "Google" : "Github", region: "us-east-1", profile: t.profileArn ?? "" }
1162
+ } else if (opt === "builderid" || opt === "awsidc" || opt === "internal") {
1163
+ // on to AWS, with a client registered for this sign-in
1164
+ const issuer = q.get("issuer_url")
1165
+ const region = q.get("idc_region")
1166
+ if (!issuer || !region) return fail("Kiro's page didn't say where to sign in at AWS")
1167
+ const reg = await signInPost(oidc(region) + "/client/register", { clientName: "Kiro IDE", clientType: "public", scopes: SCOPES,
1168
+ grantTypes: ["authorization_code", "refresh_token"], redirectUris: ["http://127.0.0.1/oauth/callback"], issuerUrl: issuer })
1169
+ if (!reg.clientId) throw new Error("AWS registered no client")
1170
+ Object.assign(aws, { region, clientId: reg.clientId, clientSecret: reg.clientSecret,
1171
+ provider: { builderid: "BuilderId", awsidc: "Enterprise", internal: "Internal" }[opt],
1172
+ state: randomBytes(24).toString("base64url"), verifier: randomBytes(48).toString("base64url") })
1173
+ const a = new URLSearchParams({ response_type: "code", client_id: aws.clientId, redirect_uri: awsRedirect, scopes: SCOPES.join(","),
1174
+ state: aws.state, code_challenge: createHash("sha256").update(aws.verifier).digest("base64url"), code_challenge_method: "S256" })
1175
+ res.writeHead(302, { Location: `${oidc(region)}/authorize?${a}` })
1176
+ return res.end()
1177
+ } else if (opt === "external_idp") {
1178
+ return fail("A company's own identity provider can't be signed in to here yet; sign in with `kiro-cli login`, and use Kiro CLI's sign-in")
1179
+ } else return fail(`Kiro's page came back with a sign-in this plugin doesn't know (${JSON.stringify(opt)})`)
1180
+ }
1181
+ } catch (e) {
1182
+ return fail(e.message)
1183
+ }
1184
+ const who = await whoIs(c)
1185
+ const user = who.email || "Kiro account"
1186
+ finish({ type: "success", provider: ID, refresh: c.refresh, access: c.access, expires: c.expires, accountId: user,
1187
+ method: c.method, loginProvider: c.loginProvider, region: c.region, profileArn: who.profile,
1188
+ ...(c.clientId ? { clientId: c.clientId, clientSecret: c.clientSecret } : {}), ...(who.plan ? { plan: who.plan } : {}) })
1189
+ html(200, page(true, "You're signed in", `${user} is signed in. You can close this tab.`))
1190
+ })
1191
+
1192
+ const timer = setTimeout(() => finish({ type: "failed" }), SIGN_IN_TIMEOUT)
1193
+ done.then(() => {
1194
+ clearTimeout(timer)
1195
+ setTimeout(() => server.close(), 10_000).unref?.()
1196
+ })
1197
+ const challenge = createHash("sha256").update(verifier).digest("base64url")
1198
+ const q = new URLSearchParams({ state, code_challenge: challenge, code_challenge_method: "S256", redirect_uri: redirect, redirect_from: "KiroIDE" })
1199
+ return {
1200
+ url: `${PORTAL}/signin?${q}`,
1201
+ instructions: "Sign in to Kiro with Google, GitHub, AWS Builder ID or IAM Identity Center.",
1202
+ method: "auto",
1203
+ callback: () => done,
1204
+ }
1205
+ }
1206
+
1207
+ // ownSignIn uses kiro-cli's sign-in, else the Kiro IDE's: nothing is copied,
1208
+ // they are read where they keep it each time.
1209
+ async function ownSignIn() {
1210
+ return {
1211
+ url: "",
1212
+ instructions: "Uses the account kiro-cli or the Kiro IDE is signed in to.",
1213
+ method: "auto",
1214
+ callback: async () => {
1215
+ const cli = await readCLI()
1216
+ const c = cli ?? (await readIDE())
1217
+ if (!c) return { type: "failed" }
1218
+ const who = fresh(c) ? await whoIs(c) : { email: "", plan: "" }
1219
+ return { type: "success", provider: ID, refresh: "", access: "", expires: 0, source: cli ? "kiro-cli" : "kiro-ide",
1220
+ accountId: who.email || (cli ? "kiro-cli's account" : "Kiro IDE's account"), ...(who.plan ? { plan: who.plan } : {}) }
1221
+ },
1222
+ }
1223
+ }
1224
+
1225
+ // ---- the plugin ------------------------------------------------------------------------
1226
+
1227
+ async function bodyOf(input, init) {
1228
+ const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
1229
+ return JSON.parse(typeof b === "string" ? b : new TextDecoder().decode(b))
1230
+ }
1231
+
1232
+ export async function KiroAuthPlugin({ client } = {}) {
1233
+ const creds = account(client)
1234
+ return {
1235
+ auth: {
1236
+ provider: ID,
1237
+ async loader(getAuth) {
1238
+ const auth = await getAuth()
1239
+ if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return {}
1240
+ return {
1241
+ baseURL: BASE,
1242
+ apiKey: "kiro", // the fetch signs each request itself
1243
+ // every Messages request answered through Kiro's own API
1244
+ async fetch(input, init = {}) {
1245
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1246
+ if (!/\/messages$/.test(new URL(url).pathname))
1247
+ return errorResponse(404, `this plugin only answers Anthropic Messages requests, not ${new URL(url).pathname}`)
1248
+ let req
1249
+ try {
1250
+ req = await bodyOf(input, init)
1251
+ } catch {
1252
+ return errorResponse(400, "a request that isn't JSON")
1253
+ }
1254
+ return generate(creds, (await getAuth()) ?? auth, req, init.signal)
1255
+ },
1256
+ }
1257
+ },
1258
+ methods: [
1259
+ { type: "oauth", label: "Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)", authorize: browserSignIn },
1260
+ { type: "oauth", label: "Kiro CLI's or Kiro IDE's sign-in", authorize: ownSignIn },
1261
+ { type: "api", label: "Kiro API key (ksk_…)" },
1262
+ ],
1263
+ },
1264
+ async config(config) {
1265
+ config.provider ??= {}
1266
+ const was = config.provider[ID] ?? {}
1267
+ config.provider[ID] = {
1268
+ name: "Kiro",
1269
+ npm: MESSAGES,
1270
+ api: BASE,
1271
+ ...was,
1272
+ models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
1273
+ }
1274
+ },
1275
+ // the account's own list, the default first
1276
+ provider: {
1277
+ id: ID,
1278
+ async models(provider, { auth } = {}) {
1279
+ if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return provider.models
1280
+ try {
1281
+ const list = await listModels(await creds(auth))
1282
+ return Object.fromEntries(list.map((m) => [m.id, runtimeModel(m)]))
1283
+ } catch {
1284
+ return provider.models
1285
+ }
1286
+ },
1287
+ },
1288
+ }
1289
+ }
1290
+
1291
+ // for tests
1292
+ export const _internal = { buildKiro, events, reply, failure, toolID, thinking, readCLI, readIDE, regionOf, planName, frames }