smoltalk 0.5.1 → 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 (77) hide show
  1. package/README.md +67 -10
  2. package/dist/classes/message/UserMessage.d.ts +66 -5
  3. package/dist/classes/message/UserMessage.js +100 -21
  4. package/dist/classes/message/contentParts.d.ts +259 -0
  5. package/dist/classes/message/contentParts.js +44 -0
  6. package/dist/classes/message/index.d.ts +8 -2
  7. package/dist/classes/message/index.js +12 -0
  8. package/dist/classes/message/renderers/AnthropicRenderer.d.ts +43 -0
  9. package/dist/classes/message/renderers/AnthropicRenderer.js +19 -0
  10. package/dist/classes/message/renderers/GoogleRenderer.d.ts +35 -0
  11. package/dist/classes/message/renderers/GoogleRenderer.js +26 -0
  12. package/dist/classes/message/renderers/JSONRenderer.d.ts +8 -0
  13. package/dist/classes/message/renderers/JSONRenderer.js +21 -0
  14. package/dist/classes/message/renderers/OpenAIChatRenderer.d.ts +30 -0
  15. package/dist/classes/message/renderers/OpenAIChatRenderer.js +20 -0
  16. package/dist/classes/message/renderers/OpenAIResponsesRenderer.d.ts +39 -0
  17. package/dist/classes/message/renderers/OpenAIResponsesRenderer.js +23 -0
  18. package/dist/classes/message/renderers/PartRenderer.d.ts +14 -0
  19. package/dist/classes/message/renderers/PartRenderer.js +16 -0
  20. package/dist/client.d.ts +4 -0
  21. package/dist/client.js +37 -16
  22. package/dist/clients/anthropic.d.ts +24 -2
  23. package/dist/clients/anthropic.js +9 -4
  24. package/dist/clients/baseClient.d.ts +7 -0
  25. package/dist/clients/baseClient.js +39 -0
  26. package/dist/clients/deepinfra.d.ts +17 -0
  27. package/dist/clients/deepinfra.js +27 -0
  28. package/dist/clients/google.js +6 -4
  29. package/dist/clients/litellm.d.ts +19 -0
  30. package/dist/clients/litellm.js +32 -0
  31. package/dist/clients/ollama.js +12 -9
  32. package/dist/clients/openai.d.ts +41 -5
  33. package/dist/clients/openai.js +78 -14
  34. package/dist/clients/openaiCompat.d.ts +22 -0
  35. package/dist/clients/openaiCompat.js +33 -0
  36. package/dist/clients/openaiResponses.js +6 -4
  37. package/dist/clients/openrouter.d.ts +23 -0
  38. package/dist/clients/openrouter.js +76 -0
  39. package/dist/clients/resolveAttachments.d.ts +11 -0
  40. package/dist/clients/resolveAttachments.js +108 -0
  41. package/dist/embed/openai.d.ts +7 -1
  42. package/dist/embed/openai.js +8 -2
  43. package/dist/embed.d.ts +18 -4
  44. package/dist/embed.js +32 -4
  45. package/dist/files/BaseFileProvider.d.ts +29 -0
  46. package/dist/files/BaseFileProvider.js +38 -0
  47. package/dist/files/anthropic.d.ts +16 -0
  48. package/dist/files/anthropic.js +20 -0
  49. package/dist/files/google.d.ts +18 -0
  50. package/dist/files/google.js +38 -0
  51. package/dist/files/openai.d.ts +16 -0
  52. package/dist/files/openai.js +23 -0
  53. package/dist/files.d.ts +46 -0
  54. package/dist/files.js +70 -0
  55. package/dist/image/openaiCompat.d.ts +9 -0
  56. package/dist/image/openaiCompat.js +60 -0
  57. package/dist/image.d.ts +13 -2
  58. package/dist/image.js +28 -3
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.js +3 -0
  61. package/dist/models.d.ts +22 -3
  62. package/dist/models.js +46 -3
  63. package/dist/statelogClient.js +2 -1
  64. package/dist/types.d.ts +28 -18
  65. package/dist/types.js +1 -4
  66. package/dist/util/attachments.d.ts +34 -0
  67. package/dist/util/attachments.js +62 -0
  68. package/dist/util/hostedTools.js +5 -1
  69. package/dist/util/imageRef.d.ts +16 -1
  70. package/dist/util/imageRef.js +95 -10
  71. package/dist/util/modalities.d.ts +2 -0
  72. package/dist/util/modalities.js +31 -0
  73. package/dist/util/provider.d.ts +32 -6
  74. package/dist/util/provider.js +38 -4
  75. package/dist/util/redact.d.ts +8 -0
  76. package/dist/util/redact.js +42 -0
  77. package/package.json +1 -1
package/dist/image.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { failure } from "./types/result.js";
2
- import { resolveProvider, resolveApiKey } from "./util/provider.js";
2
+ import { resolveProvider, resolveApiKey, resolveBaseUrl } from "./util/provider.js";
3
3
  import { openaiImage } from "./image/openai.js";
4
4
  import { googleImage } from "./image/google.js";
5
+ import { openaiCompatImage } from "./image/openaiCompat.js";
5
6
  // Null-prototype so provider names like "toString"/"__proto__" can't collide
6
7
  // with Object.prototype or pollute the registry.
7
8
  const registeredImageProviders = Object.create(null);
@@ -27,16 +28,40 @@ export async function image(input, config) {
27
28
  case "openai":
28
29
  case "openai-responses": {
29
30
  if (!apiKey) {
30
- return failure("No OpenAI API key provided. Set openAiApiKey in config or the OPENAI_API_KEY environment variable.");
31
+ return failure("No OpenAI API key provided. Set config.apiKey.openAi or the OPENAI_API_KEY environment variable.");
31
32
  }
32
33
  return openaiImage(input, config, apiKey);
33
34
  }
34
35
  case "google": {
35
36
  if (!apiKey) {
36
- return failure("No Google API key provided. Set googleApiKey in config or the GEMINI_API_KEY environment variable.");
37
+ return failure("No Google API key provided. Set config.apiKey.google or the GEMINI_API_KEY environment variable.");
37
38
  }
38
39
  return googleImage(input, config, apiKey);
39
40
  }
41
+ case "openrouter":
42
+ return failure("openrouter does not expose an OpenAI-compatible images endpoint; use openai-compat or litellm instead.");
43
+ case "deepinfra":
44
+ return failure("deepinfra exposes per-model image endpoints rather than the OpenAI `/images/generations` shape; use openai-compat with a specific backend or litellm instead.");
45
+ case "litellm": {
46
+ if (!apiKey) {
47
+ return failure("No LiteLLM API key provided. Set config.apiKey.liteLlm or the LITELLM_API_KEY environment variable.");
48
+ }
49
+ const baseURL = resolveBaseUrl("litellm", config);
50
+ if (!baseURL) {
51
+ return failure("No LiteLLM base URL provided. Set config.baseUrl.liteLlm or the LITELLM_BASE_URL environment variable.");
52
+ }
53
+ return openaiCompatImage(input, config, apiKey, baseURL);
54
+ }
55
+ case "openai-compat": {
56
+ if (!apiKey) {
57
+ return failure("No openai-compat API key provided. Set config.apiKey.openAiCompat or the OPENAI_COMPAT_API_KEY environment variable.");
58
+ }
59
+ const baseURL = resolveBaseUrl("openai-compat", config);
60
+ if (!baseURL) {
61
+ return failure("No openai-compat base URL provided. Set config.baseUrl.openAiCompat or the OPENAI_COMPAT_BASE_URL environment variable.");
62
+ }
63
+ return openaiCompatImage(input, config, apiKey, baseURL);
64
+ }
40
65
  default: {
41
66
  const custom = registeredImageProviders[provider];
42
67
  if (custom) {
package/dist/index.d.ts CHANGED
@@ -11,5 +11,9 @@ export * from "./functions.js";
11
11
  export * from "./classes/ToolCall.js";
12
12
  export * from "./embed.js";
13
13
  export * from "./image.js";
14
+ export { uploadFile, deleteFile, registerFileProvider, DEFAULT_UPLOAD_BYTES } from "./files.js";
15
+ export type { UploadFileOptions, FileProviderContext, FileProvider } from "./files.js";
16
+ export { normalizeImageRef, loadBlob } from "./util/imageRef.js";
17
+ export type { BlobRef } from "./util/imageRef.js";
14
18
  export { getLogger, EgonLog } from "./util/logger.js";
15
19
  export type { LogLevel } from "./util/logger.js";
package/dist/index.js CHANGED
@@ -11,4 +11,7 @@ export * from "./functions.js";
11
11
  export * from "./classes/ToolCall.js";
12
12
  export * from "./embed.js";
13
13
  export * from "./image.js";
14
+ // Explicit (not `export *`) so the test-only `_resetForTests` stays off the public surface.
15
+ export { uploadFile, deleteFile, registerFileProvider, DEFAULT_UPLOAD_BYTES } from "./files.js";
16
+ export { normalizeImageRef, loadBlob } from "./util/imageRef.js";
14
17
  export { getLogger, EgonLog } from "./util/logger.js";
package/dist/models.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { type ModelDataBlob, type HostedTool, type HostedToolPrice } from "./modelData.js";
3
- export declare const providers: readonly ["ollama", "openai", "openai-responses", "anthropic", "google", "replicate", "modal"];
3
+ export declare const providers: readonly ["ollama", "openai", "openai-responses", "anthropic", "google", "replicate", "modal", "openrouter", "deepinfra", "litellm", "openai-compat"];
4
4
  export declare const ProviderSchema: z.ZodEnum<{
5
- ollama: "ollama";
6
5
  openai: "openai";
7
- "openai-responses": "openai-responses";
8
6
  anthropic: "anthropic";
9
7
  google: "google";
8
+ "openai-responses": "openai-responses";
9
+ ollama: "ollama";
10
10
  replicate: "replicate";
11
11
  modal: "modal";
12
+ openrouter: "openrouter";
13
+ deepinfra: "deepinfra";
14
+ litellm: "litellm";
15
+ "openai-compat": "openai-compat";
12
16
  }>;
13
17
  export type Provider = z.infer<typeof ProviderSchema>;
14
18
  export type BaseModel = {
@@ -1450,7 +1454,22 @@ export declare function registerTextModel(model: Omit<TextModel, "type"> & {
1450
1454
  export declare function registerModelData(blob: ModelDataBlob): void;
1451
1455
  export declare function clearModelData(): void;
1452
1456
  export declare function getRegisteredModelData(): ModelDataBlob | null;
1457
+ /**
1458
+ * All known models: the baked-in catalog merged with any registered
1459
+ * (refreshed) model data and optional per-request overrides. This is the same
1460
+ * data `getModel` searches, exposed as a list so callers can enumerate, filter,
1461
+ * or display the catalog (e.g. a model picker). Fields worth filtering on live
1462
+ * on `ModelType`: `provider`, `openWeights`, `inputTokenCost` / `outputTokenCost`,
1463
+ * `maxInputTokens`, and `modalities`.
1464
+ */
1465
+ export declare function getAllModels(requestData?: ModelDataBlob): ModelType[];
1453
1466
  export declare function getModel(modelName: ModelName, requestData?: ModelDataBlob): ModelType | undefined;
1467
+ /**
1468
+ * Whether a model is known to accept the given input modality ("image", "pdf", …).
1469
+ * Returns undefined when the model is unknown or carries no `modalities` data —
1470
+ * callers should treat undefined as "don't gate".
1471
+ */
1472
+ export declare function modelSupportsInputModality(modelName: ModelName, modality: string, requestData?: ModelDataBlob): boolean | undefined;
1454
1473
  export declare function getHostedTools(opts?: {
1455
1474
  provider?: string;
1456
1475
  model?: string;
package/dist/models.js CHANGED
@@ -8,6 +8,10 @@ export const providers = [
8
8
  "google",
9
9
  "replicate",
10
10
  "modal",
11
+ "openrouter",
12
+ "deepinfra",
13
+ "litellm",
14
+ "openai-compat",
11
15
  ];
12
16
  export const ProviderSchema = z.enum(providers);
13
17
  export const speechToTextModels = [
@@ -1557,6 +1561,18 @@ export const hostedTools = [
1557
1561
  models: ["gemini-3-pro-preview", "gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.5-flash", "gemini-3.1-flash-lite"],
1558
1562
  pricing: { unit: "per_call", note: "Gemini 3 family only; see Google pricing." },
1559
1563
  },
1564
+ {
1565
+ name: "web_search",
1566
+ provider: "openrouter",
1567
+ category: "web_search",
1568
+ description: "Web search via OpenRouter's web plugin (Exa-backed). Adds citations as annotations.",
1569
+ providerToolId: "web",
1570
+ pricing: {
1571
+ unit: "per_call",
1572
+ amount: 0.02,
1573
+ note: "$4 per 1,000 results returned by the web plugin × default 5 results/call = $0.02/call. Plus token cost for inserted context. If you override max_results, scale this accordingly.",
1574
+ },
1575
+ },
1560
1576
  ];
1561
1577
  export const registeredTextModels = [];
1562
1578
  export function registerTextModel(model) {
@@ -1581,7 +1597,15 @@ function baselineModels() {
1581
1597
  ...embeddingsModels,
1582
1598
  ];
1583
1599
  }
1584
- export function getModel(modelName, requestData) {
1600
+ /**
1601
+ * All known models: the baked-in catalog merged with any registered
1602
+ * (refreshed) model data and optional per-request overrides. This is the same
1603
+ * data `getModel` searches, exposed as a list so callers can enumerate, filter,
1604
+ * or display the catalog (e.g. a model picker). Fields worth filtering on live
1605
+ * on `ModelType`: `provider`, `openWeights`, `inputTokenCost` / `outputTokenCost`,
1606
+ * `maxInputTokens`, and `modalities`.
1607
+ */
1608
+ export function getAllModels(requestData) {
1585
1609
  let models = baselineModels();
1586
1610
  if (registeredModelData) {
1587
1611
  models = mergeModelData(models, registeredModelData.models);
@@ -1589,7 +1613,26 @@ export function getModel(modelName, requestData) {
1589
1613
  if (requestData) {
1590
1614
  models = mergeModelData(models, requestData.models);
1591
1615
  }
1592
- return models.find((model) => model.modelName === modelName);
1616
+ return models;
1617
+ }
1618
+ export function getModel(modelName, requestData) {
1619
+ return getAllModels(requestData).find((model) => model.modelName === modelName);
1620
+ }
1621
+ /**
1622
+ * Whether a model is known to accept the given input modality ("image", "pdf", …).
1623
+ * Returns undefined when the model is unknown or carries no `modalities` data —
1624
+ * callers should treat undefined as "don't gate".
1625
+ */
1626
+ export function modelSupportsInputModality(modelName, modality, requestData) {
1627
+ const model = getModel(modelName, requestData);
1628
+ if (!model || model.type !== "text") {
1629
+ return undefined;
1630
+ }
1631
+ const inputs = model.modalities?.input;
1632
+ if (!inputs) {
1633
+ return undefined;
1634
+ }
1635
+ return inputs.includes(modality);
1593
1636
  }
1594
1637
  export function getHostedTools(opts = {}) {
1595
1638
  // Start from a copy so callers can never mutate the baseline registry.
@@ -1658,4 +1701,4 @@ export function isEmbeddingsModel(model) {
1658
1701
  }
1659
1702
  export const ModelNameSchema = z
1660
1703
  .string()
1661
- .regex(/^[a-zA-Z0-9._:-]+$/, "Model name must only contain letters, numbers, dots, underscores, hyphens, and colons");
1704
+ .regex(/^[a-zA-Z0-9._:@/-]+$/, "Model name must only contain letters, numbers, dots, underscores, hyphens, colons, slashes, and @");
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from "nanoid";
2
2
  import { failure, mergeResults, success } from "./types/result.js";
3
+ import { redactAttachments } from "./util/redact.js";
3
4
  export function mergeUploadResults(_results) {
4
5
  const results = mergeResults(_results);
5
6
  if (!results.success) {
@@ -277,7 +278,7 @@ export class StatelogClient {
277
278
  const postBody = JSON.stringify({
278
279
  trace_id: this.traceId,
279
280
  project_id: this.projectId,
280
- data: { ...body, timestamp: new Date().toISOString() },
281
+ data: redactAttachments({ ...body, timestamp: new Date().toISOString() }),
281
282
  });
282
283
  if (this.host.toLowerCase() === "stdout") {
283
284
  console.log(postBody);
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types/result.js";
2
+ export * from "./classes/message/contentParts.js";
2
3
  import { LogLevel } from "./util/logger.js";
3
4
  import z, { ZodType } from "zod";
4
5
  import { Message } from "./classes/message/index.js";
@@ -16,16 +17,28 @@ export type SmolConfig = {
16
17
  model: ModelName;
17
18
  /** Override the provider for the given model (e.g., use a custom endpoint for an OpenAI-compatible model). */
18
19
  provider?: string;
19
- /** API key for OpenAI. Required when using OpenAI models. */
20
- openAiApiKey?: string;
21
- /** API key for Google Gemini. Required when using Google models. */
22
- googleApiKey?: string;
23
- /** API key for Anthropic. Required when using Anthropic/Claude models. */
24
- anthropicApiKey?: string;
25
- /** API key for Ollama. Only needed when connecting to a cloud-hosted Ollama instance. */
26
- ollamaApiKey?: string;
27
- /** Base URL for the Ollama server. Defaults to localhost if not set. (Ollama only) */
28
- ollamaHost?: string;
20
+ /** API keys, nested by provider. Each key falls back to its conventional env var
21
+ * if unset (e.g. apiKey.openAi → OPENAI_API_KEY). */
22
+ apiKey?: {
23
+ openAi?: string;
24
+ google?: string;
25
+ anthropic?: string;
26
+ ollama?: string;
27
+ openRouter?: string;
28
+ deepInfra?: string;
29
+ liteLlm?: string;
30
+ openAiCompat?: string;
31
+ };
32
+ /** Custom base URLs, nested by provider. Defaults are baked in where applicable
33
+ * (e.g. openrouter, deepinfra); litellm and openai-compat require an explicit URL. */
34
+ baseUrl?: {
35
+ /** Host URL for the Ollama server. Defaults to http://localhost:11434 (or $OLLAMA_HOST). */
36
+ ollama?: string;
37
+ openRouter?: string;
38
+ deepInfra?: string;
39
+ liteLlm?: string;
40
+ openAiCompat?: string;
41
+ };
29
42
  /** Log level for internal debug logging. */
30
43
  logLevel?: LogLevel;
31
44
  /** Configuration for Statelog observability/tracing integration. */
@@ -45,6 +58,11 @@ export type SmolConfig = {
45
58
  }>;
46
59
  /** Arbitrary metadata passed to custom model providers. */
47
60
  metadata?: Record<string, any>;
61
+ /** Options for image/PDF attachments on user messages. */
62
+ attachments?: {
63
+ /** Max resolved attachment size in bytes. Default 20 MB. Over → Failure. */
64
+ maxBytes?: number;
65
+ };
48
66
  /** Refreshed model/hosted-tool data (from refreshModels) to layer over the baked-in registry for this call. */
49
67
  modelData?: ModelDataBlob;
50
68
  /** The conversation messages to send to the model. */
@@ -161,10 +179,6 @@ export interface SmolClient {
161
179
  textStream(config: SmolConfig): AsyncGenerator<StreamChunk>;
162
180
  _textStream(config: SmolConfig): AsyncGenerator<StreamChunk>;
163
181
  }
164
- export type TextPart = {
165
- type: "text";
166
- text: string;
167
- };
168
182
  /** Loose variant of SmolConfig for `getClient()` — messages are not required at construction time. */
169
183
  export type SmolClientConfig = Omit<SmolConfig, "messages"> & {
170
184
  messages?: Message[];
@@ -174,10 +188,6 @@ export type ThinkingBlock = {
174
188
  text: string;
175
189
  signature: string;
176
190
  };
177
- export declare const TextPartSchema: z.ZodObject<{
178
- type: z.ZodLiteral<"text">;
179
- text: z.ZodString;
180
- }, z.z.core.$strip>;
181
191
  export declare const ThinkingBlockSchema: z.ZodObject<{
182
192
  text: z.ZodString;
183
193
  signature: z.ZodString;
package/dist/types.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types/result.js";
2
+ export * from "./classes/message/contentParts.js";
2
3
  import z from "zod";
3
4
  export * from "./types/costEstimate.js";
4
5
  export * from "./types/tokenUsage.js";
@@ -13,10 +14,6 @@ export function promptResult({ output, toolCalls, thinkingBlocks, usage, cost, m
13
14
  hostedToolResults,
14
15
  };
15
16
  }
16
- export const TextPartSchema = z.object({
17
- type: z.literal("text"),
18
- text: z.string(),
19
- });
20
17
  export const ThinkingBlockSchema = z.object({
21
18
  text: z.string(),
22
19
  signature: z.string(),
@@ -0,0 +1,34 @@
1
+ import type { ImageRef } from "./imageRef.js";
2
+ /** Filename sent to providers that require one when a file part omits its own. */
3
+ export declare const DEFAULT_ATTACHMENT_FILENAME = "attachment.pdf";
4
+ /** A file part's filename, falling back to the shared default when unset. */
5
+ export declare function attachmentFilename(filename?: string): string;
6
+ /**
7
+ * Extract base64 + MIME from an ImageRef that has already been resolved to
8
+ * in-memory bytes. `path`/`url` refs must be resolved (via the BaseClient
9
+ * normalize pass) before serialization — reaching here with one is a bug.
10
+ */
11
+ export declare function refToBase64(ref: ImageRef): {
12
+ base64: string;
13
+ mimeType: string;
14
+ };
15
+ export declare function toDataUri(base64: string, mimeType: string): string;
16
+ /** The Files-API namespace for a provider, or null when it has no Files API. */
17
+ export declare function fileFamily(provider: string): "openai" | "anthropic" | "google" | null;
18
+ /**
19
+ * OpenAI-style image URL: a passthrough `url` ref stays a remote URL; anything
20
+ * else becomes a base64 data URI.
21
+ */
22
+ export declare function openAiImageUrl(ref: ImageRef): string;
23
+ /**
24
+ * Anthropic image/document source: a passthrough `url` ref becomes a url source;
25
+ * anything else becomes a base64 source.
26
+ */
27
+ export declare function anthropicSource(ref: ImageRef): {
28
+ type: "url";
29
+ url: string;
30
+ } | {
31
+ type: "base64";
32
+ media_type: string;
33
+ data: string;
34
+ };
@@ -0,0 +1,62 @@
1
+ /** Filename sent to providers that require one when a file part omits its own. */
2
+ export const DEFAULT_ATTACHMENT_FILENAME = "attachment.pdf";
3
+ /** A file part's filename, falling back to the shared default when unset. */
4
+ export function attachmentFilename(filename) {
5
+ if (filename === undefined) {
6
+ return DEFAULT_ATTACHMENT_FILENAME;
7
+ }
8
+ return filename;
9
+ }
10
+ /**
11
+ * Extract base64 + MIME from an ImageRef that has already been resolved to
12
+ * in-memory bytes. `path`/`url` refs must be resolved (via the BaseClient
13
+ * normalize pass) before serialization — reaching here with one is a bug.
14
+ */
15
+ export function refToBase64(ref) {
16
+ if (ref.kind === "base64") {
17
+ return { base64: ref.base64, mimeType: ref.mimeType };
18
+ }
19
+ if (ref.kind === "bytes") {
20
+ return { base64: Buffer.from(ref.data).toString("base64"), mimeType: ref.mimeType };
21
+ }
22
+ throw new Error(`Attachment ref must be resolved to base64 or bytes before serialization ` +
23
+ `(got kind="${ref.kind}"). This is a smoltalk bug.`);
24
+ }
25
+ export function toDataUri(base64, mimeType) {
26
+ return `data:${mimeType};base64,${base64}`;
27
+ }
28
+ /** The Files-API namespace for a provider, or null when it has no Files API. */
29
+ export function fileFamily(provider) {
30
+ if (provider === "openai" || provider === "openai-responses") {
31
+ return "openai";
32
+ }
33
+ if (provider === "anthropic") {
34
+ return "anthropic";
35
+ }
36
+ if (provider === "google") {
37
+ return "google";
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * OpenAI-style image URL: a passthrough `url` ref stays a remote URL; anything
43
+ * else becomes a base64 data URI.
44
+ */
45
+ export function openAiImageUrl(ref) {
46
+ if (ref.kind === "url") {
47
+ return ref.url;
48
+ }
49
+ const { base64, mimeType } = refToBase64(ref);
50
+ return toDataUri(base64, mimeType);
51
+ }
52
+ /**
53
+ * Anthropic image/document source: a passthrough `url` ref becomes a url source;
54
+ * anything else becomes a base64 source.
55
+ */
56
+ export function anthropicSource(ref) {
57
+ if (ref.kind === "url") {
58
+ return { type: "url", url: ref.url };
59
+ }
60
+ const { base64, mimeType } = refToBase64(ref);
61
+ return { type: "base64", media_type: mimeType, data: base64 };
62
+ }
@@ -27,7 +27,11 @@ export function validateHostedTools(requested, model, provider, modelData) {
27
27
  if (!effectiveProvider) {
28
28
  effectiveProvider = getModel(model, modelData)?.provider ?? "unknown";
29
29
  }
30
- return `${name} is a hosted capability; ${model} (${effectiveProvider}) doesn't offer it — pass a search function as a tool instead.`;
30
+ let err = `${name} is a hosted capability; ${model} (${effectiveProvider}) doesn't offer it — pass a search function as a tool instead.`;
31
+ if (effectiveProvider === "openai") {
32
+ err += " You can also use the provider 'openai-responses' to access hosted tools for OpenAI models (you're currently using the base 'openai' provider).";
33
+ }
34
+ return err;
31
35
  }
32
36
  }
33
37
  return null;
@@ -20,10 +20,25 @@ export type ImageRef = {
20
20
  */
21
21
  timeoutMs?: number;
22
22
  };
23
+ /** Neutral alias of the source union for non-image uses (uploads via {@link loadBlob}). */
24
+ export type BlobRef = ImageRef;
23
25
  export type NormalizedImage = {
24
26
  data: Uint8Array;
25
27
  mimeType: string;
26
28
  };
27
29
  /** Default timeout for fetching image URLs during normalization (60 seconds). */
28
30
  export declare const DEFAULT_FETCH_TIMEOUT_MS = 60000;
29
- export declare function normalizeImageRef(ref: ImageRef): Promise<NormalizedImage>;
31
+ export declare function normalizeImageRef(ref: ImageRef, options?: {
32
+ allowedMimePrefixes?: string[];
33
+ maxBytes?: number;
34
+ }): Promise<NormalizedImage>;
35
+ /**
36
+ * Load a source to bytes WITHOUT the image MIME gate (used by file uploads,
37
+ * which accept any type). Enforces `maxBytes` across all kinds.
38
+ */
39
+ export declare function loadBlob(ref: ImageRef, options?: {
40
+ maxBytes?: number;
41
+ }): Promise<{
42
+ data: Uint8Array;
43
+ mimeType?: string;
44
+ }>;
@@ -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
+ }