smoltalk 0.16.0 → 0.17.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 (42) hide show
  1. package/README.md +37 -0
  2. package/dist/clients/anthropic.js +3 -2
  3. package/dist/clients/google.js +2 -1
  4. package/dist/clients/llamaCppLoader.js +2 -2
  5. package/dist/clients/ollama.d.ts +1 -1
  6. package/dist/clients/ollama.js +1 -1
  7. package/dist/clients/openai.d.ts +2 -1
  8. package/dist/clients/openai.js +5 -3
  9. package/dist/clients/openaiResponses.js +3 -2
  10. package/dist/clients/resolveAttachments.js +2 -1
  11. package/dist/embed/openai.js +3 -2
  12. package/dist/files/anthropic.js +2 -2
  13. package/dist/files/openai.js +2 -2
  14. package/dist/image/google.js +3 -2
  15. package/dist/image/openai.js +3 -2
  16. package/dist/image/openaiCompat.js +3 -2
  17. package/dist/modelRefresh.js +10 -11
  18. package/dist/platform.browser.d.ts +5 -0
  19. package/dist/platform.browser.js +13 -0
  20. package/dist/platform.d.ts +33 -0
  21. package/dist/platform.js +13 -0
  22. package/dist/platform.node.d.ts +5 -0
  23. package/dist/platform.node.js +17 -0
  24. package/dist/speech/google.js +2 -1
  25. package/dist/speech/groq.js +1 -0
  26. package/dist/speech/mlx.js +1 -0
  27. package/dist/speech/openai.js +1 -1
  28. package/dist/speech/openaiCompat.js +1 -1
  29. package/dist/statelogClient.js +2 -1
  30. package/dist/transcription/google.js +3 -2
  31. package/dist/transcription/groq.js +1 -0
  32. package/dist/transcription/openai.js +1 -1
  33. package/dist/transcription/openaiCompat.js +1 -1
  34. package/dist/util/attachments.js +2 -1
  35. package/dist/util/base64.d.ts +17 -0
  36. package/dist/util/base64.js +59 -0
  37. package/dist/util/blobRef.js +18 -12
  38. package/dist/util/fileExtension.d.ts +7 -0
  39. package/dist/util/fileExtension.js +15 -0
  40. package/dist/util/provider.d.ts +1 -0
  41. package/dist/util/provider.js +16 -14
  42. package/package.json +14 -2
package/README.md CHANGED
@@ -838,6 +838,43 @@ unsupported provider, a model without audio input, or audio that isn't
838
838
  `mp3`/`wav` surfaces as a `Failure` from that call, not from `audioPart()`
839
839
  itself. Audio is inlined as base64, not uploaded via the Files API.
840
840
 
841
+ ## In the browser
842
+
843
+ smoltalk bundles for the browser. A bundler run for the browser (Vite,
844
+ webpack with `target: "web"`, esbuild with `--platform=browser`) picks the
845
+ browser side of smoltalk's `#platform` and `#ollama` imports on its own;
846
+ there is no separate entry point and no flag. With esbuild, mark Node
847
+ builtins external, because `@anthropic-ai/sdk` lazily imports `node:fs` on
848
+ the path it takes when no API key is passed (Vite does this for you):
849
+
850
+ ```sh
851
+ esbuild app.ts --bundle --platform=browser --format=esm --external:node:*
852
+ ```
853
+
854
+ Differences from Node:
855
+
856
+ - There are no environment variables. `env` reads as unset, nothing
857
+ throws, and a client built without a key fails with the SDK's own
858
+ missing-key error. Pass keys in `apiKey`.
859
+ - A `{ kind: "path" }` attachment throws: "readFileBytes is not available
860
+ in a browser build of smoltalk."
861
+ - `refreshModels` accepts `https:` URLs only; a `file:` URL fails the
862
+ same way, from `readTextFromFileUrl`.
863
+ - `loadLlamaCpp` fails: it loads a native module. Without `entryPath` the
864
+ error is the loader's "Failed to load smoltalk-llama-cpp", wrapping the
865
+ browser's refusal to resolve the bare specifier; with `entryPath` it is
866
+ `pathToFileUrl`'s. Local inference in a browser is the `smoltalk-webllm`
867
+ plugin.
868
+
869
+ The `openai` and `@anthropic-ai/sdk` clients are constructed with
870
+ `dangerouslyAllowBrowser: true`. That flag exists because a key sent to a
871
+ browser is visible to anyone who can open the page. For a production app,
872
+ keep the key on a server. The built-in OpenAI, Anthropic, and Google
873
+ clients take no base URL, so the browser side reaches such a server as an
874
+ OpenAI-compatible endpoint: `provider: "openai-compat"` with
875
+ `baseUrl.openAiCompat` set to the proxy, and the proxy holding the real
876
+ key.
877
+
841
878
  ## Limitations
842
879
  Smoltalk has support for a limited number of providers right now, and is mostly focused on the stateless APIs for text completion, though I plan to add support for more providers as well as image and speech models later. Smoltalk is also a personal project, and there are alternatives backed by companies:
843
880
 
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import Anthropic from "@anthropic-ai/sdk";
2
3
  import { ToolCall } from "../classes/ToolCall.js";
3
4
  import { SystemMessage, DeveloperMessage } from "../classes/message/index.js";
@@ -219,11 +220,11 @@ export class SmolAnthropic extends BaseClient {
219
220
  model;
220
221
  constructor(config) {
221
222
  super(config);
222
- const apiKey = config.apiKey?.anthropic || process.env.ANTHROPIC_API_KEY;
223
+ const apiKey = config.apiKey?.anthropic || env("ANTHROPIC_API_KEY");
223
224
  if (!apiKey) {
224
225
  throw new Error("Anthropic API key is required for SmolAnthropic client.");
225
226
  }
226
- this.client = new Anthropic({ apiKey });
227
+ this.client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey });
227
228
  this.logger = getLogger();
228
229
  this.model = new Model(config.model, config.provider, config.modelData);
229
230
  }
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { ToolCall } from "../classes/ToolCall.js";
3
4
  import { getLogger } from "../util/logger.js";
@@ -203,7 +204,7 @@ export class SmolGoogle extends BaseClient {
203
204
  model;
204
205
  constructor(config) {
205
206
  super(config);
206
- const apiKey = config.apiKey?.google || process.env.GEMINI_API_KEY;
207
+ const apiKey = config.apiKey?.google || env("GEMINI_API_KEY");
207
208
  if (!apiKey) {
208
209
  throw new Error("Google API key is required for SmolGoogle client.");
209
210
  }
@@ -1,4 +1,4 @@
1
- import { pathToFileURL } from "url";
1
+ import { pathToFileUrl } from "#platform";
2
2
  import { hasProvider, registerProvider } from "../client.js";
3
3
  import { SmolError } from "../smolError.js";
4
4
  const realImport = (specifier) => import(specifier);
@@ -63,7 +63,7 @@ export function loadLlamaCpp(options) {
63
63
  async function doLoad(entryPath) {
64
64
  let importSource;
65
65
  if (entryPath) {
66
- importSource = pathToFileURL(entryPath).href;
66
+ importSource = pathToFileUrl(entryPath);
67
67
  }
68
68
  else {
69
69
  importSource = "smoltalk-llama-cpp";
@@ -1,4 +1,4 @@
1
- import { Ollama } from "ollama";
1
+ import { Ollama } from "#ollama";
2
2
  import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
3
3
  import { BaseClient } from "./baseClient.js";
4
4
  import { ModelName } from "../models.js";
@@ -1,4 +1,4 @@
1
- import { Ollama } from "ollama";
1
+ import { Ollama } from "#ollama";
2
2
  import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { redactAttachments } from "../util/redact.js";
@@ -14,7 +14,8 @@ export declare class SmolOpenAi extends BaseClient implements SmolClient {
14
14
  protected attachmentCapabilities(): ClientAttachmentCapabilities;
15
15
  /**
16
16
  * Build the `new OpenAI({...})` options. Subclasses override to inject a
17
- * different baseURL or to pull the key from a different config field.
17
+ * different baseURL or to pull the key from a different config field. The
18
+ * constructor adds `dangerouslyAllowBrowser` itself.
18
19
  *
19
20
  * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
20
21
  * if the key is missing — subclasses with required keys do their own check.
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import OpenAI from "openai";
2
3
  import { success, } from "../types.js";
3
4
  import { ToolCall } from "../classes/ToolCall.js";
@@ -19,7 +20,7 @@ export class SmolOpenAi extends BaseClient {
19
20
  constructor(config) {
20
21
  super(config);
21
22
  const options = this.resolveClientOptions(config);
22
- this.client = new OpenAI(options);
23
+ this.client = new OpenAI({ ...options, dangerouslyAllowBrowser: true });
23
24
  this.logger = getLogger();
24
25
  this.model = new Model(config.model, config.provider, config.modelData);
25
26
  }
@@ -29,13 +30,14 @@ export class SmolOpenAi extends BaseClient {
29
30
  }
30
31
  /**
31
32
  * Build the `new OpenAI({...})` options. Subclasses override to inject a
32
- * different baseURL or to pull the key from a different config field.
33
+ * different baseURL or to pull the key from a different config field. The
34
+ * constructor adds `dangerouslyAllowBrowser` itself.
33
35
  *
34
36
  * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
35
37
  * if the key is missing — subclasses with required keys do their own check.
36
38
  */
37
39
  resolveClientOptions(config) {
38
- const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
40
+ const apiKey = config.apiKey?.openAi || env("OPENAI_API_KEY");
39
41
  if (!apiKey) {
40
42
  throw new Error("OpenAI API key is required for SmolOpenAi client.");
41
43
  }
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import OpenAI from "openai";
2
3
  import { success, } from "../types.js";
3
4
  import { ToolCall } from "../classes/ToolCall.js";
@@ -57,11 +58,11 @@ export class SmolOpenAiResponses extends BaseClient {
57
58
  model;
58
59
  constructor(config) {
59
60
  super(config);
60
- const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
61
+ const apiKey = config.apiKey?.openAi || env("OPENAI_API_KEY");
61
62
  if (!apiKey) {
62
63
  throw new Error("OpenAI API key is required for SmolOpenAiResponses client.");
63
64
  }
64
- this.client = new OpenAI({ apiKey });
65
+ this.client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey });
65
66
  this.logger = getLogger();
66
67
  this.model = new Model(config.model, config.provider, config.modelData);
67
68
  }
@@ -1,3 +1,4 @@
1
+ import { bytesToBase64 } from "../util/base64.js";
1
2
  import { UserMessage } from "../classes/message/index.js";
2
3
  import { normalizeBlob } from "../util/blobRef.js";
3
4
  import { fileFamily } from "../util/attachments.js";
@@ -45,7 +46,7 @@ async function toBase64Source(source, allowed, maxBytes) {
45
46
  allowedMimePrefixes: allowed,
46
47
  maxBytes,
47
48
  });
48
- return { kind: "base64", base64: Buffer.from(data).toString("base64"), mimeType };
49
+ return { kind: "base64", base64: bytesToBase64(data), mimeType };
49
50
  }
50
51
  /** Error message when a providerFile ref targets the wrong provider family, else null. */
51
52
  function providerFileError(fileProvider, targetProvider) {
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { getModel, isEmbeddingsModel } from "../models.js";
@@ -10,7 +11,7 @@ import { round } from "../util/util.js";
10
11
  */
11
12
  function toFloats(embedding) {
12
13
  if (typeof embedding === "string") {
13
- const bytes = Buffer.from(embedding, "base64");
14
+ const bytes = base64ToBytes(embedding);
14
15
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
15
16
  return Array.from(view);
16
17
  }
@@ -24,7 +25,7 @@ function toFloats(embedding) {
24
25
  */
25
26
  export async function openaiEmbed(inputs, config, apiKey, baseURL, options) {
26
27
  try {
27
- const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
28
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey, ...(baseURL ? { baseURL } : {}) });
28
29
  const body = {
29
30
  model: config.model,
30
31
  input: inputs,
@@ -7,13 +7,13 @@ export function anthropicFileRef(uploaded, mimeType) {
7
7
  class AnthropicFileProvider extends BaseFileProvider {
8
8
  label = "Anthropic";
9
9
  async doUpload(data, mimeType, ctx) {
10
- const client = new Anthropic({ apiKey: ctx.apiKey });
10
+ const client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
11
11
  const file = await toFile(data, ctx.filename ?? "upload", { type: mimeType });
12
12
  const uploaded = await client.beta.files.upload({ file, betas: [FILES_BETA] });
13
13
  return anthropicFileRef(uploaded, mimeType);
14
14
  }
15
15
  async doDelete(ref, ctx) {
16
- const client = new Anthropic({ apiKey: ctx.apiKey });
16
+ const client = new Anthropic({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
17
17
  await client.beta.files.delete(ref.id, { betas: [FILES_BETA] });
18
18
  }
19
19
  }
@@ -8,7 +8,7 @@ export function openaiFileRef(created, mimeType) {
8
8
  class OpenAIFileProvider extends BaseFileProvider {
9
9
  label = "OpenAI";
10
10
  async doUpload(data, mimeType, ctx) {
11
- const client = new OpenAI({ apiKey: ctx.apiKey });
11
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
12
12
  const file = await toFile(data, ctx.filename ?? "upload", { type: mimeType });
13
13
  // S5: the mapper below is trivial and won't throw post-create, so no orphan
14
14
  // is left here; a provider whose mapper does real work should best-effort delete.
@@ -16,7 +16,7 @@ class OpenAIFileProvider extends BaseFileProvider {
16
16
  return openaiFileRef(created, mimeType);
17
17
  }
18
18
  async doDelete(ref, ctx) {
19
- const client = new OpenAI({ apiKey: ctx.apiKey });
19
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: ctx.apiKey });
20
20
  await client.files.delete(ref.id);
21
21
  }
22
22
  }
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes, bytesToBase64 } from "../util/base64.js";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { getModel, isImageModel } from "../models.js";
@@ -14,7 +15,7 @@ export async function googleImage(input, config, apiKey) {
14
15
  parts.push({
15
16
  inlineData: {
16
17
  mimeType: img.mimeType,
17
- data: Buffer.from(img.data).toString("base64"),
18
+ data: bytesToBase64(img.data),
18
19
  },
19
20
  });
20
21
  }
@@ -32,7 +33,7 @@ export async function googleImage(input, config, apiKey) {
32
33
  for (const part of candidate.content?.parts ?? []) {
33
34
  if (part.inlineData?.data && part.inlineData?.mimeType) {
34
35
  images.push({
35
- data: new Uint8Array(Buffer.from(part.inlineData.data, "base64")),
36
+ data: base64ToBytes(part.inlineData.data),
36
37
  mimeType: part.inlineData.mimeType,
37
38
  });
38
39
  }
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { toFile } from "openai/uploads";
3
4
  import { success, failure } from "../types/result.js";
@@ -11,14 +12,14 @@ export async function openaiImage(input, config, apiKey) {
11
12
  if (normalized.mask && !hasImages) {
12
13
  return failure("A mask was provided without any input images. Masks are only valid for image edits — pass at least one entry in `images` alongside the mask.");
13
14
  }
14
- const client = new OpenAI({ apiKey });
15
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey });
15
16
  const baseParams = buildBaseParams(config, normalized.prompt);
16
17
  const response = hasImages
17
18
  ? await callEdit(client, baseParams, normalized)
18
19
  : await client.images.generate(baseParams);
19
20
  const mimeType = mimeFromFormat(config.outputFormat) ?? "image/png";
20
21
  const images = (response.data ?? []).map((d) => ({
21
- data: new Uint8Array(Buffer.from(d.b64_json, "base64")),
22
+ data: base64ToBytes(d.b64_json),
22
23
  mimeType,
23
24
  revisedPrompt: d.revised_prompt,
24
25
  }));
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import OpenAI from "openai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { omitUndefined } from "../util/util.js";
@@ -17,7 +18,7 @@ export async function openaiCompatImage(input, config, apiKey, baseURL) {
17
18
  if (normalized.mask) {
18
19
  return failure("openai-compat: image masks are not supported on this generic adapter.");
19
20
  }
20
- const client = new OpenAI({ apiKey, baseURL });
21
+ const client = new OpenAI({ dangerouslyAllowBrowser: true, apiKey, baseURL });
21
22
  const params = omitUndefined({
22
23
  model: config.model,
23
24
  prompt: normalized.prompt,
@@ -31,7 +32,7 @@ export async function openaiCompatImage(input, config, apiKey, baseURL) {
31
32
  const response = await client.images.generate(params);
32
33
  const mimeType = mimeFromFormat(config.outputFormat) ?? "image/png";
33
34
  const images = (response.data ?? []).map((d) => ({
34
- data: new Uint8Array(Buffer.from(d.b64_json, "base64")),
35
+ data: base64ToBytes(d.b64_json),
35
36
  mimeType,
36
37
  revisedPrompt: d.revised_prompt,
37
38
  }));
@@ -2,19 +2,19 @@
2
2
  * Fetching refreshed model data from a remote or on-disk catalog.
3
3
  *
4
4
  * Split out of modelData.ts so that module — and therefore models.ts, and
5
- * therefore the model registry — carries no Node dependency. Everything here
6
- * needs a Node runtime (node:fs, Buffer, process.env); everything in
7
- * modelData.ts is pure and bundles for the browser.
5
+ * therefore the model registry — stays pure. This file reaches the platform
6
+ * through #platform: a `file:` URL works on Node and throws in a browser,
7
+ * and `https:` works on both.
8
8
  */
9
- import { readFile } from "node:fs/promises";
10
- import { fileURLToPath } from "node:url";
9
+ import { env, readTextFromFileUrl } from "#platform";
10
+ import { utf8ByteLength } from "./util/base64.js";
11
11
  import { parseModelDataBlob } from "./modelData.js";
12
12
  import { failure } from "./types/result.js";
13
13
  export const DEFAULT_MODEL_DATA_URL = "https://raw.githubusercontent.com/egonSchiele/smoltalk/main/packages/smoltalk/data/model-data.json";
14
14
  const MAX_BYTES = 10_000_000;
15
15
  const TIMEOUT_MS = 15_000;
16
16
  function enforceSizeCap(text) {
17
- if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
17
+ if (utf8ByteLength(text) > MAX_BYTES) {
18
18
  throw new Error(`Model data exceeds the ${MAX_BYTES}-byte cap`);
19
19
  }
20
20
  return text;
@@ -24,9 +24,7 @@ function enforceSizeCap(text) {
24
24
  // integration test). Any other scheme is rejected.
25
25
  async function defaultFetcher(url, signal) {
26
26
  if (url.startsWith("file://")) {
27
- const path = fileURLToPath(url);
28
- const text = await readFile(path, "utf8");
29
- return enforceSizeCap(text);
27
+ return enforceSizeCap(await readTextFromFileUrl(url));
30
28
  }
31
29
  if (url.startsWith("https://")) {
32
30
  const res = await fetch(url, { signal });
@@ -39,8 +37,9 @@ async function defaultFetcher(url, signal) {
39
37
  }
40
38
  export async function refreshModels(opts = {}) {
41
39
  let url = DEFAULT_MODEL_DATA_URL;
42
- if (process.env.SMOLTALK_MODEL_DATA_URL) {
43
- url = process.env.SMOLTALK_MODEL_DATA_URL;
40
+ const fromEnv = env("SMOLTALK_MODEL_DATA_URL");
41
+ if (fromEnv) {
42
+ url = fromEnv;
44
43
  }
45
44
  if (opts.url) {
46
45
  url = opts.url;
@@ -0,0 +1,5 @@
1
+ import type { Platform } from "./platform.js";
2
+ export declare const readFileBytes: Platform["readFileBytes"];
3
+ export declare const readTextFromFileUrl: Platform["readTextFromFileUrl"];
4
+ export declare const pathToFileUrl: Platform["pathToFileUrl"];
5
+ export declare const env: Platform["env"];
@@ -0,0 +1,13 @@
1
+ function unavailable(fn) {
2
+ return new Error(`${fn} is not available in a browser build of smoltalk.`);
3
+ }
4
+ export const readFileBytes = async () => {
5
+ throw unavailable("readFileBytes");
6
+ };
7
+ export const readTextFromFileUrl = async () => {
8
+ throw unavailable("readTextFromFileUrl");
9
+ };
10
+ export const pathToFileUrl = () => {
11
+ throw unavailable("pathToFileUrl");
12
+ };
13
+ export const env = () => undefined;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The functions smoltalk takes from the platform it runs on. Two files
3
+ * implement them, `platform.node.ts` and `platform.browser.ts`, and the
4
+ * `#platform` entry in package.json's `imports` picks one: Node resolves
5
+ * it to the Node file, and a bundler run with its browser condition
6
+ * (esbuild `--platform=browser`, Vite, webpack `target: "web"`) picks the
7
+ * browser file. Shared code imports `#platform` and never `node:*`.
8
+ *
9
+ * The browser file throws from every file function and answers `undefined`
10
+ * for every environment variable, so a browser caller passes API keys in
11
+ * and cannot use path attachments, `file:` model data, or the llama.cpp
12
+ * loader's `entryPath`. Pure string work such as a path's extension is not
13
+ * on the platform; see `lib/util/fileExtension.ts`.
14
+ */
15
+ export type Platform = {
16
+ /** The bytes of the file at `path`. With `maxBytes`, refuses a larger
17
+ * file before reading it, with a {@link FileTooLargeError}. */
18
+ readFileBytes(path: string, maxBytes?: number): Promise<Uint8Array>;
19
+ /** The text of the file a `file:` URL names, as UTF-8. */
20
+ readTextFromFileUrl(url: string): Promise<string>;
21
+ /** The `file:` URL for a file system path. */
22
+ pathToFileUrl(path: string): string;
23
+ /** The environment variable `name`, or `undefined` when unset or when
24
+ * the platform has no environment. */
25
+ env(name: string): string | undefined;
26
+ };
27
+ /** Thrown by `readFileBytes` when the file is larger than `maxBytes`. */
28
+ export declare class FileTooLargeError extends Error {
29
+ readonly path: string;
30
+ readonly size: number;
31
+ readonly maxBytes: number;
32
+ constructor(path: string, size: number, maxBytes: number);
33
+ }
@@ -0,0 +1,13 @@
1
+ /** Thrown by `readFileBytes` when the file is larger than `maxBytes`. */
2
+ export class FileTooLargeError extends Error {
3
+ path;
4
+ size;
5
+ maxBytes;
6
+ constructor(path, size, maxBytes) {
7
+ super(`File ${path} is ${size} bytes, over the limit of ${maxBytes} bytes.`);
8
+ this.path = path;
9
+ this.size = size;
10
+ this.maxBytes = maxBytes;
11
+ this.name = "FileTooLargeError";
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ import { type Platform } from "./platform.js";
2
+ export declare const readFileBytes: Platform["readFileBytes"];
3
+ export declare const readTextFromFileUrl: Platform["readTextFromFileUrl"];
4
+ export declare const pathToFileUrl: Platform["pathToFileUrl"];
5
+ export declare const env: Platform["env"];
@@ -0,0 +1,17 @@
1
+ // The Node side of #platform. See platform.ts.
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { FileTooLargeError } from "./platform.js";
5
+ export const readFileBytes = async (path, maxBytes) => {
6
+ if (maxBytes !== undefined) {
7
+ const info = await stat(path);
8
+ if (info.size > maxBytes) {
9
+ throw new FileTooLargeError(path, info.size, maxBytes);
10
+ }
11
+ }
12
+ const buf = await readFile(path);
13
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
14
+ };
15
+ export const readTextFromFileUrl = (url) => readFile(fileURLToPath(url), "utf8");
16
+ export const pathToFileUrl = (path) => pathToFileURL(path).href;
17
+ export const env = (name) => process.env[name];
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes } from "../util/base64.js";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { pcmToWav } from "../util/audioMime.js";
@@ -36,7 +37,7 @@ export class GoogleSpeechClient extends BaseSpeechClient {
36
37
  if (!dataB64) {
37
38
  return failure("Gemini returned no audio data.");
38
39
  }
39
- const pcm = new Uint8Array(Buffer.from(dataB64, "base64"));
40
+ const pcm = base64ToBytes(dataB64);
40
41
  let audio = pcm;
41
42
  let mimeType = "application/octet-stream";
42
43
  if (format === "wav") {
@@ -6,6 +6,7 @@ import { OpenAISpeechClient } from "./openai.js";
6
6
  export class GroqSpeechClient extends OpenAISpeechClient {
7
7
  makeClient() {
8
8
  return new OpenAI({
9
+ dangerouslyAllowBrowser: true,
9
10
  apiKey: this.config.apiKey,
10
11
  baseURL: "https://api.groq.com/openai/v1",
11
12
  });
@@ -21,6 +21,7 @@ export class MlxSpeechClient extends OpenAISpeechClient {
21
21
  const baseURL = resolveBaseUrl("mlx", { baseUrl: this.config.baseUrl });
22
22
  // The OpenAI SDK refuses an empty key. The local server never reads it.
23
23
  return new OpenAI({
24
+ dangerouslyAllowBrowser: true,
24
25
  apiKey: "mlx-local",
25
26
  baseURL,
26
27
  maxRetries: 0,
@@ -5,7 +5,7 @@ import { BaseSpeechClient } from "./baseSpeechClient.js";
5
5
  export class OpenAISpeechClient extends BaseSpeechClient {
6
6
  /** Build the OpenAI SDK client. Subclasses override to point at a compatible base URL. */
7
7
  makeClient() {
8
- return new OpenAI({ apiKey: this.config.apiKey });
8
+ return new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.config.apiKey });
9
9
  }
10
10
  /** Provider default used when the declarative call omits format. */
11
11
  defaultFormat() {
@@ -14,7 +14,7 @@ export class OpenAiCompatSpeechClient extends OpenAISpeechClient {
14
14
  if (!baseURL) {
15
15
  throw new Error("openai-compat: base URL required (config.baseUrl.openAiCompat or OPENAI_COMPAT_BASE_URL).");
16
16
  }
17
- return new OpenAI({ apiKey: this.config.apiKey, baseURL });
17
+ return new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.config.apiKey, baseURL });
18
18
  }
19
19
  noKeyMessage() {
20
20
  return "No API key provided. Set apiKey.openAiCompat or OPENAI_COMPAT_API_KEY.";
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import { nanoid } from "nanoid";
2
3
  import { failure, mergeResults, success } from "./types/result.js";
3
4
  import { redactAttachments } from "./util/redact.js";
@@ -311,7 +312,7 @@ export function getStatelogClient(config) {
311
312
  const statelogConfig = {
312
313
  host: config.host,
313
314
  traceId: config.traceId || nanoid(),
314
- apiKey: process.env.STATELOG_SMOLTALK_API_KEY || "",
315
+ apiKey: env("STATELOG_SMOLTALK_API_KEY") || "",
315
316
  projectId: config.projectId,
316
317
  debugMode: config.debugMode || false,
317
318
  };
@@ -1,3 +1,4 @@
1
+ import { bytesToBase64, utf8ByteLength } from "../util/base64.js";
1
2
  import { GoogleGenAI } from "@google/genai";
2
3
  import { success, failure } from "../types/result.js";
3
4
  import { BaseTranscriptionClient } from "./baseTranscriptionClient.js";
@@ -22,7 +23,7 @@ export class GoogleTranscriptionClient extends BaseTranscriptionClient {
22
23
  if (this.config.prompt) {
23
24
  instruction += ` ${this.config.prompt}`;
24
25
  }
25
- const base64 = Buffer.from(data).toString("base64");
26
+ const base64 = bytesToBase64(data);
26
27
  const request = {
27
28
  model: this.config.model,
28
29
  contents: [{
@@ -33,7 +34,7 @@ export class GoogleTranscriptionClient extends BaseTranscriptionClient {
33
34
  ],
34
35
  }],
35
36
  };
36
- if (Buffer.byteLength(JSON.stringify(request), "utf8") > GOOGLE_INLINE_REQUEST_MAX_BYTES) {
37
+ if (utf8ByteLength(JSON.stringify(request)) > GOOGLE_INLINE_REQUEST_MAX_BYTES) {
37
38
  return failure("Audio and instructions exceed Gemini's 20 MB inline request limit; use a smaller source.");
38
39
  }
39
40
  const ai = new GoogleGenAI({ apiKey: this.config.apiKey });
@@ -7,6 +7,7 @@ import { OpenAITranscriptionClient } from "./openai.js";
7
7
  export class GroqTranscriptionClient extends OpenAITranscriptionClient {
8
8
  makeClient() {
9
9
  return new OpenAI({
10
+ dangerouslyAllowBrowser: true,
10
11
  apiKey: this.config.apiKey,
11
12
  baseURL: "https://api.groq.com/openai/v1",
12
13
  });
@@ -5,7 +5,7 @@ import { BaseTranscriptionClient } from "./baseTranscriptionClient.js";
5
5
  export class OpenAITranscriptionClient extends BaseTranscriptionClient {
6
6
  /** Build the OpenAI SDK client. Subclasses override to point at a compatible base URL. */
7
7
  makeClient() {
8
- return new OpenAI({ apiKey: this.config.apiKey });
8
+ return new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.config.apiKey });
9
9
  }
10
10
  /** Provider-specific diagnostic when no API key is resolved. Subclasses override. */
11
11
  noKeyMessage() {
@@ -14,7 +14,7 @@ export class OpenAiCompatTranscriptionClient extends OpenAITranscriptionClient {
14
14
  if (!baseURL) {
15
15
  throw new Error("openai-compat: base URL required (config.baseUrl.openAiCompat or OPENAI_COMPAT_BASE_URL).");
16
16
  }
17
- return new OpenAI({ apiKey: this.config.apiKey, baseURL });
17
+ return new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.config.apiKey, baseURL });
18
18
  }
19
19
  noKeyMessage() {
20
20
  return "No API key provided. Set apiKey.openAiCompat or OPENAI_COMPAT_API_KEY.";
@@ -1,3 +1,4 @@
1
+ import { bytesToBase64 } from "./base64.js";
1
2
  /** Filename sent to providers that require one when a file part omits its own. */
2
3
  export const DEFAULT_ATTACHMENT_FILENAME = "attachment.pdf";
3
4
  /** A file part's filename, falling back to the shared default when unset. */
@@ -17,7 +18,7 @@ export function refToBase64(ref) {
17
18
  return { base64: ref.base64, mimeType: ref.mimeType };
18
19
  }
19
20
  if (ref.kind === "bytes") {
20
- return { base64: Buffer.from(ref.data).toString("base64"), mimeType: ref.mimeType };
21
+ return { base64: bytesToBase64(ref.data), mimeType: ref.mimeType };
21
22
  }
22
23
  throw new Error(`Attachment ref must be resolved to base64 or bytes before serialization ` +
23
24
  `(got kind="${ref.kind}"). This is a smoltalk bug.`);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bytes to and from base64, and two other byte helpers, written without
3
+ * `Buffer` so they run in a browser. `Uint8Array.fromBase64` and
4
+ * `toBase64` are used where the runtime has them, and `atob`/`btoa` with a
5
+ * loop elsewhere.
6
+ */
7
+ export declare function bytesToBase64(bytes: Uint8Array): string;
8
+ /**
9
+ * Decodes standard base64 and base64url (`-` and `_` for `+` and `/`),
10
+ * with or without padding. Any other character throws. `Buffer`, which
11
+ * this replaced, skipped characters it did not know.
12
+ */
13
+ export declare function base64ToBytes(text: string): Uint8Array;
14
+ /** One array holding every chunk in order. */
15
+ export declare function concatBytes(chunks: Uint8Array[], total?: number): Uint8Array;
16
+ /** The number of bytes `text` takes as UTF-8. */
17
+ export declare function utf8ByteLength(text: string): number;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Bytes to and from base64, and two other byte helpers, written without
3
+ * `Buffer` so they run in a browser. `Uint8Array.fromBase64` and
4
+ * `toBase64` are used where the runtime has them, and `atob`/`btoa` with a
5
+ * loop elsewhere.
6
+ */
7
+ export function bytesToBase64(bytes) {
8
+ const native = bytes.toBase64;
9
+ if (typeof native === "function") {
10
+ return native.call(bytes);
11
+ }
12
+ let binary = "";
13
+ const chunk = 0x8000;
14
+ for (let i = 0; i < bytes.length; i += chunk) {
15
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
16
+ }
17
+ return btoa(binary);
18
+ }
19
+ /**
20
+ * Decodes standard base64 and base64url (`-` and `_` for `+` and `/`),
21
+ * with or without padding. Any other character throws. `Buffer`, which
22
+ * this replaced, skipped characters it did not know.
23
+ */
24
+ export function base64ToBytes(text) {
25
+ if (text.includes("-") || text.includes("_")) {
26
+ text = text.replace(/-/g, "+").replace(/_/g, "/");
27
+ }
28
+ const native = Uint8Array.fromBase64;
29
+ if (typeof native === "function") {
30
+ return native.call(Uint8Array, text);
31
+ }
32
+ const binary = atob(text);
33
+ const out = new Uint8Array(binary.length);
34
+ for (let i = 0; i < binary.length; i++) {
35
+ out[i] = binary.charCodeAt(i);
36
+ }
37
+ return out;
38
+ }
39
+ /** One array holding every chunk in order. */
40
+ export function concatBytes(chunks, total) {
41
+ let length = total;
42
+ if (length === undefined) {
43
+ length = 0;
44
+ for (const chunk of chunks) {
45
+ length += chunk.byteLength;
46
+ }
47
+ }
48
+ const out = new Uint8Array(length);
49
+ let offset = 0;
50
+ for (const chunk of chunks) {
51
+ out.set(chunk, offset);
52
+ offset += chunk.byteLength;
53
+ }
54
+ return out;
55
+ }
56
+ /** The number of bytes `text` takes as UTF-8. */
57
+ export function utf8ByteLength(text) {
58
+ return new TextEncoder().encode(text).byteLength;
59
+ }
@@ -1,5 +1,7 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { extname } from "node:path";
1
+ import { readFileBytes } from "#platform";
2
+ import { FileTooLargeError } from "../platform.js";
3
+ import { base64ToBytes, concatBytes } from "./base64.js";
4
+ import { fileExtension } from "./fileExtension.js";
3
5
  import { EXT_TO_MIME } from "./mime.js";
4
6
  /** Default timeout for fetching attachment URLs during normalization (60 seconds). */
5
7
  export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
@@ -62,7 +64,7 @@ async function readBodyWithLimit(res, maxBytes) {
62
64
  chunks.push(value);
63
65
  }
64
66
  // Pass the known total so concat doesn't re-scan the chunk list for its length.
65
- return new Uint8Array(Buffer.concat(chunks, total));
67
+ return concatBytes(chunks, total);
66
68
  }
67
69
  async function loadRef(ref, allowed, maxBytes) {
68
70
  switch (ref.kind) {
@@ -70,26 +72,30 @@ async function loadRef(ref, allowed, maxBytes) {
70
72
  return { data: ref.data, mimeType: ref.mimeType };
71
73
  case "base64":
72
74
  return {
73
- data: new Uint8Array(Buffer.from(ref.base64, "base64")),
75
+ data: base64ToBytes(ref.base64),
74
76
  mimeType: ref.mimeType,
75
77
  };
76
78
  case "path": {
77
- // S1: bound memory — reject on stat before reading the whole file.
78
- if (maxBytes !== undefined) {
79
- const info = await stat(ref.path);
80
- if (info.size > maxBytes) {
81
- throw tooLargeError(maxBytes, info.size);
79
+ // S1: bound memory — readFileBytes rejects on the file's size before
80
+ // reading it. In a browser it throws: there is no file system.
81
+ let buf;
82
+ try {
83
+ buf = await readFileBytes(ref.path, maxBytes);
84
+ }
85
+ catch (err) {
86
+ if (err instanceof FileTooLargeError) {
87
+ throw tooLargeError(err.maxBytes, err.size);
82
88
  }
89
+ throw err;
83
90
  }
84
- const buf = await readFile(ref.path);
85
- const ext = extname(ref.path).toLowerCase();
91
+ const ext = fileExtension(ref.path);
86
92
  const inferred = EXT_TO_MIME[ext];
87
93
  const mimeType = ref.mimeType ?? inferred;
88
94
  if (allowed !== null && (!mimeType || !isAllowedMime(mimeType, allowed))) {
89
95
  throw new Error(`Could not determine an allowed MIME type for path "${ref.path}". ` +
90
96
  `Allowed: ${allowed.join(", ")}. Pass an explicit mimeType on the BlobRef.`);
91
97
  }
92
- return { data: new Uint8Array(buf), mimeType };
98
+ return { data: buf, mimeType };
93
99
  }
94
100
  case "url": {
95
101
  // S2: only http(s) — reject file:/ftp:/gopher:/data: etc.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The extension of a path including its dot, lower-cased, or "" when there
3
+ * is none. Follows Node's `path.extname`: only the last segment counts, a
4
+ * leading dot (`.bashrc`) is not an extension, and a trailing dot gives ".", and ".." gives "".
5
+ * String work only, so it is the same on every platform.
6
+ */
7
+ export declare function fileExtension(path: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The extension of a path including its dot, lower-cased, or "" when there
3
+ * is none. Follows Node's `path.extname`: only the last segment counts, a
4
+ * leading dot (`.bashrc`) is not an extension, and a trailing dot gives ".", and ".." gives "".
5
+ * String work only, so it is the same on every platform.
6
+ */
7
+ export function fileExtension(path) {
8
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
9
+ const base = path.slice(slash + 1);
10
+ const dot = base.lastIndexOf(".");
11
+ if (dot <= 0 || base === "..") {
12
+ return "";
13
+ }
14
+ return base.slice(dot).toLowerCase();
15
+ }
@@ -36,6 +36,7 @@ type NestedKeyConfig = {
36
36
  };
37
37
  /**
38
38
  * Resolve the API key for a provider, checking config then env vars.
39
+ * In a browser `env` answers nothing, so the caller passes the key.
39
40
  */
40
41
  export declare function resolveApiKey(provider: string, config: NestedKeyConfig): string | undefined;
41
42
  /**
@@ -1,3 +1,4 @@
1
+ import { env } from "#platform";
1
2
  import { getModel } from "../models.js";
2
3
  import { SmolError } from "../smolError.js";
3
4
  /**
@@ -16,31 +17,32 @@ export function resolveProvider(modelName, explicitProvider, modelData) {
16
17
  }
17
18
  /**
18
19
  * Resolve the API key for a provider, checking config then env vars.
20
+ * In a browser `env` answers nothing, so the caller passes the key.
19
21
  */
20
22
  export function resolveApiKey(provider, config) {
21
23
  const k = config.apiKey;
22
24
  switch (provider) {
23
25
  case "openai":
24
26
  case "openai-responses":
25
- return k?.openAi || process.env.OPENAI_API_KEY;
27
+ return k?.openAi || env("OPENAI_API_KEY");
26
28
  case "google":
27
- return k?.google || process.env.GEMINI_API_KEY;
29
+ return k?.google || env("GEMINI_API_KEY");
28
30
  case "anthropic":
29
- return k?.anthropic || process.env.ANTHROPIC_API_KEY;
31
+ return k?.anthropic || env("ANTHROPIC_API_KEY");
30
32
  case "ollama":
31
33
  return k?.ollama;
32
34
  case "openrouter":
33
- return k?.openRouter || process.env.OPENROUTER_API_KEY;
35
+ return k?.openRouter || env("OPENROUTER_API_KEY");
34
36
  case "deepinfra":
35
- return k?.deepInfra || process.env.DEEPINFRA_API_KEY;
37
+ return k?.deepInfra || env("DEEPINFRA_API_KEY");
36
38
  case "litellm":
37
- return k?.liteLlm || process.env.LITELLM_API_KEY;
39
+ return k?.liteLlm || env("LITELLM_API_KEY");
38
40
  case "openai-compat":
39
- return k?.openAiCompat || process.env.OPENAI_COMPAT_API_KEY;
41
+ return k?.openAiCompat || env("OPENAI_COMPAT_API_KEY");
40
42
  case "groq":
41
- return k?.groq || process.env.GROQ_API_KEY;
43
+ return k?.groq || env("GROQ_API_KEY");
42
44
  case "typesafe":
43
- return k?.typesafe || process.env.TYPESAFE_API_KEY;
45
+ return k?.typesafe || env("TYPESAFE_API_KEY");
44
46
  default:
45
47
  return config.apiKey?.[provider];
46
48
  }
@@ -57,20 +59,20 @@ export function resolveBaseUrl(provider, config) {
57
59
  const b = config.baseUrl;
58
60
  switch (provider) {
59
61
  case "ollama":
60
- return b?.ollama || process.env.OLLAMA_HOST;
62
+ return b?.ollama || env("OLLAMA_HOST");
61
63
  case "openrouter":
62
64
  return b?.openRouter || "https://openrouter.ai/api/v1";
63
65
  case "deepinfra":
64
66
  return b?.deepInfra || "https://api.deepinfra.com/v1/openai";
65
67
  case "litellm":
66
- return b?.liteLlm || process.env.LITELLM_BASE_URL;
68
+ return b?.liteLlm || env("LITELLM_BASE_URL");
67
69
  case "openai-compat":
68
- return b?.openAiCompat || process.env.OPENAI_COMPAT_BASE_URL;
70
+ return b?.openAiCompat || env("OPENAI_COMPAT_BASE_URL");
69
71
  case "mlx":
70
- return b?.mlx || process.env.MLX_BASE_URL || "http://127.0.0.1:8080/v1";
72
+ return b?.mlx || env("MLX_BASE_URL") || "http://127.0.0.1:8080/v1";
71
73
  case "typesafe":
72
74
  // A Laya server (`laya-serve`) speaks the same protocol; point this at it.
73
- return b?.typesafe || process.env.TYPESAFE_BASE_URL || "https://api.typesafe.ai";
75
+ return b?.typesafe || env("TYPESAFE_BASE_URL") || "https://api.typesafe.ai";
74
76
  default:
75
77
  return undefined;
76
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [
@@ -23,6 +23,16 @@
23
23
  "require": "./dist/testing/index.js"
24
24
  }
25
25
  },
26
+ "imports": {
27
+ "#platform": {
28
+ "browser": "./dist/platform.browser.js",
29
+ "default": "./dist/platform.node.js"
30
+ },
31
+ "#ollama": {
32
+ "browser": "ollama/browser",
33
+ "default": "ollama"
34
+ }
35
+ },
26
36
  "type": "module",
27
37
  "types": "./dist/index.d.ts",
28
38
  "keywords": [
@@ -41,6 +51,7 @@
41
51
  "zod": "^4.4.3"
42
52
  },
43
53
  "devDependencies": {
54
+ "esbuild": "^0.28.2",
44
55
  "tsx": "^4.19.2"
45
56
  },
46
57
  "peerDependencies": {
@@ -60,6 +71,7 @@
60
71
  "doc": "typedoc --disableSources --out docs lib && prettier docs/ --write",
61
72
  "typecheck": "tsc --noEmit",
62
73
  "seed-data": "tsx scripts/seed-model-data.ts",
63
- "refresh-data": "tsx scripts/refresh-from-modelsdev.ts"
74
+ "refresh-data": "tsx scripts/refresh-from-modelsdev.ts",
75
+ "check:browser": "node scripts/check-browser-bundle.mjs"
64
76
  }
65
77
  }