@adaptiqai/deepseek-harness 0.2.12 → 0.2.13

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/lib/host.js CHANGED
@@ -54,7 +54,7 @@ import { attributionHeaders as attributionHeaders2 } from "@deepseek-ai/dsh-llm"
54
54
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
55
55
 
56
56
  // src/host/adapter.ts
57
- import { attributionHeaders, LlmAdapter, LlmError, ProviderRequestId } from "@deepseek-ai/dsh-llm";
57
+ import { attributionHeaders, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ProviderRequestId } from "@deepseek-ai/dsh-llm";
58
58
 
59
59
  // ../core/lib/errors.js
60
60
  var CODES = {
@@ -122,21 +122,26 @@ function classifyFailure(status, raw, headers, model2, options = {}) {
122
122
  const library = /\blibrary is full/iu.test(detail.message ?? "");
123
123
  return { ...make(CODES.memoryFull, library ? MEMORY_FULL_LIBRARY : MEMORY_FULL, "memory-full"), memoryFull: library ? "library" : "no-room" };
124
124
  }
125
+ if (detail.code === "plan_usage_exhausted") {
126
+ const renewsAt = envelope.detail?.renews_at;
127
+ const failure = make(CODES.noLanes, detail.message ?? "Your plan's usage is used up for now. Rent extra lanes in the meantime.", "no-lanes");
128
+ return typeof renewsAt === "number" ? { ...failure, planRenewsAt: renewsAt } : failure;
129
+ }
125
130
  if (effective === 402 || detail.code === "rental_paused") {
126
131
  const releaseAt = envelope.detail?.release_at;
127
132
  const minutes = typeof releaseAt === "number" ? Math.max(1, Math.ceil((releaseAt * 1e3 - Date.now()) / 6e4)) : null;
128
133
  const keep = minutes === null ? "to continue" : `within ${minutes} minute${minutes === 1 ? "" : "s"} to keep them`;
129
- const failure = make(CODES.outOfCredit, `Your balance ran out, so your lanes on ${name2} are paused. Add funds ${keep}.`, "out-of-credit");
134
+ const failure = make(CODES.outOfCredit, `Your balance ran out, so your lanes are paused. Add funds ${keep}.`, "out-of-credit");
130
135
  return minutes === null ? failure : { ...failure, minutesLeft: minutes };
131
136
  }
132
137
  if (effective === 401) {
133
138
  return make(CODES.signIn, "Your AdaptiqAI sign-in has expired. Open Settings \u2192 AdaptiqAI and sign in again.", "sign-in");
134
139
  }
135
140
  if (detail.code === "rental_stopped_unused") {
136
- return make(CODES.noLanes, `Your lane on ${name2} stopped because it went unused, so nothing more was charged. Get access to start it again.`, "no-lanes");
141
+ return make(CODES.noLanes, "Your lane stopped because it went unused, so nothing more was charged. Get access to start it again.", "no-lanes");
137
142
  }
138
143
  if (has(detail, "AdaptiqLaneAccessRequired", "inference_entitlement_required", "instance_access_required", "lane_access") || effective === 403 && has(detail, "lane", "entitle", "model access")) {
139
- return make(CODES.noLanes, `Your workspace has no lanes on ${name2} yet. Get access to start.`, "no-lanes");
144
+ return make(CODES.noLanes, "You need to rent a lane or subscribe to AdaptiqAI to continue.", "no-lanes");
140
145
  }
141
146
  if (effective === 403) {
142
147
  return make(CODES.signIn, "This AdaptiqAI connection isn't allowed to do that. Sign in again from Settings \u2192 AdaptiqAI.", "sign-in");
@@ -321,10 +326,10 @@ var REFRESH_MARGIN_MS = 2 * 6e4;
321
326
  var DEVICE_TIMEOUT_MS = 10 * 6e4;
322
327
  var UNREACHABLE = "AdaptiqAI can't be reached. Check your internet connection and try again.";
323
328
  function deviceLabel(value) {
324
- let label = value.replace(/\.local$/iu, "").replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/gu, " ").trim() || "this computer";
325
- while (Buffer.byteLength(label) > 80)
326
- label = label.slice(0, -1);
327
- return label;
329
+ let label2 = value.replace(/\.local$/iu, "").replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/gu, " ").trim() || "this computer";
330
+ while (Buffer.byteLength(label2) > 80)
331
+ label2 = label2.slice(0, -1);
332
+ return label2;
328
333
  }
329
334
  var defaultSleep = (ms, signal) => new Promise((resolve2, reject) => {
330
335
  if (signal.aborted) {
@@ -1274,15 +1279,19 @@ var en = {
1274
1279
  n_connected_body: "Your AdaptiqAI models are in the model picker.",
1275
1280
  n_compaction_title: "Nothing to compact",
1276
1281
  n_compaction_body: "AdaptiqAI keeps the whole conversation in memory, so nothing needs shortening.",
1282
+ n_imagesTextOnly_title: "Images were not sent",
1283
+ n_imagesTextOnly_body: "{model} reads text only, so the model got a short note in place of each image. Choose a model that reads images to ask about them.",
1277
1284
  n_signIn_title: "Sign in to keep going",
1278
1285
  n_signIn_body: "Your AdaptiqAI connection needs a fresh sign-in.",
1279
1286
  n_connect_body: "Connect your AdaptiqAI account to use this model.",
1280
- n_noLanes_title: "No lanes on {model}",
1281
- n_noLanes_body: "Your workspace has no lanes on {model} yet.",
1282
- n_laneStopped_body: "Your lane on {model} stopped because it went unused, so nothing more was charged.",
1287
+ n_noLanes_title: "Rent a lane to continue",
1288
+ n_noLanes_body: "You need to rent a lane or subscribe to AdaptiqAI to continue.",
1289
+ n_laneStopped_body: "Your lane stopped because it went unused, so nothing more was charged.",
1290
+ n_planUsedUp_body: "Your plan's usage is used up until {until}. Rent extra lanes in the meantime.",
1291
+ n_planUsedUpNoDate_body: "Your plan's usage is used up for now. Rent extra lanes in the meantime.",
1283
1292
  n_credit_title: "Add funds to continue",
1284
- n_credit_body: "Your balance ran out, so your lanes on {model} are paused.",
1285
- n_creditMinutes_body: "Your balance ran out, so your lanes on {model} are paused. Add funds within {minutes} min to keep them.",
1293
+ n_credit_body: "Your balance ran out, so your lanes are paused.",
1294
+ n_creditMinutes_body: "Your balance ran out, so your lanes are paused. Add funds within {minutes} min to keep them.",
1286
1295
  n_busy_title: "All lanes are busy",
1287
1296
  n_busy_body: "Your request on {model} is waiting for a free lane and will retry by itself.",
1288
1297
  n_preparing_title: "Getting your conversation ready",
@@ -1674,8 +1683,8 @@ var str4 = (value) => typeof value === "string" && value.trim() !== "" ? value.t
1674
1683
  function say(title, body, params = {}) {
1675
1684
  return { title: fill(en[title], params), body: body === null ? "" : fill(en[body], params), text: { title, body, params } };
1676
1685
  }
1677
- function button(id, label, extra = {}) {
1678
- return { id, label: en[label], labelKey: label, ...extra };
1686
+ function button(id, label2, extra = {}) {
1687
+ return { id, label: en[label2], labelKey: label2, ...extra };
1679
1688
  }
1680
1689
  var count2 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1681
1690
  var PRIVATE_HELD = "A private conversation is not kept on AdaptiqAI, so it can't use this before it exists there. Turn Remember on for this conversation to add it.";
@@ -1842,7 +1851,7 @@ function copyFailure(error, model2) {
1842
1851
  case "model_mismatch":
1843
1852
  return str4(detail.message) ?? `This collection can't be made available for ${model2}.`;
1844
1853
  case "inference_entitlement_required":
1845
- return `Your workspace has no lanes on ${model2}.`;
1854
+ return "You need to rent a lane or subscribe to AdaptiqAI to continue.";
1846
1855
  case "collection_not_found":
1847
1856
  return "This collection no longer exists.";
1848
1857
  case "sources_not_exportable":
@@ -2733,13 +2742,13 @@ var Workspace = class {
2733
2742
  this.publish();
2734
2743
  }
2735
2744
  /** A browser approval completed. */
2736
- signedIn(label) {
2745
+ signedIn(label2) {
2737
2746
  if (this.state.defaultModel === void 0) {
2738
2747
  this.state = { ...this.state, defaultModel: "pending" };
2739
2748
  void this.persist();
2740
2749
  }
2741
2750
  this.clearNotices("sign-in");
2742
- this.raise({ kind: "connected", tone: "success", ...say("connectedTo", "n_connected_body", { workspace: label }), actions: [] });
2751
+ this.raise({ kind: "connected", tone: "success", ...say("connectedTo", "n_connected_body", { workspace: label2 }), actions: [] });
2743
2752
  this.summaryMode = "unknown";
2744
2753
  this.summaryMissingAt = null;
2745
2754
  void this.refresh();
@@ -3325,6 +3334,14 @@ var Workspace = class {
3325
3334
  actions: [button("open-console", "n_openConsole", { url: this.links.console, primary: true })]
3326
3335
  });
3327
3336
  }
3337
+ /**
3338
+ * Say that a message's images were not sent because the model reads text only.
3339
+ * @param sessionId - the conversation, when known.
3340
+ * @param modelName - the model's display name.
3341
+ */
3342
+ imagesNotSent(sessionId2, modelName) {
3343
+ this.raise({ kind: "images", tone: "warning", ...say("n_imagesTextOnly_title", "n_imagesTextOnly_body", { model: modelName }), actions: [], ...sessionId2 === void 0 ? {} : { sessionId: sessionId2 } });
3344
+ }
3328
3345
  failed(sessionId2, model2, failure) {
3329
3346
  if (failure.code === CODES.compaction) {
3330
3347
  this.raise({ kind: "compaction", tone: "info", ...say("n_compaction_title", "n_compaction_body"), actions: [], ...sessionId2 === void 0 ? {} : { sessionId: sessionId2 } });
@@ -3366,11 +3383,13 @@ var Workspace = class {
3366
3383
  break;
3367
3384
  case "no-lanes": {
3368
3385
  const offer = this.offers.find((item) => item.model === model2) ?? this.offers.find((item) => item.name.toLowerCase() === name2.toLowerCase());
3386
+ const body = failure.serverCode === "rental_stopped_unused" ? "n_laneStopped_body" : failure.planRenewsAt !== void 0 ? "n_planUsedUp_body" : failure.serverCode === "plan_usage_exhausted" ? "n_planUsedUpNoDate_body" : "n_noLanes_body";
3387
+ const until = failure.planRenewsAt === void 0 ? "" : `${new Date(failure.planRenewsAt * 1e3).toISOString().slice(0, 16).replace("T", " ")} UTC`;
3369
3388
  this.raise({
3370
3389
  kind: "no-lanes",
3371
3390
  tone: "warning",
3372
3391
  modal: true,
3373
- ...say("n_noLanes_title", failure.serverCode === "rental_stopped_unused" ? "n_laneStopped_body" : "n_noLanes_body", { model: name2 }),
3392
+ ...say("n_noLanes_title", body, { until }),
3374
3393
  actions: [button("get-access", "getAccess", { primary: true, ...offer === void 0 ? {} : { offerId: offer.id } }), button("dismiss", "notNow")]
3375
3394
  });
3376
3395
  break;
@@ -3380,7 +3399,7 @@ var Workspace = class {
3380
3399
  kind: "out-of-credit",
3381
3400
  tone: "error",
3382
3401
  modal: true,
3383
- ...failure.minutesLeft === void 0 ? say("n_credit_title", "n_credit_body", { model: name2 }) : say("n_credit_title", "n_creditMinutes_body", { model: name2, minutes: String(failure.minutesLeft) }),
3402
+ ...failure.minutesLeft === void 0 ? say("n_credit_title", "n_credit_body") : say("n_credit_title", "n_creditMinutes_body", { minutes: String(failure.minutesLeft) }),
3384
3403
  actions: [button("add-funds", "n_addFunds", { url: this.links.models, primary: true }), button("dismiss", "notNow")]
3385
3404
  });
3386
3405
  break;
@@ -3560,7 +3579,7 @@ var Workspace = class {
3560
3579
  if (target === void 0)
3561
3580
  throw new Error("Choose an AdaptiqAI model first.");
3562
3581
  const modelName = this.modelName(target);
3563
- const label = modelName ?? target;
3582
+ const label2 = modelName ?? target;
3564
3583
  let body;
3565
3584
  try {
3566
3585
  body = await this.deps.api.json(`/v1/memories/${encodeURIComponent(id)}/copies`, {
@@ -3576,7 +3595,7 @@ var Workspace = class {
3576
3595
  this.copies.set(copyKey(id, target), { source: id, from, target, copy: null, status: "building" });
3577
3596
  return { status: "building", model: target, modelName };
3578
3597
  }
3579
- throw new Error(copyFailure(error, label));
3598
+ throw new Error(copyFailure(error, label2));
3580
3599
  }
3581
3600
  const copy = obj4(body?.copy);
3582
3601
  const status = copyStatus(str4(copy?.status));
@@ -4671,7 +4690,70 @@ async function* translate(events, hooks) {
4671
4690
  };
4672
4691
  }
4673
4692
 
4693
+ // src/host/images.ts
4694
+ var ENGINE_IMAGE_LIMITS = Object.freeze({
4695
+ maxImages: 8,
4696
+ maxImageBytes: 20 * 1024 * 1024,
4697
+ maxPixels: 16e6
4698
+ });
4699
+ var ImageProblem = class extends Error {
4700
+ name = "ImageProblem";
4701
+ };
4702
+ var startsWith = (data, bytes, at = 0) => data.length >= at + bytes.length && bytes.every((byte, index) => data[at + index] === byte);
4703
+ var ascii = (text2) => [...text2].map((char) => char.charCodeAt(0));
4704
+ function sniffImageType(data) {
4705
+ if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
4706
+ if (startsWith(data, [255, 216, 255])) return "image/jpeg";
4707
+ if (startsWith(data, ascii("GIF87a")) || startsWith(data, ascii("GIF89a"))) return "image/gif";
4708
+ if (startsWith(data, ascii("RIFF")) && startsWith(data, ascii("WEBP"), 8)) return "image/webp";
4709
+ return null;
4710
+ }
4711
+ function fitPixels(width, height, maxPixels) {
4712
+ if (width * height <= maxPixels) return { width, height };
4713
+ const scale = Math.sqrt(maxPixels / (width * height));
4714
+ let w = Math.max(1, Math.floor(width * scale));
4715
+ let h = Math.max(1, Math.floor(height * scale));
4716
+ while (w * h > maxPixels) {
4717
+ if (w >= h) w--;
4718
+ else h--;
4719
+ }
4720
+ return { width: w, height: h };
4721
+ }
4722
+ var carriesImages = (message) => message.role === "user" || message.role === "tool";
4723
+ function sentImages(messages) {
4724
+ const out = [];
4725
+ for (const message of messages) {
4726
+ if (!carriesImages(message)) continue;
4727
+ for (const block of message.content) if (block.type === "image" && block.offloaded !== true) out.push(block);
4728
+ }
4729
+ return out;
4730
+ }
4731
+ function hasImages(messages) {
4732
+ return messages.some((message) => message.content.some((block) => block.type === "image"));
4733
+ }
4734
+ var mib = (bytes) => `${Math.round(bytes / (1024 * 1024) * 10) / 10} MB`;
4735
+ var label = (ref) => ref.name === void 0 ? "An image" : `The image "${ref.name}"`;
4736
+ async function prepareImages(messages, source, limits, signal) {
4737
+ const refs = /* @__PURE__ */ new Map();
4738
+ for (const block of sentImages(messages)) refs.set(String(block.attachment.attachmentId), block.attachment);
4739
+ const entries = await Promise.all([...refs].map(async ([id, ref]) => {
4740
+ const target = { ...fitPixels(ref.width, ref.height, limits.maxPixels), maxBytes: limits.maxImageBytes };
4741
+ const version = await source.readImageRequest(ref, target, signal);
4742
+ const mediaType = sniffImageType(version.data);
4743
+ if (mediaType === null) throw new ImageProblem(`${label(ref)} is not a PNG, JPEG, WebP or GIF file, so AdaptiqAI can't read it. Attach it in one of those formats.`);
4744
+ if (version.data.byteLength > limits.maxImageBytes) {
4745
+ throw new ImageProblem(`${label(ref)} is ${mib(version.data.byteLength)}; AdaptiqAI takes images up to ${mib(limits.maxImageBytes)}. Attach a smaller copy.`);
4746
+ }
4747
+ if (version.width * version.height > limits.maxPixels) {
4748
+ throw new ImageProblem(`${label(ref)} is ${version.width}\xD7${version.height} pixels, more than AdaptiqAI takes. Attach a smaller copy.`);
4749
+ }
4750
+ return [id, { url: `data:${mediaType};base64,${Buffer.from(version.data).toString("base64")}`, width: version.width, height: version.height }];
4751
+ }));
4752
+ return new Map(entries);
4753
+ }
4754
+
4674
4755
  // src/host/wire.ts
4756
+ import { offloadedImageText, requestImageHandleText, textOnlyImageText } from "@deepseek-ai/dsh-llm";
4675
4757
  function sessionReferences(message) {
4676
4758
  if (message.role !== "user") return null;
4677
4759
  const source = message.source;
@@ -4763,16 +4845,62 @@ function userTurns(messages) {
4763
4845
  }
4764
4846
  var text = (blocks) => blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
4765
4847
  var reasoning = (blocks) => blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
4848
+ var Parts = class {
4849
+ parts = [];
4850
+ glue = false;
4851
+ text(value, own = false) {
4852
+ if (value === "") return;
4853
+ const last = this.parts.at(-1);
4854
+ if (last?.type === "text") last.text += (own || this.glue ? "\n" : "") + value;
4855
+ else this.parts.push({ type: "text", text: value });
4856
+ this.glue = own;
4857
+ }
4858
+ image(url) {
4859
+ this.parts.push({ type: "image_url", image_url: { url } });
4860
+ this.glue = false;
4861
+ }
4862
+ get hasImage() {
4863
+ return this.parts.some((part) => part.type === "image_url");
4864
+ }
4865
+ get joined() {
4866
+ return this.parts.map((part) => part.type === "text" ? part.text : "").join("");
4867
+ }
4868
+ };
4869
+ function putImage(block, images, parts, inline) {
4870
+ const access2 = images?.access?.(block.attachment);
4871
+ if (block.offloaded === true) {
4872
+ parts.text(offloadedImageText(block.attachment, access2), true);
4873
+ return null;
4874
+ }
4875
+ const sent = images?.sent?.get(String(block.attachment.attachmentId));
4876
+ if (sent === void 0) {
4877
+ parts.text(textOnlyImageText(block.attachment), true);
4878
+ return null;
4879
+ }
4880
+ parts.text(requestImageHandleText(block.attachment, sent, access2), true);
4881
+ if (!inline) return sent;
4882
+ parts.image(sent.url);
4883
+ return null;
4884
+ }
4766
4885
  function replayField(message) {
4767
4886
  const source = message.source;
4768
4887
  const state2 = source?.replayState;
4769
4888
  const field = state2?.response?.reasoningField;
4770
4889
  return typeof field === "string" && /^[a-z_]{1,32}$/u.test(field) ? field : "reasoning_content";
4771
4890
  }
4772
- function wireMessages(options, recalled = /* @__PURE__ */ new Map()) {
4891
+ function wireMessages(options, recalled = /* @__PURE__ */ new Map(), images) {
4773
4892
  const out = [];
4774
4893
  if (options.system !== void 0 && options.system !== "") out.push({ role: "system", content: options.system });
4894
+ let toolImages = [];
4895
+ const flushToolImages = () => {
4896
+ if (toolImages.length === 0) return;
4897
+ const parts = [{ type: "text", text: toolImages.length === 1 ? "The image from the tool result above:" : "The images from the tool results above, in order:" }];
4898
+ for (const image of toolImages) parts.push({ type: "image_url", image_url: { url: image.url } });
4899
+ out.push({ role: "user", content: parts });
4900
+ toolImages = [];
4901
+ };
4775
4902
  options.messages.forEach((message, index) => {
4903
+ if (message.role !== "tool") flushToolImages();
4776
4904
  switch (message.role) {
4777
4905
  case "system": {
4778
4906
  const content = text(message.content);
@@ -4784,6 +4912,15 @@ function wireMessages(options, recalled = /* @__PURE__ */ new Map()) {
4784
4912
  return;
4785
4913
  }
4786
4914
  case "user": {
4915
+ if (message.content.some((block) => block.type === "image")) {
4916
+ const parts = new Parts();
4917
+ for (const block of message.content) {
4918
+ if (block.type === "text") parts.text(block.text);
4919
+ else if (block.type === "image") putImage(block, images, parts, true);
4920
+ }
4921
+ out.push({ role: "user", content: parts.hasImage ? parts.parts : parts.joined });
4922
+ return;
4923
+ }
4787
4924
  const references = recalled.size === 0 ? null : sessionReferences(message);
4788
4925
  const content = text(message.content);
4789
4926
  out.push({ role: "user", content: references === null ? content : recallSnapshot(content, references, recalled) });
@@ -4805,7 +4942,15 @@ function wireMessages(options, recalled = /* @__PURE__ */ new Map()) {
4805
4942
  return;
4806
4943
  }
4807
4944
  case "tool": {
4808
- const content = text(message.content);
4945
+ const parts = new Parts();
4946
+ for (const block of message.content) {
4947
+ if (block.type === "text") parts.text(block.text);
4948
+ else if (block.type === "image") {
4949
+ const deferred = putImage(block, images, parts, false);
4950
+ if (deferred !== null) toolImages.push(deferred);
4951
+ }
4952
+ }
4953
+ const content = parts.joined;
4809
4954
  out.push({ role: "tool", tool_call_id: message.toolCallId, content: content === "" ? "(no output)" : content });
4810
4955
  return;
4811
4956
  }
@@ -4813,12 +4958,13 @@ function wireMessages(options, recalled = /* @__PURE__ */ new Map()) {
4813
4958
  return;
4814
4959
  }
4815
4960
  });
4961
+ flushToolImages();
4816
4962
  return out;
4817
4963
  }
4818
4964
  function buildChatBody(options, model2, extras) {
4819
4965
  const body = {
4820
4966
  model: options.model,
4821
- messages: wireMessages(options, extras.recalled),
4967
+ messages: wireMessages(options, extras.recalled, extras.images),
4822
4968
  stream: true,
4823
4969
  stream_options: { include_usage: extras.includeUsage ?? true }
4824
4970
  };
@@ -4970,6 +5116,41 @@ var AdaptiqAdapter = class extends LlmAdapter {
4970
5116
  stream(options) {
4971
5117
  return this.generate(options);
4972
5118
  }
5119
+ /**
5120
+ * The request's images, read from the harness's stored copies, when the
5121
+ * model's card says it reads images. Otherwise each image goes as a short
5122
+ * note and the person is told.
5123
+ */
5124
+ async images(options, model2, sessionId2, fail2, signal) {
5125
+ if (!hasImages(options.messages)) return void 0;
5126
+ const access2 = this.deps.imageAccess === void 0 ? void 0 : (ref) => {
5127
+ try {
5128
+ return this.deps.imageAccess?.(ref);
5129
+ } catch {
5130
+ return void 0;
5131
+ }
5132
+ };
5133
+ const withAccess = access2 === void 0 ? {} : { access: access2 };
5134
+ const problem = (message) => fail2({ code: CODES.invalidRequest, message, status: null, requestId: null, retryAfterMs: null, serverType: null, serverCode: null, notice: null });
5135
+ if (model2?.input.includes("image") !== true) {
5136
+ if (sentImages(options.messages).length > 0) this.deps.events.imagesNotSent?.(sessionId2, model2?.name ?? options.model);
5137
+ return { sent: null, ...withAccess };
5138
+ }
5139
+ const limits = this.deps.imageLimits ?? ENGINE_IMAGE_LIMITS;
5140
+ const excess = sentImages(options.messages).length - limits.maxImages;
5141
+ if (excess > 0) {
5142
+ throw new LlmError(`AdaptiqAI takes up to ${limits.maxImages} images per request; the ${excess} oldest ${excess === 1 ? "one goes" : "go"} as a note naming ${excess === 1 ? "it" : "them"}.`, IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: excess });
5143
+ }
5144
+ const source = this.deps.attachments?.();
5145
+ if (source === void 0) throw problem("This DeepSeek Harness has no attachment service, so images can't be sent. Update DeepSeek Harness and try again.");
5146
+ try {
5147
+ return { sent: await prepareImages(options.messages, source, limits, signal), ...withAccess };
5148
+ } catch (error) {
5149
+ if (error instanceof ImageProblem) throw problem(error.message);
5150
+ if (signal.aborted) throw new LlmError("Request stopped.", CODES.aborted, { cause: error });
5151
+ throw problem(`An attached image could not be read: ${error instanceof Error ? error.message : String(error)}. Attach it again.`);
5152
+ }
5153
+ }
4973
5154
  async *generate(options) {
4974
5155
  if (options.purpose === "compaction") {
4975
5156
  const message = "AdaptiqAI keeps the whole conversation in native memory, so it never needs compacting. Nothing was removed.";
@@ -5044,7 +5225,8 @@ var AdaptiqAdapter = class extends LlmAdapter {
5044
5225
  if (references.length > 0) recalled = await this.deps.recall(sessionId2, options.model, references, signal).catch(() => NOTHING_RECALLED);
5045
5226
  }
5046
5227
  const parent = options.sessionId === void 0 ? void 0 : this.deps.parentSession?.(String(options.sessionId));
5047
- let body = JSON.stringify(buildChatBody(options, model2, { remember, stored, recalled }));
5228
+ const images = await this.images(options, model2, sessionId2, fail2, signal);
5229
+ let body = JSON.stringify(buildChatBody(options, model2, { remember, stored, recalled, images }));
5048
5230
  this.deps.debug?.(`adaptiq: request ${sessionId2 ?? "-"}: ${options.messages.length} messages, ${body.length} bytes, ${recalled.size} read from memory, transcripts sent: ${body.includes("<referenced-sessions>") ? "yes" : "no"}`);
5049
5231
  let response;
5050
5232
  let refreshed = false;
@@ -5085,7 +5267,7 @@ var AdaptiqAdapter = class extends LlmAdapter {
5085
5267
  await response.body?.cancel().catch(() => {
5086
5268
  });
5087
5269
  stored = false;
5088
- body = JSON.stringify(buildChatBody(options, model2, { remember, stored, recalled }));
5270
+ body = JSON.stringify(buildChatBody(options, model2, { remember, stored, recalled, images }));
5089
5271
  continue;
5090
5272
  }
5091
5273
  break;
@@ -6091,7 +6273,7 @@ Open **Settings \u2192 AdaptiqAI** to connect, or run \`/adaptiq signin\`.
6091
6273
  var ENTRY_PROTOCOL = 1;
6092
6274
  var RELOAD_SIGNATURE = true ? "ada3dfffa1b3d7b1" : "source";
6093
6275
  function pluginVersion() {
6094
- if (true) return "0.2.12";
6276
+ if (true) return "0.2.13";
6095
6277
  try {
6096
6278
  return createRequire(import.meta.url)("../package.json").version;
6097
6279
  } catch {
@@ -6114,14 +6296,25 @@ async function harnessSessions(ctx) {
6114
6296
  return new Map(records.map((record) => {
6115
6297
  let title = null;
6116
6298
  try {
6117
- const label = typeof labels?.projectedLabels === "function" ? labels.projectedLabels(record).label : void 0;
6118
- if (typeof label === "string" && label.trim() !== "" && label !== record.header.id) title = label.trim();
6299
+ const label2 = typeof labels?.projectedLabels === "function" ? labels.projectedLabels(record).label : void 0;
6300
+ if (typeof label2 === "string" && label2.trim() !== "" && label2 !== record.header.id) title = label2.trim();
6119
6301
  } catch {
6120
6302
  }
6121
6303
  const subagent = record.header.origin === "subagent" || (record.header.delegationDepth ?? 0) > 0;
6122
6304
  return [record.header.id, { title, subagent }];
6123
6305
  }));
6124
6306
  }
6307
+ function attachmentService(ctx) {
6308
+ const service = ctx.get("attachments");
6309
+ return typeof service?.readImageRequest === "function" ? service : void 0;
6310
+ }
6311
+ function imageAccess(ctx, ref) {
6312
+ const host = attachmentService(ctx)?.imageHostPath?.(ref);
6313
+ if (host === void 0) return void 0;
6314
+ const fs = ctx.get("fs");
6315
+ const readonlyPath = fs?.processPathFromHostPath?.(host);
6316
+ return readonlyPath === void 0 ? void 0 : { readonlyPath };
6317
+ }
6125
6318
  var VERSION = pluginVersion();
6126
6319
  async function apply(ctx, config) {
6127
6320
  await applyWith(ctx, config, void 0);
@@ -6146,8 +6339,8 @@ async function applyWith(ctx, raw, runtime) {
6146
6339
  onChange: () => {
6147
6340
  workspace?.authChanged();
6148
6341
  },
6149
- onSignedIn: (label) => {
6150
- workspace?.signedIn(label);
6342
+ onSignedIn: (label2) => {
6343
+ workspace?.signedIn(label2);
6151
6344
  }
6152
6345
  });
6153
6346
  const api = new AdaptiqApi(apiBase, auth, () => ({
@@ -6229,6 +6422,8 @@ async function applyWith(ctx, raw, runtime) {
6229
6422
  attachPending: (sessionId2) => owned.attachPending(sessionId2),
6230
6423
  events: owned,
6231
6424
  autoSleep: (model2) => owned.autoSleep(model2),
6425
+ attachments: () => attachmentService(ctx),
6426
+ imageAccess: (ref) => imageAccess(ctx, ref),
6232
6427
  ...process.env.DSH_ADAPTIQ_DEBUG_LOG === void 0 || process.env.DSH_ADAPTIQ_DEBUG_LOG === "" ? {} : { debug: (message) => {
6233
6428
  log("info", message);
6234
6429
  } }
@@ -7,6 +7,7 @@
7
7
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
8
8
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, PreparedAdapterCall, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm';
9
9
  import { type AdaptiqFailure, type AuthManager, type CatalogModel, type RequestEvents, type UserTurn } from '@adaptiqai/core';
10
+ import { type ImageLimits, type ImageRef, type ImageSource } from './images.ts';
10
11
  import { type ParentSession, type ReferencedSession } from './wire.ts';
11
12
  /** What the adapter tells the rest of the plugin about each request. */
12
13
  export interface AdapterDeps {
@@ -42,6 +43,14 @@ export interface AdapterDeps {
42
43
  readonly events: RequestEvents;
43
44
  /** Whether the server behind a model puts idle conversations to sleep (it reports session history apart). */
44
45
  readonly autoSleep?: (model: string) => boolean;
46
+ /** The harness's attachment service, where attached images are stored (absent on hosts without one). */
47
+ attachments?(): ImageSource | undefined;
48
+ /** Where the model's tools can read the stored copy of an image. */
49
+ imageAccess?(ref: ImageRef): {
50
+ readonlyPath: string;
51
+ } | undefined;
52
+ /** The engine's image limits (tests use smaller ones). */
53
+ readonly imageLimits?: ImageLimits;
45
54
  /** Diagnostics for one request (no content), when a debug log is on. */
46
55
  debug?(message: string): void;
47
56
  readonly fetch?: typeof fetch;
@@ -78,5 +87,11 @@ export declare class AdaptiqAdapter extends LlmAdapter {
78
87
  resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
79
88
  prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;
80
89
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
90
+ /**
91
+ * The request's images, read from the harness's stored copies, when the
92
+ * model's card says it reads images. Otherwise each image goes as a short
93
+ * note and the person is told.
94
+ */
95
+ private images;
81
96
  private generate;
82
97
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Images a person attached, as inline data the AdaptiqAI API accepts.
3
+ *
4
+ * The harness stores each attached image once, content-addressed (sha256), and
5
+ * hands the adapter a durable reference. The bytes sent are read from that
6
+ * stored copy, never from the file the person picked, so a history image is the
7
+ * same bytes on every turn (the engine dedupes by content hash) even when the
8
+ * original file has changed since.
9
+ */
10
+ import type { ImageBlock, RequestMessage } from '@deepseek-ai/dsh-llm';
11
+ /** One image reference as the harness stores it. */
12
+ export type ImageRef = ImageBlock['attachment'];
13
+ /** Image media types the engine accepts. */
14
+ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
15
+ /** What the engine accepts per request. */
16
+ export interface ImageLimits {
17
+ /** Image parts in one request (history included). */
18
+ readonly maxImages: number;
19
+ /** Encoded bytes of one image. */
20
+ readonly maxImageBytes: number;
21
+ /** Width × height of one image; larger ones are scaled down before sending. */
22
+ readonly maxPixels: number;
23
+ }
24
+ /** The engine's limits behind api.adaptiqai.com (8 images, 20 MiB and 16.8 MP each); pixels kept a little under. */
25
+ export declare const ENGINE_IMAGE_LIMITS: ImageLimits;
26
+ /** The request version the harness's attachment service makes of one stored image. */
27
+ export interface RequestImageVersion {
28
+ readonly data: Uint8Array;
29
+ readonly mediaType: string;
30
+ readonly width: number;
31
+ readonly height: number;
32
+ }
33
+ /** The part of the harness's attachment service (`ctx.attachments`) the adapter reads images through. */
34
+ export interface ImageSource {
35
+ readImageRequest(ref: ImageRef, target: {
36
+ width: number;
37
+ height: number;
38
+ maxBytes: number;
39
+ }, signal?: AbortSignal): Promise<RequestImageVersion>;
40
+ }
41
+ /** One image ready for the wire. */
42
+ export interface WireImage {
43
+ /** `data:<mime>;base64,...` */
44
+ readonly url: string;
45
+ readonly width: number;
46
+ readonly height: number;
47
+ }
48
+ /** An image the engine would refuse, said in plain words. */
49
+ export declare class ImageProblem extends Error {
50
+ readonly name = "ImageProblem";
51
+ }
52
+ /**
53
+ * The image format, read from the bytes themselves.
54
+ * @param data - encoded image bytes.
55
+ * @returns the media type, or null when the bytes are no PNG, JPEG, WebP or GIF.
56
+ */
57
+ export declare function sniffImageType(data: Uint8Array): ImageMediaType | null;
58
+ /**
59
+ * The size to send one image at: its own, or scaled down (aspect kept) to fit the pixel limit.
60
+ * @param width - stored width.
61
+ * @param height - stored height.
62
+ * @param maxPixels - width × height limit.
63
+ * @returns target width and height.
64
+ */
65
+ export declare function fitPixels(width: number, height: number, maxPixels: number): {
66
+ width: number;
67
+ height: number;
68
+ };
69
+ /**
70
+ * The images a request sends (not offloaded by the harness), one entry per occurrence, oldest first.
71
+ * @param messages - the request history.
72
+ * @returns image blocks in request order.
73
+ */
74
+ export declare function sentImages(messages: readonly RequestMessage[]): ImageBlock[];
75
+ /**
76
+ * Whether any message of the request carries an image.
77
+ * @param messages - the request history.
78
+ * @returns true when one does.
79
+ */
80
+ export declare function hasImages(messages: readonly RequestMessage[]): boolean;
81
+ /**
82
+ * Read every image the request sends from the harness's stored copies, check
83
+ * it against the engine's limits and encode it as a data URL.
84
+ * @param messages - the request history.
85
+ * @param source - the harness's attachment service.
86
+ * @param limits - the engine's limits.
87
+ * @param signal - cancellation.
88
+ * @returns attachment id → wire image.
89
+ * @throws ImageProblem when an image is not one the engine accepts.
90
+ */
91
+ export declare function prepareImages(messages: readonly RequestMessage[], source: ImageSource, limits: ImageLimits, signal?: AbortSignal): Promise<Map<string, WireImage>>;
@@ -1,17 +1,13 @@
1
- /**
2
- * Harness request → OpenAI Chat Completions body. Model-specific behaviour
3
- * (thinking field and values, sampling defaults) comes only from the
4
- * model's catalog entry.
5
- */
6
1
  import type { GenerateOptions, RequestMessage } from '@deepseek-ai/dsh-llm';
7
2
  import { type CatalogModel, type SessionRole, type UserTurn } from '@adaptiqai/core';
3
+ import type { ImageRef, WireImage } from './images.ts';
8
4
  /** One Chat Completions message. */
9
5
  export type WireMessage = {
10
6
  role: 'system';
11
7
  content: string;
12
8
  } | {
13
9
  role: 'user';
14
- content: string;
10
+ content: string | WirePart[];
15
11
  } | {
16
12
  role: 'assistant';
17
13
  content: string | null;
@@ -22,6 +18,25 @@ export type WireMessage = {
22
18
  tool_call_id: string;
23
19
  content: string;
24
20
  };
21
+ /** One part of a user message that carries images, in the person's order. */
22
+ export type WirePart = {
23
+ type: 'text';
24
+ text: string;
25
+ } | {
26
+ type: 'image_url';
27
+ image_url: {
28
+ url: string;
29
+ };
30
+ };
31
+ /** How the images of a request go on the wire. */
32
+ export interface WireImages {
33
+ /** Attachment id → inline image; null when the model reads text only (each image becomes a short note). */
34
+ readonly sent: ReadonlyMap<string, WireImage> | null;
35
+ /** Where the model's tools can read the harness's stored copy of an image. */
36
+ readonly access?: (ref: ImageRef) => {
37
+ readonlyPath: string;
38
+ } | undefined;
39
+ }
25
40
  export interface WireToolCall {
26
41
  id: string;
27
42
  type: 'function';
@@ -40,6 +55,8 @@ export interface WireExtras {
40
55
  readonly includeUsage?: boolean;
41
56
  /** Referenced sessions attached to this conversation and ready, by session id → name. */
42
57
  readonly recalled?: ReadonlyMap<string, string>;
58
+ /** The request's images; without it every image becomes a short note. */
59
+ readonly images?: WireImages;
43
60
  }
44
61
  /** One session another message mentioned with `@`, as the harness snapshot records it. */
45
62
  export interface ReferencedSession {
@@ -94,9 +111,10 @@ export declare function userTurns(messages: readonly RequestMessage[]): UserTurn
94
111
  * Convert the harness history to Chat Completions messages.
95
112
  * @param options - the harness request.
96
113
  * @param recalled - referenced sessions read from memory: their snapshots become one line.
114
+ * @param images - the request's images; without it each image becomes a short note.
97
115
  * @returns the wire messages in order.
98
116
  */
99
- export declare function wireMessages(options: Pick<GenerateOptions, 'messages' | 'system'>, recalled?: ReadonlyMap<string, string>): WireMessage[];
117
+ export declare function wireMessages(options: Pick<GenerateOptions, 'messages' | 'system'>, recalled?: ReadonlyMap<string, string>, images?: WireImages): WireMessage[];
100
118
  /**
101
119
  * Build the request body for one call.
102
120
  * @param options - the harness request.