smoltalk 0.6.0 → 0.7.1
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/dist/classes/message/UserMessage.d.ts +66 -5
- package/dist/classes/message/UserMessage.js +100 -21
- package/dist/classes/message/contentParts.d.ts +259 -0
- package/dist/classes/message/contentParts.js +44 -0
- package/dist/classes/message/index.d.ts +8 -2
- package/dist/classes/message/index.js +12 -0
- package/dist/classes/message/renderers/AnthropicRenderer.d.ts +43 -0
- package/dist/classes/message/renderers/AnthropicRenderer.js +19 -0
- package/dist/classes/message/renderers/GoogleRenderer.d.ts +35 -0
- package/dist/classes/message/renderers/GoogleRenderer.js +26 -0
- package/dist/classes/message/renderers/JSONRenderer.d.ts +8 -0
- package/dist/classes/message/renderers/JSONRenderer.js +21 -0
- package/dist/classes/message/renderers/OpenAIChatRenderer.d.ts +30 -0
- package/dist/classes/message/renderers/OpenAIChatRenderer.js +20 -0
- package/dist/classes/message/renderers/OpenAIResponsesRenderer.d.ts +39 -0
- package/dist/classes/message/renderers/OpenAIResponsesRenderer.js +23 -0
- package/dist/classes/message/renderers/PartRenderer.d.ts +14 -0
- package/dist/classes/message/renderers/PartRenderer.js +16 -0
- package/dist/clients/anthropic.d.ts +24 -0
- package/dist/clients/anthropic.js +4 -3
- package/dist/clients/baseClient.d.ts +7 -0
- package/dist/clients/baseClient.js +39 -0
- package/dist/clients/google.js +3 -2
- package/dist/clients/ollama.js +7 -6
- package/dist/clients/openai.js +3 -2
- package/dist/clients/openaiResponses.js +3 -2
- package/dist/clients/resolveAttachments.d.ts +11 -0
- package/dist/clients/resolveAttachments.js +108 -0
- package/dist/files/BaseFileProvider.d.ts +29 -0
- package/dist/files/BaseFileProvider.js +38 -0
- package/dist/files/anthropic.d.ts +16 -0
- package/dist/files/anthropic.js +20 -0
- package/dist/files/google.d.ts +18 -0
- package/dist/files/google.js +38 -0
- package/dist/files/openai.d.ts +16 -0
- package/dist/files/openai.js +23 -0
- package/dist/files.d.ts +46 -0
- package/dist/files.js +70 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/models.d.ts +17 -2
- package/dist/models.js +29 -2
- package/dist/statelogClient.js +2 -1
- package/dist/types.d.ts +6 -8
- package/dist/types.js +1 -4
- package/dist/util/attachments.d.ts +34 -0
- package/dist/util/attachments.js +62 -0
- package/dist/util/hostedTools.js +5 -1
- package/dist/util/imageRef.d.ts +16 -1
- package/dist/util/imageRef.js +95 -10
- package/dist/util/modalities.d.ts +2 -0
- package/dist/util/modalities.js +31 -0
- package/dist/util/redact.d.ts +8 -0
- package/dist/util/redact.js +42 -0
- package/package.json +1 -1
package/dist/util/imageRef.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
27
|
-
throw new Error(`Could not determine
|
|
28
|
-
`Pass an explicit mimeType
|
|
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
|
|
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 =
|
|
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
|
|
44
|
-
throw new Error(`Could not determine
|
|
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
|
|
131
|
+
`Allowed: ${allowed.join(", ")}. Pass an explicit mimeType on the ImageRef.`);
|
|
47
132
|
}
|
|
48
133
|
return { data: buf, mimeType };
|
|
49
134
|
}
|
|
@@ -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
|
+
}
|