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

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 +5 -0
  2. package/index.mjs +116 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -96,6 +96,11 @@ request:
96
96
  - A tool's images (a screenshot, Codex's view_image) go with the
97
97
  message that answers the call, as Kiro's tool results hold text alone.
98
98
  - Only the latest images are sent.
99
+ - An image Kiro would turn away for size (over 5 MiB as Base64, which is
100
+ 3.75 MiB of file) is made smaller on the request being sent: its long
101
+ edge brought to 2000 pixels, then WebP or JPEG, smaller each try. One
102
+ that can't be read or brought under is replaced by a short note, so the
103
+ conversation goes on (#428). Smaller images are sent as they are.
99
104
  - Tools the history used but the request doesn't offer are declared.
100
105
  - It turns the AWS event stream Kiro answers with back into Messages'
101
106
  events (streamed) or one message. The reply includes text, thinking,
package/index.mjs CHANGED
@@ -710,6 +710,106 @@ function addImage(images, p) {
710
710
  if (p.source?.type === "base64" && p.source.data) images.push({ format: imageFormat(p.source.media_type), source: { bytes: p.source.data } })
711
711
  }
712
712
 
713
+ // ---- images over Kiro's size limit ---------------------------------------------------
714
+ //
715
+ // Kiro turns away an image whose Base64 is over 5 MiB, counted on the
716
+ // encoded string, not the file (magpie-community/plugins#428):
717
+ // messages.130.content.2.image.source.base64: image exceeds 5 MB maximum:
718
+ // 5561904 bytes > 5242880 bytes (IMAGE_SIZE_EXCEEDED)
719
+ // 5561904 is that image's Base64 length (4,171,428 bytes decoded), so the
720
+ // largest file that fits is 3,932,160 bytes. kiro-cli's own gate is 10 MB of
721
+ // raw file (MAX_IMAGE_SIZE, aws/amazon-q-developer-cli chat/consts.rs), which
722
+ // lets such an image in and then every later turn fails on it
723
+ // (kirodotdev/Kiro#11497). The image stays in history, so each turn would
724
+ // fail the same way: an image over the limit is made smaller on the request
725
+ // being sent, and one that can't be is replaced by a note.
726
+ const IMAGE_LIMIT = 5 * 1024 * 1024 // Base64 characters
727
+ const IMAGE_EDGE = 2000 // a shrunk image's long edge: Kiro's web app's limit (kirodotdev/Kiro#8884), above what Claude looks at
728
+ const IMAGE_MIN_EDGE = 256
729
+ 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
730
+ const IMAGE_MAX_INPUT = 64 * 1024 * 1024 // Base64 characters looked at all
731
+ const IMAGE_DEADLINE = 30_000 // ms one image may take
732
+ const fitted = new Map() // sha256 of the Base64 → Promise of { format, bytes } or { note }, newest last
733
+ const FITTED_KEEP = 4
734
+
735
+ const mib = (n) => (n / (1024 * 1024)).toFixed(1) + " MB"
736
+
737
+ // shrink tries, in order, the image's own kind at Claude's size, then a
738
+ // lossy kind (WebP keeps transparency, JPEG stays JPEG) smaller each time,
739
+ // until the Base64 fits.
740
+ async function shrink(b64) {
741
+ const I = globalThis.Bun?.Image
742
+ if (typeof I !== "function") return { note: "this plugin host can't resize images" }
743
+ const started = Date.now()
744
+ const raw = Buffer.from(b64, "base64")
745
+ let meta
746
+ try {
747
+ meta = await new I(raw, { maxPixels: IMAGE_MAX_PIXELS }).metadata()
748
+ } catch (e) {
749
+ return { note: e?.code === "ERR_IMAGE_TOO_MANY_PIXELS" ? "it has too many pixels to resize" : "it couldn't be read as an image" }
750
+ }
751
+ const long = Math.max(meta.width, meta.height)
752
+ const jpeg = meta.format === "jpeg"
753
+ const own = jpeg ? { format: "jpeg", opts: { quality: 85 } } : meta.format === "webp" ? { format: "webp", opts: { quality: 85 } } : { format: "png", opts: { compressionLevel: 9 } }
754
+ const lossy = (quality) => (jpeg ? { format: "jpeg", opts: { quality } } : { format: "webp", opts: { quality } })
755
+ const tries = [{ edge: Math.min(long, IMAGE_EDGE), ...own }, { edge: Math.min(long, IMAGE_EDGE), ...lossy(80) }]
756
+ 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) })
757
+ for (const t of tries) {
758
+ if (Date.now() - started > IMAGE_DEADLINE) return { note: "resizing it took too long" }
759
+ let out
760
+ try {
761
+ let img = new I(raw, { maxPixels: IMAGE_MAX_PIXELS })
762
+ if (t.edge < long) img = img.resize(t.edge, t.edge, { fit: "inside", withoutEnlargement: true })
763
+ out = await img[t.format](t.opts).toBase64()
764
+ } catch (e) {
765
+ if (e?.code === "ERR_IMAGE_FORMAT_UNSUPPORTED") continue
766
+ return { note: "it couldn't be resized" }
767
+ }
768
+ if (out.length <= IMAGE_LIMIT) return { format: t.format, bytes: out }
769
+ }
770
+ return { note: "it couldn't be made small enough" }
771
+ }
772
+
773
+ function fitOne(b64) {
774
+ const key = createHash("sha256").update(b64).digest("hex")
775
+ let p = fitted.get(key)
776
+ if (p) fitted.delete(key) // kept as the newest below
777
+ 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" }))
778
+ fitted.set(key, p)
779
+ while (fitted.size > FITTED_KEEP) fitted.delete(fitted.keys().next().value)
780
+ return p
781
+ }
782
+
783
+ // fitImages brings every image left in a built request under Kiro's limit,
784
+ // in place: the request is the plugin's own copy, never the caller's. An
785
+ // image already under it is sent as it is.
786
+ async function fitImages(out) {
787
+ const s = out.conversationState
788
+ const users = [...(s.history ?? []).map((e) => e.userInputMessage), s.currentMessage.userInputMessage].filter(Boolean)
789
+ for (const u of users) {
790
+ if (!u.images?.length) continue
791
+ const kept = []
792
+ const notes = []
793
+ for (const img of u.images) {
794
+ const b64 = img.source?.bytes
795
+ if (typeof b64 !== "string" || b64.length <= IMAGE_LIMIT) {
796
+ kept.push(img)
797
+ continue
798
+ }
799
+ const r = await fitOne(b64)
800
+ if (r.bytes) kept.push({ ...img, format: r.format, source: { ...img.source, bytes: r.bytes } })
801
+ else
802
+ notes.push(
803
+ `[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}.]`,
804
+ )
805
+ }
806
+ if (kept.length) u.images = kept
807
+ else delete u.images
808
+ if (notes.length) u.content = joinNonEmpty(u.content, notes.join("\n"))
809
+ }
810
+ return out
811
+ }
812
+
713
813
  const textOf = (c) =>
714
814
  typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n") : ""
715
815
  const joinNonEmpty = (a, b) => (a && b ? a + "\n\n" + b : a + b)
@@ -744,8 +844,19 @@ function formatText(req) {
744
844
  return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + JSON.stringify(f.schema) + "."
745
845
  }
746
846
 
747
- // buildKiro is the body of a generateAssistantResponse call.
847
+ // buildKiro is the body of a generateAssistantResponse call, as it is
848
+ // before its images are fitted (kiroBody).
748
849
  function buildKiro(req, model, profile, budget) {
850
+ return JSON.stringify(kiroRequest(req, model, profile, budget))
851
+ }
852
+
853
+ // kiroBody is the body sent: buildKiro's, with every image it keeps under
854
+ // Kiro's size limit.
855
+ async function kiroBody(req, model, profile, budget) {
856
+ return JSON.stringify(await fitImages(kiroRequest(req, model, profile, budget)))
857
+ }
858
+
859
+ function kiroRequest(req, model, profile, budget) {
749
860
  const entries = []
750
861
  // the messages the caller marked for its prompt cache (cache_control),
751
862
  // which go to Kiro as a cachePoint on the message they end up in (#110)
@@ -901,7 +1012,7 @@ function buildKiro(req, model, profile, budget) {
901
1012
  if (entries.length > 1) state.history = entries.slice(0, -1)
902
1013
  const out = { conversationState: state, agentMode: "vibe" }
903
1014
  if (profile) out.profileArn = profile
904
- return JSON.stringify(out)
1015
+ return out
905
1016
  }
906
1017
 
907
1018
  function sendKiro(a, body, signal) {
@@ -1342,7 +1453,7 @@ async function generate(creds, auth, req, signal) {
1342
1453
  }
1343
1454
  let res
1344
1455
  try {
1345
- res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1456
+ res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1346
1457
  } catch (e) {
1347
1458
  if (e?.name === "AbortError") throw e
1348
1459
  return errorResponse(502, e.message)
@@ -1355,7 +1466,7 @@ async function generate(creds, auth, req, signal) {
1355
1466
  return errorResponse(502, e.message)
1356
1467
  }
1357
1468
  try {
1358
- res = await sendKiro(a, buildKiro(req, model, a.profile, budget), signal)
1469
+ res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1359
1470
  } catch (e) {
1360
1471
  if (e?.name === "AbortError") throw e
1361
1472
  return errorResponse(502, e.message)
@@ -1634,4 +1745,4 @@ export async function KiroAuthPlugin({ client } = {}) {
1634
1745
  }
1635
1746
 
1636
1747
  // for tests
1637
- export const _internal = { generate, refresh, usageOf, buildKiro, events, reply, failure, toolID, toolName, toolNames, thinking, configModel, runtimeModel, readCLI, readIDE, regionOf, planName, frames }
1748
+ 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.18",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",