@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.
- package/README.md +92 -0
- package/index.mjs +1292 -0
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 }
|