smoltalk 0.14.0 → 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 |
@@ -561,6 +561,10 @@ that exposes OpenAI-shaped `/audio/*` endpoints, use the generic
561
561
  **`openai-compat`** provider with `baseUrl` (mirrors the chat client). Anthropic,
562
562
  OpenRouter, and Ollama have no audio endpoints and return a `Failure`.
563
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
+
564
568
  ```ts
565
569
  // example: skip-typecheck
566
570
  // Groq STT (OpenAI-compatible; provider inferred from the model)
@@ -655,7 +659,11 @@ if (result.success) {
655
659
  `tts-1` and `tts-1-hd` are the only baked-in models in v1. `voice` is
656
660
  required. Options: `format` (OpenAI accepts `"mp3"` | `"opus"` | `"aac"` |
657
661
  `"flac"` | `"wav"` | `"pcm"`, default `"mp3"`; a custom provider may accept
658
- 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 —
659
667
  for `tts-1`/`tts-1-hd` that's a 4096-code-point input cap, a 0.25–4.0 speed
660
668
  range, and the format list above; exceeding any of them returns a `Failure`
661
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
  }
@@ -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
  }
@@ -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.14.0",
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": [