smoltalk 0.13.2 → 0.14.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/README.md CHANGED
@@ -250,7 +250,7 @@ model ids aren't in the smoltalk registry.
250
250
 
251
251
  | `provider:` | What it is | Required config | Cost source |
252
252
  |-------------|------------|-----------------|-------------|
253
- | `"openrouter"` | OpenRouter.ai aggregator | `apiKey.openRouter` (or `OPENROUTER_API_KEY`) | `usage.cost` (auto-enabled by injecting `usage:{include:true}`) |
253
+ | `"openrouter"` | OpenRouter.ai aggregator | `apiKey.openRouter` (or `OPENROUTER_API_KEY`) | `usage.cost` |
254
254
  | `"deepinfra"` | DeepInfra hosted models | `apiKey.deepInfra` (or `DEEPINFRA_API_KEY`) | `usage.estimated_cost` |
255
255
  | `"litellm"` | Your own LiteLLM proxy | `apiKey.liteLlm` + `baseUrl.liteLlm` (or `LITELLM_API_KEY` / `LITELLM_BASE_URL`) | `x-litellm-response-cost` header (non-stream only) |
256
256
  | `"openai-compat"` | Any OpenAI-shape backend (vLLM, TGI, LM Studio…) | `apiKey.openAiCompat` + `baseUrl.openAiCompat` (or `OPENAI_COMPAT_API_KEY` / `OPENAI_COMPAT_BASE_URL`) | Best-effort: reads `usage.cost`/`estimated_cost`/`cost_usd` if present |
@@ -276,7 +276,7 @@ const r = await textSync({
276
276
  | `deepinfra` | ✅ | ✅ | ❌ (uses per-model endpoints, not OpenAI shape) | ❌ |
277
277
  | `litellm` | ✅ | ✅ | ✅ (if the upstream model supports it) | ✅ (if upstream supports it) |
278
278
  | `openai-compat` | ✅ | ✅ | ✅ (backend-dependent) | depends on backend |
279
- | `mlx` | ✅ | ❌ | ❌ | ❌ |
279
+ | `mlx` | ✅ | ✅ | ❌ | ❌ |
280
280
 
281
281
  Smoltalk surfaces a clear `failure(...)` from `embed()`/`image()` for the
282
282
  unsupported combinations rather than silently dropping the call.
@@ -543,6 +543,11 @@ const { resolveModel } = await loadLlamaCpp({
543
543
  const modelPath = await resolveModel("hf:org/repo/model.gguf", "/models/cache");
544
544
  ```
545
545
 
546
+ `embed()` with `provider: "llama-cpp"` and a local `.gguf` path works the
547
+ same way and needs `smoltalk-llama-cpp` >= 0.5.0; the vector is computed in
548
+ process. See that package's README for the embedding caveats (dimension
549
+ truncation, one model file per role).
550
+
546
551
  ## Audio (STT/TTS)
547
552
 
548
553
  Three audio primitives. `transcribe()` (speech-to-text) and `speak()`
@@ -556,6 +561,10 @@ that exposes OpenAI-shaped `/audio/*` endpoints, use the generic
556
561
  **`openai-compat`** provider with `baseUrl` (mirrors the chat client). Anthropic,
557
562
  OpenRouter, and Ollama have no audio endpoints and return a `Failure`.
558
563
 
564
+ `speak()` also supports **`mlx`**: a local MLX speech server at `baseUrl.mlx`,
565
+ `MLX_BASE_URL`, or `http://127.0.0.1:8080/v1`. No API key, no retries, zero
566
+ cost, and `"wav"` by default.
567
+
559
568
  ```ts
560
569
  // example: skip-typecheck
561
570
  // Groq STT (OpenAI-compatible; provider inferred from the model)
@@ -650,7 +659,11 @@ if (result.success) {
650
659
  `tts-1` and `tts-1-hd` are the only baked-in models in v1. `voice` is
651
660
  required. Options: `format` (OpenAI accepts `"mp3"` | `"opus"` | `"aac"` |
652
661
  `"flac"` | `"wav"` | `"pcm"`, default `"mp3"`; a custom provider may accept
653
- other strings) and `speed`. Limits are declared per model in the registry —
662
+ other strings), `speed`, and `instructions`. `instructions` is free-text
663
+ guidance on how the speech should sound (`"Alarmed and urgent."`). It is passed
664
+ through as-is by the OpenAI-shaped providers (`openai`, `groq`, `openai-compat`,
665
+ `mlx`); whether the model reads it is up to the model (`gpt-4o-mini-tts` does,
666
+ `tts-1` does not). The `google` provider ignores it. Limits are declared per model in the registry —
654
667
  for `tts-1`/`tts-1-hd` that's a 4096-code-point input cap, a 0.25–4.0 speed
655
668
  range, and the format list above; exceeding any of them returns a `Failure`
656
669
  before the request is sent. The returned `audio` is a `Uint8Array` you own —
@@ -260,6 +260,9 @@ export class SmolGoogle extends BaseClient {
260
260
  });
261
261
  });
262
262
  const genConfig = {};
263
+ if (config.maxTokens !== undefined) {
264
+ genConfig.maxOutputTokens = config.maxTokens;
265
+ }
263
266
  if (systemParts.length > 0) {
264
267
  genConfig.systemInstruction = systemParts.join("\n");
265
268
  }
@@ -1,4 +1,5 @@
1
1
  import type { BaseClient } from "./baseClient.js";
2
+ import type { EmbedProvider } from "../embed.js";
2
3
  /**
3
4
  * Minimal structural view of smoltalk-llama-cpp's module. Declared here (not
4
5
  * imported from the plugin) so smoltalk compiles without the plugin installed
@@ -7,6 +8,9 @@ import type { BaseClient } from "./baseClient.js";
7
8
  export type LlamaCppModule = {
8
9
  LlamaCPP: typeof BaseClient;
9
10
  resolveModel: (uriOrPath: string, cacheDir: string) => Promise<string>;
11
+ /** Present from smoltalk-llama-cpp 0.5.0. Absent on older plugins, which
12
+ * then serve chat only. */
13
+ embed?: EmbedProvider;
10
14
  };
11
15
  type ImportFn = (specifier: string) => Promise<Record<string, unknown>>;
12
16
  /**
@@ -85,6 +85,9 @@ export class SmolOllama extends BaseClient {
85
85
  if (config.responseFormat) {
86
86
  request.format = responseFormatToJsonSchema(config.responseFormat);
87
87
  }
88
+ if (config.maxTokens !== undefined) {
89
+ request.options = { num_predict: config.maxTokens };
90
+ }
88
91
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
89
92
  this.logger.debug("Sending request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
90
93
  this.statelogClient?.promptRequest(request);
@@ -151,6 +154,9 @@ export class SmolOllama extends BaseClient {
151
154
  if (config.responseFormat) {
152
155
  request.format = responseFormatToJsonSchema(config.responseFormat);
153
156
  }
157
+ if (config.maxTokens !== undefined) {
158
+ request.options = { num_predict: config.maxTokens };
159
+ }
154
160
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
155
161
  this.logger.debug("Sending streaming request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
156
162
  this.statelogClient?.promptRequest(request);
@@ -32,11 +32,18 @@ export declare class SmolOpenAi extends BaseClient implements SmolClient {
32
32
  protected resolveCostUsd(_usage: any, _rawResponse?: Response): number | undefined;
33
33
  /**
34
34
  * Extra request body fields injected on every call. Subclasses override to
35
- * add provider-specific request shapes (e.g. OpenRouter's `usage: { include: true }`
36
- * or its `plugins: [{ id: "web" }]` for web search). Merged into the request
35
+ * add provider-specific request shapes (e.g. OpenRouter's
36
+ * `plugins: [{ id: "web" }]` for web search). Merged into the request
37
37
  * after the standard params so it can override them.
38
38
  */
39
39
  protected buildRequestExtras(_config: SmolConfig): Record<string, unknown>;
40
+ /**
41
+ * The request field carrying `config.maxTokens`. OpenAI deprecated
42
+ * `max_tokens` in favor of `max_completion_tokens`, and its reasoning models
43
+ * reject `max_tokens` outright. Compat servers override this to send
44
+ * `max_tokens`, which is the field they reliably accept.
45
+ */
46
+ protected maxTokensParam(config: SmolConfig): Record<string, number>;
40
47
  /**
41
48
  * Extract provider-specific hosted-tool results (e.g. OpenRouter web_search
42
49
  * annotations) from a completion. Subclasses override; default returns none.
@@ -51,13 +51,25 @@ export class SmolOpenAi extends BaseClient {
51
51
  }
52
52
  /**
53
53
  * Extra request body fields injected on every call. Subclasses override to
54
- * add provider-specific request shapes (e.g. OpenRouter's `usage: { include: true }`
55
- * or its `plugins: [{ id: "web" }]` for web search). Merged into the request
54
+ * add provider-specific request shapes (e.g. OpenRouter's
55
+ * `plugins: [{ id: "web" }]` for web search). Merged into the request
56
56
  * after the standard params so it can override them.
57
57
  */
58
58
  buildRequestExtras(_config) {
59
59
  return {};
60
60
  }
61
+ /**
62
+ * The request field carrying `config.maxTokens`. OpenAI deprecated
63
+ * `max_tokens` in favor of `max_completion_tokens`, and its reasoning models
64
+ * reject `max_tokens` outright. Compat servers override this to send
65
+ * `max_tokens`, which is the field they reliably accept.
66
+ */
67
+ maxTokensParam(config) {
68
+ if (config.maxTokens === undefined) {
69
+ return {};
70
+ }
71
+ return { max_completion_tokens: config.maxTokens };
72
+ }
61
73
  /**
62
74
  * Extract provider-specific hosted-tool results (e.g. OpenRouter web_search
63
75
  * annotations) from a completion. Subclasses override; default returns none.
@@ -126,6 +138,7 @@ export class SmolOpenAi extends BaseClient {
126
138
  ...(config.reasoningEffort && {
127
139
  reasoning_effort: config.reasoningEffort,
128
140
  }),
141
+ ...this.maxTokensParam(config),
129
142
  ...sanitizeAttributes(config.rawAttributes),
130
143
  ...this.buildRequestExtras(config),
131
144
  };
@@ -20,5 +20,6 @@ export declare class SmolOpenAiCompat extends SmolOpenAi {
20
20
  apiKey: string;
21
21
  baseURL: string;
22
22
  };
23
+ protected maxTokensParam(config: SmolConfig): Record<string, number>;
23
24
  protected resolveCostUsd(usage: any): number | undefined;
24
25
  }
@@ -30,6 +30,14 @@ export class SmolOpenAiCompat extends SmolOpenAi {
30
30
  }
31
31
  return { apiKey, baseURL };
32
32
  }
33
+ // Compat servers (OpenRouter, DeepInfra, LiteLLM, vLLM, mlx_lm.server) all
34
+ // accept `max_tokens`; `max_completion_tokens` support is patchy.
35
+ maxTokensParam(config) {
36
+ if (config.maxTokens === undefined) {
37
+ return {};
38
+ }
39
+ return { max_tokens: config.maxTokens };
40
+ }
33
41
  resolveCostUsd(usage) {
34
42
  // Try the three common conventions across OpenAI-compatible providers.
35
43
  const c = usage?.cost ?? usage?.estimated_cost ?? usage?.cost_usd;
@@ -5,9 +5,8 @@ import type { SmolConfig, HostedToolResult } from "../types.js";
5
5
  *
6
6
  * - Baked base URL `https://openrouter.ai/api/v1` (override via config.baseUrl.openRouter).
7
7
  * - Key: config.apiKey.openRouter or env OPENROUTER_API_KEY.
8
- * - Cost: reads `usage.cost` (USD). OpenRouter only returns this when
9
- * `usage: { include: true }` is set in the request body, which this client
10
- * injects automatically via buildRequestExtras.
8
+ * - Cost: reads `usage.cost` (USD). OpenRouter includes this in every
9
+ * response (in the final SSE chunk when streaming); no request flag needed.
11
10
  * - Hosted web_search: when config.hostedTools includes "web_search",
12
11
  * injects `plugins: [{ id: "web", max_results: 5 }]` and parses
13
12
  * message.annotations into a HostedToolResult.
@@ -6,9 +6,8 @@ import { resolveApiKey, resolveBaseUrl } from "../util/provider.js";
6
6
  *
7
7
  * - Baked base URL `https://openrouter.ai/api/v1` (override via config.baseUrl.openRouter).
8
8
  * - Key: config.apiKey.openRouter or env OPENROUTER_API_KEY.
9
- * - Cost: reads `usage.cost` (USD). OpenRouter only returns this when
10
- * `usage: { include: true }` is set in the request body, which this client
11
- * injects automatically via buildRequestExtras.
9
+ * - Cost: reads `usage.cost` (USD). OpenRouter includes this in every
10
+ * response (in the final SSE chunk when streaming); no request flag needed.
12
11
  * - Hosted web_search: when config.hostedTools includes "web_search",
13
12
  * injects `plugins: [{ id: "web", max_results: 5 }]` and parses
14
13
  * message.annotations into a HostedToolResult.
@@ -27,8 +26,7 @@ export class SmolOpenRouter extends SmolOpenAiCompat {
27
26
  return typeof usage?.cost === "number" ? usage.cost : undefined;
28
27
  }
29
28
  buildRequestExtras(config) {
30
- // OpenRouter only returns usage.cost when this is set.
31
- const extras = { usage: { include: true } };
29
+ const extras = {};
32
30
  if (config.hostedTools?.includes("web_search")) {
33
31
  extras.plugins = [{ id: "web", max_results: 5 }];
34
32
  }
@@ -0,0 +1,15 @@
1
+ import { EmbedConfig, EmbedResult } from "../embed.js";
2
+ import { Result } from "../types/result.js";
3
+ /**
4
+ * Embeddings from an MLX server on localhost (`agency local serve
5
+ * --embedding …`, or any server with an OpenAI-shaped /v1/embeddings
6
+ * route). The same call the OpenAI helper makes, with the two things the
7
+ * chat client also fixes: the key is a placeholder the server ignores, and
8
+ * the cost is zero. `dimensions` is passed through as-is; whether the
9
+ * server honours it depends on the server and model.
10
+ *
11
+ * Float encoding is requested because a base URL is given (see
12
+ * openaiEmbed): a local server that ignores the SDK's base64 default and
13
+ * returns float arrays would otherwise yield empty vectors.
14
+ */
15
+ export declare function mlxEmbed(inputs: string[], config: EmbedConfig, baseURL: string): Promise<Result<EmbedResult>>;
@@ -0,0 +1,24 @@
1
+ import { success } from "../types/result.js";
2
+ import { openaiEmbed } from "./openai.js";
3
+ /**
4
+ * Embeddings from an MLX server on localhost (`agency local serve
5
+ * --embedding …`, or any server with an OpenAI-shaped /v1/embeddings
6
+ * route). The same call the OpenAI helper makes, with the two things the
7
+ * chat client also fixes: the key is a placeholder the server ignores, and
8
+ * the cost is zero. `dimensions` is passed through as-is; whether the
9
+ * server honours it depends on the server and model.
10
+ *
11
+ * Float encoding is requested because a base URL is given (see
12
+ * openaiEmbed): a local server that ignores the SDK's base64 default and
13
+ * returns float arrays would otherwise yield empty vectors.
14
+ */
15
+ export async function mlxEmbed(inputs, config, baseURL) {
16
+ const result = await openaiEmbed(inputs, config, "mlx-local", baseURL);
17
+ if (!result.success) {
18
+ return result;
19
+ }
20
+ return success({
21
+ ...result.value,
22
+ costEstimate: { inputCost: 0, outputCost: 0, totalCost: 0, currency: "USD" },
23
+ });
24
+ }
@@ -1,9 +1,19 @@
1
1
  import { EmbedConfig, EmbedResult } from "../embed.js";
2
2
  import { Result } from "../types/result.js";
3
+ export type OpenAiEmbedOptions = {
4
+ /**
5
+ * Wire encoding to ask the server for. Left unset, real OpenAI (no
6
+ * baseURL) keeps the SDK's base64 default, and every other backend gets
7
+ * "float": the SDK decodes an unrequested reply as base64 no matter what
8
+ * came back, so a server that ignores the field and returns float arrays
9
+ * would yield empty vectors. Set explicitly to override either default.
10
+ */
11
+ encodingFormat?: "float" | "base64";
12
+ };
3
13
  /**
4
14
  * OpenAI-compatible embedding call. Used by openai directly and by other
5
15
  * OpenAI-shape backends (deepinfra, litellm, openai-compat) which pass a
6
16
  * custom `baseURL`. Cost comes from the smoltalk model registry; provider-
7
17
  * returned cost fields aren't standardized on this endpoint.
8
18
  */
9
- export declare function openaiEmbed(inputs: string[], config: EmbedConfig, apiKey: string, baseURL?: string): Promise<Result<EmbedResult>>;
19
+ export declare function openaiEmbed(inputs: string[], config: EmbedConfig, apiKey: string, baseURL?: string, options?: OpenAiEmbedOptions): Promise<Result<EmbedResult>>;
@@ -2,25 +2,47 @@ import OpenAI from "openai";
2
2
  import { success, failure } from "../types/result.js";
3
3
  import { getModel, isEmbeddingsModel } from "../models.js";
4
4
  import { round } from "../util/util.js";
5
+ /**
6
+ * A server may answer a float request with base64 anyway (some always
7
+ * encode). Once we name an encoding the SDK returns the body untouched, so
8
+ * handle both shapes here. Little-endian float32, the same layout the SDK's
9
+ * own decoder assumes.
10
+ */
11
+ function toFloats(embedding) {
12
+ if (typeof embedding === "string") {
13
+ const bytes = Buffer.from(embedding, "base64");
14
+ const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
15
+ return Array.from(view);
16
+ }
17
+ return embedding;
18
+ }
5
19
  /**
6
20
  * OpenAI-compatible embedding call. Used by openai directly and by other
7
21
  * OpenAI-shape backends (deepinfra, litellm, openai-compat) which pass a
8
22
  * custom `baseURL`. Cost comes from the smoltalk model registry; provider-
9
23
  * returned cost fields aren't standardized on this endpoint.
10
24
  */
11
- export async function openaiEmbed(inputs, config, apiKey, baseURL) {
25
+ export async function openaiEmbed(inputs, config, apiKey, baseURL, options) {
12
26
  try {
13
27
  const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
14
- const response = await client.embeddings.create({
28
+ const body = {
15
29
  model: config.model,
16
30
  input: inputs,
17
- ...(config.dimensions !== undefined
18
- ? { dimensions: config.dimensions }
19
- : {}),
20
- });
31
+ };
32
+ if (config.dimensions !== undefined) {
33
+ body.dimensions = config.dimensions;
34
+ }
35
+ let encodingFormat = options?.encodingFormat;
36
+ if (encodingFormat === undefined && baseURL !== undefined) {
37
+ encodingFormat = "float";
38
+ }
39
+ if (encodingFormat !== undefined) {
40
+ body.encoding_format = encodingFormat;
41
+ }
42
+ const response = await client.embeddings.create(body);
21
43
  const embeddings = [...response.data]
22
44
  .sort((a, b) => a.index - b.index)
23
- .map((d) => d.embedding);
45
+ .map((d) => toFloats(d.embedding));
24
46
  const inputTokens = response.usage.prompt_tokens;
25
47
  const costEstimate = calculateEmbeddingCost(config.model, inputTokens, config.modelData);
26
48
  return success({
package/dist/embed.d.ts CHANGED
@@ -25,6 +25,7 @@ export type EmbedConfig = {
25
25
  deepInfra?: string;
26
26
  liteLlm?: string;
27
27
  openAiCompat?: string;
28
+ mlx?: string;
28
29
  /** Arbitrary provider names, for URLs targeting a custom-registered provider. */
29
30
  [provider: string]: string | undefined;
30
31
  };
@@ -39,4 +40,9 @@ export type EmbedResult = {
39
40
  };
40
41
  export type EmbedProvider = (inputs: string[], config: EmbedConfig) => Promise<Result<EmbedResult>>;
41
42
  export declare function registerEmbeddingProvider(name: string, fn: EmbedProvider): void;
43
+ /** True when `name` has an embed provider registered through
44
+ * registerEmbeddingProvider. The built-in cases in embed() are not its
45
+ * concern, the same as hasProvider in client.ts. */
46
+ export declare function hasEmbeddingProvider(name: string): boolean;
47
+ export declare function unregisterEmbeddingProvider(name: string): boolean;
42
48
  export declare function embed(input: string | string[], config: EmbedConfig): Promise<Result<EmbedResult>>;
package/dist/embed.js CHANGED
@@ -3,12 +3,33 @@ import { resolveProvider, resolveApiKey, resolveBaseUrl } from "./util/provider.
3
3
  import { openaiEmbed } from "./embed/openai.js";
4
4
  import { googleEmbed } from "./embed/google.js";
5
5
  import { ollamaEmbed } from "./embed/ollama.js";
6
+ import { mlxEmbed } from "./embed/mlx.js";
7
+ import { loadLlamaCpp } from "./clients/llamaCppLoader.js";
8
+ function errorMessage(err) {
9
+ if (err instanceof Error) {
10
+ return err.message;
11
+ }
12
+ return String(err);
13
+ }
6
14
  // Null-prototype so provider names like "toString"/"__proto__" can't collide
7
15
  // with Object.prototype or pollute the registry.
8
16
  const registeredEmbedProviders = Object.create(null);
9
17
  export function registerEmbeddingProvider(name, fn) {
10
18
  registeredEmbedProviders[name] = fn;
11
19
  }
20
+ /** True when `name` has an embed provider registered through
21
+ * registerEmbeddingProvider. The built-in cases in embed() are not its
22
+ * concern, the same as hasProvider in client.ts. */
23
+ export function hasEmbeddingProvider(name) {
24
+ return name in registeredEmbedProviders;
25
+ }
26
+ export function unregisterEmbeddingProvider(name) {
27
+ if (name in registeredEmbedProviders) {
28
+ delete registeredEmbedProviders[name];
29
+ return true;
30
+ }
31
+ return false;
32
+ }
12
33
  export async function embed(input, config) {
13
34
  const inputs = Array.isArray(input) ? input : [input];
14
35
  let provider;
@@ -63,6 +84,31 @@ export async function embed(input, config) {
63
84
  }
64
85
  return openaiEmbed(inputs, config, apiKey, baseURL);
65
86
  }
87
+ case "mlx": {
88
+ // resolveBaseUrl always returns a value for "mlx" (it has a default).
89
+ return mlxEmbed(inputs, config, resolveBaseUrl("mlx", config));
90
+ }
91
+ case "llama-cpp": {
92
+ // A hand-registered provider wins, the same rule loadLlamaCpp applies
93
+ // to the chat class. Otherwise load the plugin the way text() does;
94
+ // the loader caches its import.
95
+ const custom = registeredEmbedProviders[provider];
96
+ if (custom) {
97
+ return custom(inputs, config);
98
+ }
99
+ let plugin;
100
+ try {
101
+ plugin = await loadLlamaCpp();
102
+ }
103
+ catch (err) {
104
+ return failure(errorMessage(err));
105
+ }
106
+ if (typeof plugin.embed !== "function") {
107
+ return failure("Your installed smoltalk-llama-cpp has no embeddings support. " +
108
+ "Upgrade it (npm i smoltalk-llama-cpp@latest; >=0.5.0 required).");
109
+ }
110
+ return plugin.embed(inputs, config);
111
+ }
66
112
  default: {
67
113
  const custom = registeredEmbedProviders[provider];
68
114
  if (custom) {
@@ -11,6 +11,11 @@ export type SpeechClientConfig = {
11
11
  /** Base-URL map (for OpenAI-compatible providers); read via resolveBaseUrl. */
12
12
  baseUrl?: SmolConfig["baseUrl"];
13
13
  voice: string;
14
+ /** How the speech should sound, in plain words: "Alarmed and urgent."
15
+ * Passed through as-is by the OpenAI-shaped providers (openai, groq,
16
+ * openai-compat, mlx); whether the model reads it is up to the model
17
+ * (OpenAI's tts-1 and tts-1-hd do not). The google provider ignores it. */
18
+ instructions?: string;
14
19
  modelData?: ModelDataBlob;
15
20
  /** Output format; provider-specific vocabulary (OpenAI: mp3/opus/aac/flac/wav/pcm). */
16
21
  format?: string;
@@ -0,0 +1,23 @@
1
+ import OpenAI from "openai";
2
+ import { Result } from "../types/result.js";
3
+ import type { SpeakFormat } from "../util/audioMime.js";
4
+ import { OpenAISpeechClient } from "./openai.js";
5
+ import type { SpeechResult } from "../speech.js";
6
+ /**
7
+ * Speech from an MLX server on localhost, usually `agency local serve
8
+ * --speech <model>`. The same request the OpenAI client makes, with the
9
+ * things the chat and embedding mlx clients also fix: the base URL has a
10
+ * default, the key is a placeholder the server ignores, and the cost is
11
+ * zero. It also never retries. The server runs one generation at a time,
12
+ * so a retry would wait behind the request that just timed out and then
13
+ * generate the same text again.
14
+ */
15
+ export declare class MlxSpeechClient extends OpenAISpeechClient {
16
+ protected makeClient(): OpenAI;
17
+ protected requiresKey(): boolean;
18
+ protected defaultFormat(): SpeakFormat;
19
+ /** Local speech is free. Set after the base class's cost pass, so model
20
+ * data that prices this model cannot override it (matching the chat mlx
21
+ * client, whose provider cost of 0 wins over registry pricing). */
22
+ speak(text: string): Promise<Result<SpeechResult>>;
23
+ }
@@ -0,0 +1,49 @@
1
+ import OpenAI from "openai";
2
+ import { success } from "../types/result.js";
3
+ import { resolveBaseUrl } from "../util/provider.js";
4
+ import { OpenAISpeechClient } from "./openai.js";
5
+ /** How long one request may take. Equal to the SDK default; stated here so
6
+ * the ceiling is visible. A local model generating a long piece of text is
7
+ * slow, and the caller keeps its requests short. */
8
+ const MLX_SPEECH_TIMEOUT_MS = 600_000;
9
+ /**
10
+ * Speech from an MLX server on localhost, usually `agency local serve
11
+ * --speech <model>`. The same request the OpenAI client makes, with the
12
+ * things the chat and embedding mlx clients also fix: the base URL has a
13
+ * default, the key is a placeholder the server ignores, and the cost is
14
+ * zero. It also never retries. The server runs one generation at a time,
15
+ * so a retry would wait behind the request that just timed out and then
16
+ * generate the same text again.
17
+ */
18
+ export class MlxSpeechClient extends OpenAISpeechClient {
19
+ makeClient() {
20
+ // resolveBaseUrl always returns a value for "mlx" (it has a default).
21
+ const baseURL = resolveBaseUrl("mlx", { baseUrl: this.config.baseUrl });
22
+ // The OpenAI SDK refuses an empty key. The local server never reads it.
23
+ return new OpenAI({
24
+ apiKey: "mlx-local",
25
+ baseURL,
26
+ maxRetries: 0,
27
+ timeout: MLX_SPEECH_TIMEOUT_MS,
28
+ });
29
+ }
30
+ requiresKey() {
31
+ return false;
32
+ }
33
+ defaultFormat() {
34
+ return "wav";
35
+ }
36
+ /** Local speech is free. Set after the base class's cost pass, so model
37
+ * data that prices this model cannot override it (matching the chat mlx
38
+ * client, whose provider cost of 0 wins over registry pricing). */
39
+ async speak(text) {
40
+ const result = await super.speak(text);
41
+ if (!result.success) {
42
+ return result;
43
+ }
44
+ return success({
45
+ ...result.value,
46
+ cost: { inputCost: 0, outputCost: 0, totalCost: 0, currency: "USD" },
47
+ });
48
+ }
49
+ }
@@ -10,5 +10,8 @@ export declare class OpenAISpeechClient extends BaseSpeechClient {
10
10
  protected defaultFormat(): SpeakFormat;
11
11
  /** Provider-specific diagnostic when no API key is resolved. Subclasses override. */
12
12
  protected noKeyMessage(): string;
13
+ /** Whether a request needs an API key at all. A local server ignores the
14
+ * key, and its client says so by returning false. */
15
+ protected requiresKey(): boolean;
13
16
  protected _speak(text: string): Promise<Result<SpeechResult>>;
14
17
  }
@@ -15,10 +15,15 @@ export class OpenAISpeechClient extends BaseSpeechClient {
15
15
  noKeyMessage() {
16
16
  return "No OpenAI API key provided. Set apiKey.openAi or OPENAI_API_KEY.";
17
17
  }
18
+ /** Whether a request needs an API key at all. A local server ignores the
19
+ * key, and its client says so by returning false. */
20
+ requiresKey() {
21
+ return true;
22
+ }
18
23
  // No try/catch here: BaseSpeechClient.speak() is the single
19
24
  // redacting/logging exception boundary.
20
25
  async _speak(text) {
21
- if (!this.config.apiKey) {
26
+ if (this.requiresKey() && !this.config.apiKey) {
22
27
  return failure(this.noKeyMessage());
23
28
  }
24
29
  // The shared contract carries format as a plain string; narrow to OpenAI's
@@ -40,6 +45,9 @@ export class OpenAISpeechClient extends BaseSpeechClient {
40
45
  if (this.config.speed !== undefined) {
41
46
  params.speed = this.config.speed;
42
47
  }
48
+ if (this.config.instructions !== undefined && this.config.instructions !== "") {
49
+ params.instructions = this.config.instructions;
50
+ }
43
51
  const res = await client.audio.speech.create(params, { signal: this.config.abortSignal });
44
52
  const audio = new Uint8Array(await res.arrayBuffer());
45
53
  const result = { audio, mimeType };
package/dist/speech.d.ts CHANGED
@@ -7,6 +7,11 @@ import { BaseSpeechClient, SpeechClientConfig } from "./speech/baseSpeechClient.
7
7
  export type SpeakOptions = {
8
8
  model: string;
9
9
  voice: string;
10
+ /** How the speech should sound, in plain words: "Alarmed and urgent."
11
+ * Passed through as-is by the OpenAI-shaped providers (openai, groq,
12
+ * openai-compat, mlx); whether the model reads it is up to the model
13
+ * (OpenAI's tts-1 and tts-1-hd do not). The google provider ignores it. */
14
+ instructions?: string;
10
15
  provider?: string;
11
16
  modelData?: ModelDataBlob;
12
17
  apiKey?: SmolConfig["apiKey"];
package/dist/speech.js CHANGED
@@ -6,12 +6,14 @@ import { OpenAISpeechClient } from "./speech/openai.js";
6
6
  import { GroqSpeechClient } from "./speech/groq.js";
7
7
  import { GoogleSpeechClient } from "./speech/google.js";
8
8
  import { OpenAiCompatSpeechClient } from "./speech/openaiCompat.js";
9
+ import { MlxSpeechClient } from "./speech/mlx.js";
9
10
  // Checked before the user registry so a registered "openai" can't hijack the built-in.
10
11
  const builtinClients = Object.create(null);
11
12
  builtinClients["openai"] = OpenAISpeechClient;
12
13
  builtinClients["groq"] = GroqSpeechClient;
13
14
  builtinClients["google"] = GoogleSpeechClient;
14
15
  builtinClients["openai-compat"] = OpenAiCompatSpeechClient;
16
+ builtinClients["mlx"] = MlxSpeechClient;
15
17
  // Null-prototype so provider names like "toString"/"__proto__" can't collide
16
18
  // with Object.prototype or pollute the registry.
17
19
  const registered = Object.create(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [