@magpie-community/opencode-kiro-auth 0.1.17 → 0.1.19

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 +8 -0
  2. package/index.mjs +124 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,9 @@ Refreshing such a sign-in:
62
62
  plugin's `auth.refresh`, once for the account and before its requests,
63
63
  models and usage need it. magpie saves the new token. A failed renewal,
64
64
  even one Kiro refused, doesn't mark the account: it is tried again.
65
+ The usage read does: a sign-in Kiro refused there marks the account
66
+ signed out (`signIn: "expired"`), so magpie passes over it and offers
67
+ Sign in again on its row, and a clean read takes the mark off.
65
68
  - OpenCode doesn't call that hook: there the token is refreshed 2 minutes
66
69
  before it expires, before a request, and saved.
67
70
  - Either way, a token Kiro turns down (a 403) is refreshed and the request
@@ -96,6 +99,11 @@ request:
96
99
  - A tool's images (a screenshot, Codex's view_image) go with the
97
100
  message that answers the call, as Kiro's tool results hold text alone.
98
101
  - Only the latest images are sent.
102
+ - An image Kiro would turn away for size (over 5 MiB as Base64, which is
103
+ 3.75 MiB of file) is made smaller on the request being sent: its long
104
+ edge brought to 2000 pixels, then WebP or JPEG, smaller each try. One
105
+ that can't be read or brought under is replaced by a short note, so the
106
+ conversation goes on (#428). Smaller images are sent as they are.
99
107
  - Tools the history used but the request doesn't offer are declared.
100
108
  - It turns the AWS event stream Kiro answers with back into Messages'
101
109
  events (streamed) or one message. The reply includes text, thinking,
package/index.mjs CHANGED
@@ -219,7 +219,8 @@ const EXPIRED_CLI = EXPIRED + " with `kiro-cli login` or the Kiro IDE"
219
219
 
220
220
  // gone is an error saying the sign-in itself is gone — refused, or never
221
221
  // there. The built-in marked no Kiro account lapsed, not even then, nor
222
- // cleared one: every answer and usage read says the account is kept.
222
+ // cleared one: every answer says the account is kept. The usage read says
223
+ // expired for it, and renewed for a clean read (auth.usage).
223
224
  const gone = (msg) => Object.assign(new Error(msg), { gone: true })
224
225
  const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is refreshed before a request
225
226
  const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
@@ -710,6 +711,106 @@ function addImage(images, p) {
710
711
  if (p.source?.type === "base64" && p.source.data) images.push({ format: imageFormat(p.source.media_type), source: { bytes: p.source.data } })
711
712
  }
712
713
 
714
+ // ---- images over Kiro's size limit ---------------------------------------------------
715
+ //
716
+ // Kiro turns away an image whose Base64 is over 5 MiB, counted on the
717
+ // encoded string, not the file (magpie-community/plugins#428):
718
+ // messages.130.content.2.image.source.base64: image exceeds 5 MB maximum:
719
+ // 5561904 bytes > 5242880 bytes (IMAGE_SIZE_EXCEEDED)
720
+ // 5561904 is that image's Base64 length (4,171,428 bytes decoded), so the
721
+ // largest file that fits is 3,932,160 bytes. kiro-cli's own gate is 10 MB of
722
+ // raw file (MAX_IMAGE_SIZE, aws/amazon-q-developer-cli chat/consts.rs), which
723
+ // lets such an image in and then every later turn fails on it
724
+ // (kirodotdev/Kiro#11497). The image stays in history, so each turn would
725
+ // fail the same way: an image over the limit is made smaller on the request
726
+ // being sent, and one that can't be is replaced by a note.
727
+ const IMAGE_LIMIT = 5 * 1024 * 1024 // Base64 characters
728
+ const IMAGE_EDGE = 2000 // a shrunk image's long edge: Kiro's web app's limit (kirodotdev/Kiro#8884), above what Claude looks at
729
+ const IMAGE_MIN_EDGE = 256
730
+ const IMAGE_MAX_PIXELS = 50_000_000 // a decoded image is ~4 bytes a pixel: a 200MB ceiling, and a header claiming more is refused unread
731
+ const IMAGE_MAX_INPUT = 64 * 1024 * 1024 // Base64 characters looked at all
732
+ const IMAGE_DEADLINE = 30_000 // ms one image may take
733
+ const fitted = new Map() // sha256 of the Base64 → Promise of { format, bytes } or { note }, newest last
734
+ const FITTED_KEEP = 4
735
+
736
+ const mib = (n) => (n / (1024 * 1024)).toFixed(1) + " MB"
737
+
738
+ // shrink tries, in order, the image's own kind at Claude's size, then a
739
+ // lossy kind (WebP keeps transparency, JPEG stays JPEG) smaller each time,
740
+ // until the Base64 fits.
741
+ async function shrink(b64) {
742
+ const I = globalThis.Bun?.Image
743
+ if (typeof I !== "function") return { note: "this plugin host can't resize images" }
744
+ const started = Date.now()
745
+ const raw = Buffer.from(b64, "base64")
746
+ let meta
747
+ try {
748
+ meta = await new I(raw, { maxPixels: IMAGE_MAX_PIXELS }).metadata()
749
+ } catch (e) {
750
+ return { note: e?.code === "ERR_IMAGE_TOO_MANY_PIXELS" ? "it has too many pixels to resize" : "it couldn't be read as an image" }
751
+ }
752
+ const long = Math.max(meta.width, meta.height)
753
+ const jpeg = meta.format === "jpeg"
754
+ const own = jpeg ? { format: "jpeg", opts: { quality: 85 } } : meta.format === "webp" ? { format: "webp", opts: { quality: 85 } } : { format: "png", opts: { compressionLevel: 9 } }
755
+ const lossy = (quality) => (jpeg ? { format: "jpeg", opts: { quality } } : { format: "webp", opts: { quality } })
756
+ const tries = [{ edge: Math.min(long, IMAGE_EDGE), ...own }, { edge: Math.min(long, IMAGE_EDGE), ...lossy(80) }]
757
+ for (let edge = Math.min(long, IMAGE_EDGE) * 0.7; edge >= IMAGE_MIN_EDGE; edge *= 0.7) tries.push({ edge: Math.round(edge), ...lossy(75) })
758
+ for (const t of tries) {
759
+ if (Date.now() - started > IMAGE_DEADLINE) return { note: "resizing it took too long" }
760
+ let out
761
+ try {
762
+ let img = new I(raw, { maxPixels: IMAGE_MAX_PIXELS })
763
+ if (t.edge < long) img = img.resize(t.edge, t.edge, { fit: "inside", withoutEnlargement: true })
764
+ out = await img[t.format](t.opts).toBase64()
765
+ } catch (e) {
766
+ if (e?.code === "ERR_IMAGE_FORMAT_UNSUPPORTED") continue
767
+ return { note: "it couldn't be resized" }
768
+ }
769
+ if (out.length <= IMAGE_LIMIT) return { format: t.format, bytes: out }
770
+ }
771
+ return { note: "it couldn't be made small enough" }
772
+ }
773
+
774
+ function fitOne(b64) {
775
+ const key = createHash("sha256").update(b64).digest("hex")
776
+ let p = fitted.get(key)
777
+ if (p) fitted.delete(key) // kept as the newest below
778
+ else p = b64.length > IMAGE_MAX_INPUT ? Promise.resolve({ note: "it is too large to resize" }) : shrink(b64).catch(() => ({ note: "it couldn't be resized" }))
779
+ fitted.set(key, p)
780
+ while (fitted.size > FITTED_KEEP) fitted.delete(fitted.keys().next().value)
781
+ return p
782
+ }
783
+
784
+ // fitImages brings every image left in a built request under Kiro's limit,
785
+ // in place: the request is the plugin's own copy, never the caller's. An
786
+ // image already under it is sent as it is.
787
+ async function fitImages(out) {
788
+ const s = out.conversationState
789
+ const users = [...(s.history ?? []).map((e) => e.userInputMessage), s.currentMessage.userInputMessage].filter(Boolean)
790
+ for (const u of users) {
791
+ if (!u.images?.length) continue
792
+ const kept = []
793
+ const notes = []
794
+ for (const img of u.images) {
795
+ const b64 = img.source?.bytes
796
+ if (typeof b64 !== "string" || b64.length <= IMAGE_LIMIT) {
797
+ kept.push(img)
798
+ continue
799
+ }
800
+ const r = await fitOne(b64)
801
+ if (r.bytes) kept.push({ ...img, format: r.format, source: { ...img.source, bytes: r.bytes } })
802
+ else
803
+ notes.push(
804
+ `[An image (${img.format}, ${mib(b64.length)} as Base64) was left out of this message: Kiro takes at most ${mib(IMAGE_LIMIT)} per image, and ${r.note}.]`,
805
+ )
806
+ }
807
+ if (kept.length) u.images = kept
808
+ else delete u.images
809
+ if (notes.length) u.content = joinNonEmpty(u.content, notes.join("\n"))
810
+ }
811
+ return out
812
+ }
813
+
713
814
  const textOf = (c) =>
714
815
  typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n") : ""
715
816
  const joinNonEmpty = (a, b) => (a && b ? a + "\n\n" + b : a + b)
@@ -744,8 +845,19 @@ function formatText(req) {
744
845
  return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + JSON.stringify(f.schema) + "."
745
846
  }
746
847
 
747
- // buildKiro is the body of a generateAssistantResponse call.
848
+ // buildKiro is the body of a generateAssistantResponse call, as it is
849
+ // before its images are fitted (kiroBody).
748
850
  function buildKiro(req, model, profile, budget) {
851
+ return JSON.stringify(kiroRequest(req, model, profile, budget))
852
+ }
853
+
854
+ // kiroBody is the body sent: buildKiro's, with every image it keeps under
855
+ // Kiro's size limit.
856
+ async function kiroBody(req, model, profile, budget) {
857
+ return JSON.stringify(await fitImages(kiroRequest(req, model, profile, budget)))
858
+ }
859
+
860
+ function kiroRequest(req, model, profile, budget) {
749
861
  const entries = []
750
862
  // the messages the caller marked for its prompt cache (cache_control),
751
863
  // which go to Kiro as a cachePoint on the message they end up in (#110)
@@ -901,7 +1013,7 @@ function buildKiro(req, model, profile, budget) {
901
1013
  if (entries.length > 1) state.history = entries.slice(0, -1)
902
1014
  const out = { conversationState: state, agentMode: "vibe" }
903
1015
  if (profile) out.profileArn = profile
904
- return JSON.stringify(out)
1016
+ return out
905
1017
  }
906
1018
 
907
1019
  function sendKiro(a, body, signal) {
@@ -1342,7 +1454,7 @@ async function generate(creds, auth, req, signal) {
1342
1454
  }
1343
1455
  let res
1344
1456
  try {
1345
- res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1457
+ res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1346
1458
  } catch (e) {
1347
1459
  if (e?.name === "AbortError") throw e
1348
1460
  return errorResponse(502, e.message)
@@ -1355,7 +1467,7 @@ async function generate(creds, auth, req, signal) {
1355
1467
  return errorResponse(502, e.message)
1356
1468
  }
1357
1469
  try {
1358
- res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1470
+ res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1359
1471
  } catch (e) {
1360
1472
  if (e?.name === "AbortError") throw e
1361
1473
  return errorResponse(502, e.message)
@@ -1586,8 +1698,10 @@ export async function KiroAuthPlugin({ client } = {}) {
1586
1698
  },
1587
1699
  }
1588
1700
  },
1589
- // the account's credits, as magpie's built-in showed them; a read,
1590
- // clean or failed, marked the built-in's account nothing
1701
+ // the account's credits, as magpie's built-in showed them. A sign-in
1702
+ // Kiro refused (gone) marks the account signed out, so magpie passes
1703
+ // over it and its row offers Sign in again; a clean read takes the
1704
+ // mark off, as after `kiro-cli login`. Any other failure keeps it.
1591
1705
  async usage(getAuth) {
1592
1706
  const auth = await getAuth()
1593
1707
  if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return { error: "not signed in", signIn: "kept" }
@@ -1595,9 +1709,9 @@ export async function KiroAuthPlugin({ client } = {}) {
1595
1709
  const u = usageOf(await usageLimits(await creds(auth), true))
1596
1710
  // named as the account is
1597
1711
  if (u.user && auth.type === "oauth" && !auth.source) u.user = nameOf(u.user, auth.loginProvider)
1598
- return { ...u, signIn: "kept" }
1712
+ return { ...u, signIn: "renewed" }
1599
1713
  } catch (e) {
1600
- return { error: e.message, signIn: "kept" }
1714
+ return { error: e.message, signIn: e.gone ? "expired" : "kept" }
1601
1715
  }
1602
1716
  },
1603
1717
  methods: [
@@ -1634,4 +1748,4 @@ export async function KiroAuthPlugin({ client } = {}) {
1634
1748
  }
1635
1749
 
1636
1750
  // for tests
1637
- export const _internal = { generate, refresh, usageOf, buildKiro, events, reply, failure, toolID, toolName, toolNames, thinking, configModel, runtimeModel, readCLI, readIDE, regionOf, planName, frames }
1751
+ export const _internal = { generate, refresh, usageOf, buildKiro, kiroBody, IMAGE_LIMIT, events, reply, failure, toolID, toolName, toolNames, thinking, configModel, runtimeModel, readCLI, readIDE, regionOf, planName, frames }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",