mycomesh-consumer 0.9.14 → 0.9.15

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.
@@ -27,7 +27,8 @@
27
27
  "tier_name": "OpenAI frontier",
28
28
  "capabilities": {
29
29
  "input": [
30
- "text"
30
+ "text",
31
+ "image"
31
32
  ],
32
33
  "output": [
33
34
  "text"
@@ -50,7 +51,8 @@
50
51
  "tier_name": "Anthropic Claude",
51
52
  "capabilities": {
52
53
  "input": [
53
- "text"
54
+ "text",
55
+ "image"
54
56
  ],
55
57
  "output": [
56
58
  "text"
@@ -73,7 +75,8 @@
73
75
  "tier_name": "Anthropic Claude",
74
76
  "capabilities": {
75
77
  "input": [
76
- "text"
78
+ "text",
79
+ "image"
77
80
  ],
78
81
  "output": [
79
82
  "text"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/consumer.mjs CHANGED
@@ -43,6 +43,20 @@ class Cached {
43
43
  }
44
44
  }
45
45
 
46
+ const MAX_REQUEST_BYTES = 7_500_000; // the sealed request must fit the network's 8 MB plaintext limit
47
+
48
+ /** What a request asks a backend to read besides text: "image", "file". */
49
+ export function inputKinds(content) {
50
+ const kinds = new Set();
51
+ for (const message of Array.isArray(content) ? content : []) {
52
+ for (const part of Array.isArray(message?.content) ? message.content : []) {
53
+ if (part?.type === "image_url" || part?.type === "input_image") kinds.add("image");
54
+ if (part?.type === "file" || part?.type === "input_file") kinds.add("file");
55
+ }
56
+ }
57
+ return [...kinds].sort();
58
+ }
59
+
46
60
  // Session affinity: a conversation goes back to the Provider that answered its last turn, whose upstream
47
61
  // account holds the conversation's prompt cache (measured: a new Codex thread with the same 10k-token prefix
48
62
  // read 9,728 tokens from cache on the same account, none on another).
@@ -397,6 +411,12 @@ export class Consumer {
397
411
  async attempt({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, session, onDelta }) {
398
412
  let lastError;
399
413
  let raisedFee = 0;
414
+ const size = Buffer.byteLength(JSON.stringify({ content, tools: options.tools ?? null }), "utf8");
415
+ if (size > MAX_REQUEST_BYTES) {
416
+ throw Object.assign(new Error(`this request is ${(size / 1e6).toFixed(1)} MB; the network carries up to ${MAX_REQUEST_BYTES / 1e6} MB (smaller or fewer images)`), { status: 413 });
417
+ }
418
+ const needs = inputKinds(content); // images and files go only to Providers whose backend reads them
419
+ let unreadable = false;
400
420
  const conversation = sessionKey({ endpoint, model, content, options, session });
401
421
  const preferred = this.stickyProvider(conversation);
402
422
  for (const relay of await this.relays()) {
@@ -404,6 +424,9 @@ export class Consumer {
404
424
  try {
405
425
  candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
406
426
  && (!provider || descriptor.provider_signer === provider.toLowerCase()));
427
+ const able = candidates.filter((descriptor) => needs.every((kind) => (descriptor.inputs || ["text"]).includes(kind)));
428
+ if (able.length < candidates.length && !able.length) unreadable = true;
429
+ candidates = able;
407
430
  }
408
431
  catch (error) { lastError = error; continue; }
409
432
  const records = await this.reputations({ waitMs: REPUTATION_WAIT_MS }).catch(() => ({}));
@@ -478,6 +501,9 @@ export class Consumer {
478
501
  if (reply.body?.dispatched !== false || ![502, 503].includes(reply.status)) throw lastError;
479
502
  }
480
503
  }
504
+ if (!lastError && unreadable) {
505
+ throw Object.assign(new Error(`no Provider online for ${model} reads ${needs.join(" and ")} input (Codex Providers read text and images, not files)`), { status: 400 });
506
+ }
481
507
  throw lastError || new Error(`no Provider serves model ${model}`);
482
508
  }
483
509
  }
package/src/dialects.mjs CHANGED
@@ -10,7 +10,50 @@ export class DialectError extends Error {
10
10
  constructor(message, status = 400) { super(message); this.status = status; }
11
11
  }
12
12
 
13
- /** Text of Anthropic or Gemini content: a string or text blocks/parts; images and documents become a note. */
13
+ /**
14
+ * Content in the network's parts (OpenAI Chat): a string when it is all text, else text, image_url (a data: or
15
+ * https URL) and file (a data: URL and its filename) parts. Anthropic blocks, Responses inputs and Gemini parts
16
+ * all land here.
17
+ */
18
+ export function parts(content, where) {
19
+ if (typeof content === "string" || content === undefined || content === null) return content ?? "";
20
+ if (!Array.isArray(content)) throw new DialectError(`${where}: content must be text`);
21
+ const out = [];
22
+ for (const part of content) {
23
+ const media = mediaPart(part);
24
+ if (media) out.push(media);
25
+ else { const words = text([part], where); if (words) out.push({ type: "text", text: words }); }
26
+ }
27
+ return out.every((p) => p.type === "text") ? out.map((p) => p.text).join("") : out;
28
+ }
29
+
30
+ const dataUrl = (mime, data) => `data:${mime || "application/octet-stream"};base64,${data}`;
31
+
32
+ /** An image or document block of any dialect as a network part; null for text and the rest. */
33
+ function mediaPart(part) {
34
+ if (!part || typeof part !== "object") return null;
35
+ if (part.type === "image" && part.source) { // Anthropic
36
+ const url = part.source.type === "base64" ? dataUrl(part.source.media_type, part.source.data) : part.source.url;
37
+ return url ? { type: "image_url", image_url: { url } } : null;
38
+ }
39
+ if (part.type === "document" && part.source?.type === "base64") {
40
+ return { type: "file", file: { filename: part.title || "document.pdf", file_data: dataUrl(part.source.media_type, part.source.data) } };
41
+ }
42
+ if (part.type === "input_image" && (part.image_url || part.url)) return { type: "image_url", image_url: { url: part.image_url || part.url } }; // Responses
43
+ if (part.type === "input_file" && part.file_data) {
44
+ return { type: "file", file: { filename: part.filename || "file", file_data: part.file_data.startsWith("data:") ? part.file_data : dataUrl("application/pdf", part.file_data) } };
45
+ }
46
+ if (part.type === "image_url" && part.image_url) return { type: "image_url", image_url: { url: part.image_url.url || part.image_url } }; // Chat
47
+ if (part.type === "file" && part.file) return { type: "file", file: part.file };
48
+ if (part.inlineData?.data) { // Gemini
49
+ const url = dataUrl(part.inlineData.mimeType, part.inlineData.data);
50
+ return part.inlineData.mimeType?.startsWith("image/") ? { type: "image_url", image_url: { url } }
51
+ : { type: "file", file: { filename: "file", file_data: url } };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** The text of content (images and documents become a note): for places that can hold only text. */
14
57
  function text(content, where) {
15
58
  if (typeof content === "string") return content;
16
59
  if (content === undefined || content === null) return "";
@@ -19,8 +62,9 @@ function text(content, where) {
19
62
  if (typeof part === "string") return part;
20
63
  if (part && typeof part.text === "string" && (part.type === undefined || part.type === "text")) return part.text;
21
64
  if (part?.type === "thinking" || part?.type === "redacted_thinking" || part?.thought) return "";
22
- if (part?.type === "image" || part?.inlineData || part?.type === "input_image") return "[image omitted: this network carries text only]";
23
- if (part?.type === "document") return part.source?.type === "text" ? String(part.source.data ?? "") : "[document omitted]";
65
+ if (part?.type === "image" || part?.inlineData || part?.type === "input_image" || part?.type === "image_url") return "[image]";
66
+ if (part?.type === "document") return part.source?.type === "text" ? String(part.source.data ?? "") : "[document]";
67
+ if (part?.type === "input_file" || part?.type === "file") return "[file]";
24
68
  if (part?.type === "input_text" || part?.type === "output_text") return String(part.text ?? "");
25
69
  throw new DialectError(`${where}: unsupported content (${part?.type || Object.keys(part || {})[0] || "unknown"})`);
26
70
  }).join("");
@@ -57,13 +101,20 @@ export function fromAnthropic(body) {
57
101
  messages.push({ role: "assistant", content: words || (calls.length ? null : ""), ...(calls.length ? { tool_calls: calls } : {}) });
58
102
  continue;
59
103
  }
60
- // A user turn: tool results answer the previous assistant's calls; the rest is what the user said.
104
+ // A user turn: tool results answer the previous assistant's calls; the rest is what the user said. A tool
105
+ // result can only be text, so images it returned (a screenshot Read) follow in a user message.
106
+ const shown = [];
61
107
  for (const block of blocks.filter((b) => b?.type === "tool_result")) {
62
- const result = text(block.content ?? "", "tool_result");
108
+ const content = Array.isArray(block.content) ? block.content : [{ type: "text", text: String(block.content ?? "") }];
109
+ const media = content.map(mediaPart).filter(Boolean);
110
+ const result = text(content.filter((b) => !mediaPart(b)), "tool_result") + (media.length ? `\n[${media.length} attachment(s) follow]` : "");
63
111
  messages.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.is_error ? `Error: ${result}` : result });
112
+ if (media.length) shown.push({ type: "text", text: `Attachments returned by tool call ${block.tool_use_id}:` }, ...media);
64
113
  }
65
114
  const rest = blocks.filter((b) => b?.type !== "tool_result");
66
- if (rest.length || !blocks.length) messages.push({ role: "user", content: text(rest, "user message") });
115
+ const said = rest.length ? parts(rest, "user message") : "";
116
+ if (shown.length) messages.push({ role: "user", content: [...shown, ...(typeof said === "string" ? (said ? [{ type: "text", text: said }] : []) : said)] });
117
+ else if (rest.length || !blocks.length) messages.push({ role: "user", content: said });
67
118
  }
68
119
  const options = {};
69
120
  for (const key of ["temperature", "top_p"]) if (body[key] !== undefined) options[key] = body[key];
@@ -190,16 +241,17 @@ export function fromResponses(body) {
190
241
  const type = item?.type || (item?.role ? "message" : "");
191
242
  if (type === "message") {
192
243
  const role = item.role === "developer" ? "system" : item.role;
193
- const words = text(item.content, `${role} message`);
194
- if (role === "assistant") { const a = assistant(); a.content = (a.content || "") + words; }
195
- else messages.push({ role, content: words });
244
+ if (role === "assistant") { const a = assistant(); a.content = (a.content || "") + text(item.content, "assistant message"); }
245
+ else messages.push({ role, content: role === "user" ? parts(item.content, "user message") : text(item.content, `${role} message`) });
196
246
  } else if (type === "function_call" || type === "custom_tool_call") {
197
247
  const a = assistant();
198
248
  (a.tool_calls ||= []).push({ id: item.call_id, type: "function", function: { name: item.name,
199
249
  arguments: type === "custom_tool_call" ? JSON.stringify({ input: item.input ?? "" }) : (item.arguments ?? "{}") } });
200
250
  } else if (type === "function_call_output" || type === "custom_tool_call_output") {
201
- const output = typeof item.output === "string" ? item.output : text(item.output, "tool output");
251
+ const media = Array.isArray(item.output) ? item.output.map(mediaPart).filter(Boolean) : [];
252
+ const output = typeof item.output === "string" ? item.output : text(item.output.filter((b) => !mediaPart(b)), "tool output");
202
253
  messages.push({ role: "tool", tool_call_id: item.call_id, content: output });
254
+ if (media.length) messages.push({ role: "user", content: [{ type: "text", text: `Attachments returned by tool call ${item.call_id}:` }, ...media] });
203
255
  } // reasoning items and the like stay with the client
204
256
  }
205
257
  for (const message of messages) if (message.role === "assistant" && !message.tool_calls?.length) delete message.tool_calls;
@@ -242,6 +294,8 @@ export function geminiRoute(path) {
242
294
  return match ? { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent", count: match[2] === "countTokens" } : null;
243
295
  }
244
296
 
297
+ const partsOf = (value, where) => parts(value, where);
298
+
245
299
  export function fromGemini(body, model) {
246
300
  if (!Array.isArray(body.contents)) throw new DialectError("contents are required");
247
301
  const messages = [];
@@ -255,7 +309,8 @@ export function fromGemini(body, model) {
255
309
  const parts = item.parts || [];
256
310
  const calls = parts.filter((p) => p.functionCall);
257
311
  const results = parts.filter((p) => p.functionResponse);
258
- const words = text(parts.filter((p) => !p.functionCall && !p.functionResponse), `${item.role || "user"} content`);
312
+ const said = parts.filter((p) => !p.functionCall && !p.functionResponse);
313
+ const words = item.role === "model" ? text(said, "model content") : partsOf(said, "user content");
259
314
  if (item.role === "model") {
260
315
  const toolCalls = calls.map((p) => {
261
316
  const call = { id: p.functionCall.id || `call_${counter++}`, type: "function",
@@ -266,13 +321,13 @@ export function fromGemini(body, model) {
266
321
  messages.push({ role: "assistant", content: words || (toolCalls.length ? null : ""), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) });
267
322
  continue;
268
323
  }
269
- for (const part of results) {
324
+ for (const part of results) { // results first: they answer the calls the model just made
270
325
  const index = open.findIndex((call) => call.function.name === part.functionResponse.name);
271
326
  const call = index >= 0 ? open.splice(index, 1)[0] : null;
272
327
  messages.push({ role: "tool", tool_call_id: part.functionResponse.id || call?.id || `call_${counter++}`,
273
328
  content: JSON.stringify(part.functionResponse.response ?? {}) });
274
329
  }
275
- if (words || !results.length) messages.push({ role: "user", content: words });
330
+ if ((typeof words === "string" ? words : words.length) || !results.length) messages.push({ role: "user", content: words });
276
331
  }
277
332
  const config = body.generationConfig || body.generation_config || {};
278
333
  const options = {};