smoltalk 0.6.0 → 0.7.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.
Files changed (55) hide show
  1. package/dist/classes/message/UserMessage.d.ts +66 -5
  2. package/dist/classes/message/UserMessage.js +100 -21
  3. package/dist/classes/message/contentParts.d.ts +259 -0
  4. package/dist/classes/message/contentParts.js +44 -0
  5. package/dist/classes/message/index.d.ts +8 -2
  6. package/dist/classes/message/index.js +12 -0
  7. package/dist/classes/message/renderers/AnthropicRenderer.d.ts +43 -0
  8. package/dist/classes/message/renderers/AnthropicRenderer.js +19 -0
  9. package/dist/classes/message/renderers/GoogleRenderer.d.ts +35 -0
  10. package/dist/classes/message/renderers/GoogleRenderer.js +26 -0
  11. package/dist/classes/message/renderers/JSONRenderer.d.ts +8 -0
  12. package/dist/classes/message/renderers/JSONRenderer.js +21 -0
  13. package/dist/classes/message/renderers/OpenAIChatRenderer.d.ts +30 -0
  14. package/dist/classes/message/renderers/OpenAIChatRenderer.js +20 -0
  15. package/dist/classes/message/renderers/OpenAIResponsesRenderer.d.ts +39 -0
  16. package/dist/classes/message/renderers/OpenAIResponsesRenderer.js +23 -0
  17. package/dist/classes/message/renderers/PartRenderer.d.ts +14 -0
  18. package/dist/classes/message/renderers/PartRenderer.js +16 -0
  19. package/dist/clients/anthropic.d.ts +24 -0
  20. package/dist/clients/anthropic.js +4 -3
  21. package/dist/clients/baseClient.d.ts +7 -0
  22. package/dist/clients/baseClient.js +39 -0
  23. package/dist/clients/google.js +3 -2
  24. package/dist/clients/ollama.js +7 -6
  25. package/dist/clients/openai.js +3 -2
  26. package/dist/clients/openaiResponses.js +3 -2
  27. package/dist/clients/resolveAttachments.d.ts +11 -0
  28. package/dist/clients/resolveAttachments.js +108 -0
  29. package/dist/files/BaseFileProvider.d.ts +29 -0
  30. package/dist/files/BaseFileProvider.js +38 -0
  31. package/dist/files/anthropic.d.ts +16 -0
  32. package/dist/files/anthropic.js +20 -0
  33. package/dist/files/google.d.ts +18 -0
  34. package/dist/files/google.js +38 -0
  35. package/dist/files/openai.d.ts +16 -0
  36. package/dist/files/openai.js +23 -0
  37. package/dist/files.d.ts +46 -0
  38. package/dist/files.js +70 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +3 -0
  41. package/dist/models.d.ts +17 -2
  42. package/dist/models.js +29 -2
  43. package/dist/statelogClient.js +2 -1
  44. package/dist/types.d.ts +6 -8
  45. package/dist/types.js +1 -4
  46. package/dist/util/attachments.d.ts +34 -0
  47. package/dist/util/attachments.js +62 -0
  48. package/dist/util/hostedTools.js +5 -1
  49. package/dist/util/imageRef.d.ts +16 -1
  50. package/dist/util/imageRef.js +95 -10
  51. package/dist/util/modalities.d.ts +2 -0
  52. package/dist/util/modalities.js +31 -0
  53. package/dist/util/redact.d.ts +8 -0
  54. package/dist/util/redact.js +42 -0
  55. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { extname } from "node:path";
3
3
  /** Default timeout for fetching image URLs during normalization (60 seconds). */
4
4
  export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
@@ -8,8 +8,70 @@ const EXT_TO_MIME = {
8
8
  ".jpeg": "image/jpeg",
9
9
  ".webp": "image/webp",
10
10
  ".gif": "image/gif",
11
+ ".pdf": "application/pdf",
11
12
  };
12
- export async function normalizeImageRef(ref) {
13
+ function isAllowedMime(mimeType, allowedPrefixes) {
14
+ for (const prefix of allowedPrefixes) {
15
+ if (mimeType.startsWith(prefix)) {
16
+ return true;
17
+ }
18
+ }
19
+ return false;
20
+ }
21
+ export async function normalizeImageRef(ref, options = {}) {
22
+ const allowed = options.allowedMimePrefixes ?? ["image/"];
23
+ const result = await loadRef(ref, allowed, options.maxBytes);
24
+ if (options.maxBytes !== undefined && result.data.length > options.maxBytes) {
25
+ throw new Error(`Attachment exceeds the maximum size of ${options.maxBytes} bytes ` +
26
+ `(got ${result.data.length} bytes).`);
27
+ }
28
+ if (!result.mimeType) {
29
+ throw new Error("internal: gated load returned no mimeType");
30
+ }
31
+ return { data: result.data, mimeType: result.mimeType };
32
+ }
33
+ /**
34
+ * Load a source to bytes WITHOUT the image MIME gate (used by file uploads,
35
+ * which accept any type). Enforces `maxBytes` across all kinds.
36
+ */
37
+ export async function loadBlob(ref, options = {}) {
38
+ const result = await loadRef(ref, null, options.maxBytes);
39
+ if (options.maxBytes !== undefined && result.data.length > options.maxBytes) {
40
+ throw tooLargeError(options.maxBytes, result.data.length);
41
+ }
42
+ return result;
43
+ }
44
+ function tooLargeError(maxBytes, got) {
45
+ return new Error(`Attachment exceeds the maximum size of ${maxBytes} bytes (got ${got} bytes).`);
46
+ }
47
+ /**
48
+ * Read a response body into memory, aborting as soon as the running total
49
+ * exceeds `maxBytes` so a large or hostile URL can't exhaust memory. Falls back
50
+ * to buffering whole when the body isn't streamable.
51
+ */
52
+ async function readBodyWithLimit(res, maxBytes) {
53
+ if (maxBytes === undefined || !res.body) {
54
+ return new Uint8Array(await res.arrayBuffer());
55
+ }
56
+ const reader = res.body.getReader();
57
+ const chunks = [];
58
+ let total = 0;
59
+ while (true) {
60
+ const { done, value } = await reader.read();
61
+ if (done) {
62
+ break;
63
+ }
64
+ total += value.byteLength;
65
+ if (total > maxBytes) {
66
+ await reader.cancel();
67
+ throw tooLargeError(maxBytes, `>${total}`);
68
+ }
69
+ chunks.push(value);
70
+ }
71
+ // Pass the known total so concat doesn't re-scan the chunk list for its length.
72
+ return new Uint8Array(Buffer.concat(chunks, total));
73
+ }
74
+ async function loadRef(ref, allowed, maxBytes) {
13
75
  switch (ref.kind) {
14
76
  case "bytes":
15
77
  return { data: ref.data, mimeType: ref.mimeType };
@@ -19,31 +81,54 @@ export async function normalizeImageRef(ref) {
19
81
  mimeType: ref.mimeType,
20
82
  };
21
83
  case "path": {
84
+ // S1: bound memory — reject on stat before reading the whole file.
85
+ if (maxBytes !== undefined) {
86
+ const info = await stat(ref.path);
87
+ if (info.size > maxBytes) {
88
+ throw tooLargeError(maxBytes, info.size);
89
+ }
90
+ }
22
91
  const buf = await readFile(ref.path);
23
92
  const ext = extname(ref.path).toLowerCase();
24
93
  const inferred = EXT_TO_MIME[ext];
25
94
  const mimeType = ref.mimeType ?? inferred;
26
- if (!mimeType || !mimeType.startsWith("image/")) {
27
- throw new Error(`Could not determine image MIME type for path "${ref.path}". ` +
28
- `Pass an explicit mimeType (e.g. "image/png") on the ImageRef.`);
95
+ if (allowed !== null && (!mimeType || !isAllowedMime(mimeType, allowed))) {
96
+ throw new Error(`Could not determine an allowed MIME type for path "${ref.path}". ` +
97
+ `Allowed: ${allowed.join(", ")}. Pass an explicit mimeType on the ImageRef.`);
29
98
  }
30
99
  return { data: new Uint8Array(buf), mimeType };
31
100
  }
32
101
  case "url": {
102
+ // S2: only http(s) — reject file:/ftp:/gopher:/data: etc.
103
+ let scheme;
104
+ try {
105
+ scheme = new URL(ref.url).protocol;
106
+ }
107
+ catch {
108
+ throw new Error(`Invalid attachment URL: "${ref.url}".`);
109
+ }
110
+ if (scheme !== "http:" && scheme !== "https:") {
111
+ throw new Error(`Unsupported URL scheme "${scheme}" for attachment; only http(s) is allowed.`);
112
+ }
33
113
  const timeoutMs = ref.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
34
114
  const res = await fetch(ref.url, {
35
115
  signal: AbortSignal.timeout(timeoutMs),
36
116
  });
37
117
  if (!res.ok) {
38
- throw new Error(`Failed to fetch image from ${ref.url}: ${res.status}`);
118
+ throw new Error(`Failed to fetch attachment from ${ref.url}: ${res.status}`);
119
+ }
120
+ // Reject on an honest Content-Length before reading a single byte of body.
121
+ const declaredLength = Number(res.headers.get("content-length"));
122
+ if (maxBytes !== undefined && declaredLength > maxBytes) {
123
+ throw tooLargeError(maxBytes, declaredLength);
39
124
  }
40
- const buf = new Uint8Array(await res.arrayBuffer());
125
+ const buf = await readBodyWithLimit(res, maxBytes);
41
126
  const contentType = res.headers.get("content-type") ?? undefined;
42
127
  const mimeType = ref.mimeType ?? contentType;
43
- if (!mimeType || !mimeType.startsWith("image/")) {
44
- throw new Error(`Could not determine image MIME type for URL "${ref.url}". ` +
128
+ if (allowed !== null && (!mimeType || !isAllowedMime(mimeType, allowed))) {
129
+ throw new Error(`Could not determine an allowed MIME type for URL "${ref.url}". ` +
45
130
  `Response Content-Type was "${contentType ?? "missing"}". ` +
46
- `Pass an explicit mimeType (e.g. "image/png") on the ImageRef.`);
131
+ `Allowed: ${allowed.join(", ")}. Pass an explicit mimeType on the ImageRef.`);
47
132
  }
48
133
  return { data: buf, mimeType };
49
134
  }
@@ -0,0 +1,2 @@
1
+ import { PromptResult, Result, SmolConfig } from "../types.js";
2
+ export declare function validateModalities(config: SmolConfig): Result<PromptResult> | null;
@@ -0,0 +1,31 @@
1
+ import { UserMessage } from "../classes/message/index.js";
2
+ import { modelSupportsInputModality } from "../models.js";
3
+ import { failure } from "../types.js";
4
+ export function validateModalities(config) {
5
+ let needsImage = false;
6
+ let needsPdf = false;
7
+ for (const msg of config.messages) {
8
+ if (!(msg instanceof UserMessage)) {
9
+ continue;
10
+ }
11
+ const parts = msg.getContentParts();
12
+ if (parts === null) {
13
+ continue;
14
+ }
15
+ for (const part of parts) {
16
+ if (part.type === "image") {
17
+ needsImage = true;
18
+ }
19
+ if (part.type === "file") {
20
+ needsPdf = true;
21
+ }
22
+ }
23
+ }
24
+ if (needsImage && modelSupportsInputModality(config.model, "image", config.modelData) === false) {
25
+ return failure(`Model ${config.model} does not support image input.`);
26
+ }
27
+ if (needsPdf && modelSupportsInputModality(config.model, "pdf", config.modelData) === false) {
28
+ return failure(`Model ${config.model} does not support PDF/document input.`);
29
+ }
30
+ return null;
31
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deep copy of `value` with attachment payloads (base64 / data URIs) replaced by
3
+ * a short summary, so logs and observability never carry large media blobs.
4
+ * Normal prose is left intact (only base64-shaped strings are summarized).
5
+ */
6
+ export declare function redactAttachments(value: unknown): unknown;
7
+ /** Replace every occurrence of a secret (e.g. an API key) in text with a marker. */
8
+ export declare function redactSecret(text: string, secret?: string): string;
@@ -0,0 +1,42 @@
1
+ const MAX_STRING = 2048;
2
+ /**
3
+ * Deep copy of `value` with attachment payloads (base64 / data URIs) replaced by
4
+ * a short summary, so logs and observability never carry large media blobs.
5
+ * Normal prose is left intact (only base64-shaped strings are summarized).
6
+ */
7
+ export function redactAttachments(value) {
8
+ if (typeof value === "string") {
9
+ return redactString(value);
10
+ }
11
+ if (Array.isArray(value)) {
12
+ return value.map((v) => redactAttachments(v));
13
+ }
14
+ if (value && typeof value === "object") {
15
+ const out = {};
16
+ for (const [k, v] of Object.entries(value)) {
17
+ out[k] = redactAttachments(v);
18
+ }
19
+ return out;
20
+ }
21
+ return value;
22
+ }
23
+ function redactString(s) {
24
+ const marker = ";base64,";
25
+ if (s.startsWith("data:") && s.includes(marker)) {
26
+ const prefix = s.slice(0, s.indexOf(marker) + marker.length);
27
+ return `${prefix}[redacted ${s.length} chars]`;
28
+ }
29
+ // A bare base64 payload is a single unbroken token. Requiring zero whitespace
30
+ // keeps prose (which has spaces) intact even when it's long and punctuation-free.
31
+ if (s.length > MAX_STRING && /^[A-Za-z0-9+/=]+$/.test(s)) {
32
+ return `[redacted ${s.length} base64 chars]`;
33
+ }
34
+ return s;
35
+ }
36
+ /** Replace every occurrence of a secret (e.g. an API key) in text with a marker. */
37
+ export function redactSecret(text, secret) {
38
+ if (!secret) {
39
+ return text;
40
+ }
41
+ return text.split(secret).join("[redacted]");
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [