smoltalk 0.10.0 → 0.10.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.
Files changed (37) hide show
  1. package/README.md +40 -6
  2. package/dist/model.js +30 -11
  3. package/dist/models.d.ts +61 -3
  4. package/dist/models.js +74 -0
  5. package/dist/speech/baseSpeechClient.d.ts +5 -0
  6. package/dist/speech/baseSpeechClient.js +21 -2
  7. package/dist/speech/google.d.ts +6 -0
  8. package/dist/speech/google.js +54 -0
  9. package/dist/speech/groq.d.ts +11 -0
  10. package/dist/speech/groq.js +19 -0
  11. package/dist/speech/openai.d.ts +8 -0
  12. package/dist/speech/openai.js +16 -4
  13. package/dist/speech/openaiCompat.d.ts +13 -0
  14. package/dist/speech/openaiCompat.js +22 -0
  15. package/dist/speech.d.ts +5 -0
  16. package/dist/speech.js +6 -0
  17. package/dist/transcription/baseTranscriptionClient.d.ts +5 -0
  18. package/dist/transcription/baseTranscriptionClient.js +44 -18
  19. package/dist/transcription/google.d.ts +6 -0
  20. package/dist/transcription/google.js +56 -0
  21. package/dist/transcription/groq.d.ts +10 -0
  22. package/dist/transcription/groq.js +17 -0
  23. package/dist/transcription/openai.d.ts +5 -0
  24. package/dist/transcription/openai.js +11 -3
  25. package/dist/transcription/openaiCompat.d.ts +13 -0
  26. package/dist/transcription/openaiCompat.js +22 -0
  27. package/dist/transcription.d.ts +3 -0
  28. package/dist/transcription.js +6 -0
  29. package/dist/types.d.ts +1 -0
  30. package/dist/util/audioMime.d.ts +17 -0
  31. package/dist/util/audioMime.js +42 -1
  32. package/dist/util/googleAudioUsage.d.ts +14 -0
  33. package/dist/util/googleAudioUsage.js +52 -0
  34. package/dist/util/mime.js +2 -0
  35. package/dist/util/provider.d.ts +1 -0
  36. package/dist/util/provider.js +2 -0
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -509,11 +509,43 @@ the provider call. Embeddings and images are one-shot functions.
509
509
 
510
510
  ## Audio (STT/TTS)
511
511
 
512
- Three audio primitives, all OpenAI-only in v1. `transcribe()` (speech-to-text)
513
- and `speak()` (text-to-speech) are async and return `Result<T>` (never throw).
514
- `audioPart()` (attach audio to a chat message) is different: it's a
515
- synchronous plain-object constructor, not a `Result`-returning call — see
516
- "Audio in chat" below.
512
+ Three audio primitives. `transcribe()` (speech-to-text) and `speak()`
513
+ (text-to-speech) are async and return `Result<T>` (never throw). `audioPart()`
514
+ (attach audio to a chat message) is different: it's a synchronous plain-object
515
+ constructor, not a `Result`-returning call — see "Audio in chat" below.
516
+
517
+ `transcribe()` and `speak()` support **OpenAI**, **Groq** (OpenAI-compatible
518
+ endpoints), and **Google Gemini** (native multimodal). For any other provider
519
+ that exposes OpenAI-shaped `/audio/*` endpoints, use the generic
520
+ **`openai-compat`** provider with `baseUrl` (mirrors the chat client). Anthropic,
521
+ OpenRouter, and Ollama have no audio endpoints and return a `Failure`.
522
+
523
+ ```ts
524
+ // example: skip-typecheck
525
+ // Groq STT (OpenAI-compatible; provider inferred from the model)
526
+ await transcribe(src, { model: "whisper-large-v3" });
527
+
528
+ // Gemini STT (native multimodal — a general Gemini model transcribes)
529
+ await transcribe(src, { model: "gemini-2.5-flash", provider: "google" });
530
+
531
+ // Groq TTS → WAV by default
532
+ await speak("Hello", { model: "canopylabs/orpheus-v1-english", voice: "troy" });
533
+
534
+ // Gemini TTS → raw PCM by default; format: "wav" wraps it in a WAV header.
535
+ // Gemini has no numeric `speed` (rejected) and produces PCM/WAV only.
536
+ await speak("Hello", {
537
+ model: "gemini-2.5-flash-preview-tts", voice: "Kore",
538
+ provider: "google", format: "wav",
539
+ });
540
+
541
+ // Any OpenAI-compatible /audio endpoint (vLLM, LiteLLM, a proxy, …)
542
+ await transcribe(src, {
543
+ model: "whisper-1",
544
+ provider: "openai-compat",
545
+ apiKey: { openAiCompat: "..." }, // or OPENAI_COMPAT_API_KEY
546
+ baseUrl: { openAiCompat: "https://my-proxy/v1" }, // or OPENAI_COMPAT_BASE_URL
547
+ });
548
+ ```
517
549
 
518
550
  ### Speech-to-text
519
551
 
@@ -529,7 +561,9 @@ if (result.success) {
529
561
  }
530
562
  ```
531
563
 
532
- `whisper-1` is the only baked-in model in v1. Options: `language`, `prompt`,
564
+ Baked-in STT models: `whisper-1` (OpenAI) and `whisper-large-v3` /
565
+ `whisper-large-v3-turbo` (Groq); Gemini transcribes with a general model such as
566
+ `gemini-2.5-flash`. Options: `language`, `prompt`,
533
567
  `timestampGranularity` (`"segment"` | `"word"`), `maxBytes` (a safety limit —
534
568
  the effective cap is the smaller of your limit and the model's declared upload
535
569
  cap, 25 MB for `whisper-1`). The result carries `text` plus optional
package/dist/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getModel, getModelForProvider, isSpeechToTextModel, isTextModel, isTextToSpeechModel, ModelNameSchema, } from "./models.js";
1
+ import { getModel, getModelForProvider, isSpeechToTextModel, isTextToSpeechModel, ModelNameSchema, } from "./models.js";
2
2
  import { SmolError } from "./smolError.js";
3
3
  import { round } from "./util/util.js";
4
4
  const TOKEN_COST_UNIT = 1_000_000;
@@ -32,7 +32,23 @@ export class Model {
32
32
  else {
33
33
  model = getModel(this.model, this.modelData);
34
34
  }
35
- if (!model || !isTextModel(model)) {
35
+ if (!model) {
36
+ return null;
37
+ }
38
+ // This token engine prices text generation and token-billed audio models
39
+ // (e.g. Gemini TTS). Image and embeddings models have their own cost paths,
40
+ // so they are never priced here even if they carry text-token rates.
41
+ if (model.type === "image" || model.type === "embeddings") {
42
+ return null;
43
+ }
44
+ // BaseModel token-rate fields, read structurally across the model union.
45
+ const rates = model;
46
+ // Price only models that carry at least one token rate; those without
47
+ // (per-minute STT, per-char TTS) return null so their dedicated helpers apply.
48
+ if (rates.inputTokenCost === undefined &&
49
+ rates.outputTokenCost === undefined &&
50
+ rates.inputAudioTokenCost === undefined &&
51
+ rates.outputAudioTokenCost === undefined) {
36
52
  return null;
37
53
  }
38
54
  const cachedTokens = usage.cachedInputTokens ?? 0;
@@ -40,15 +56,15 @@ export class Model {
40
56
  // Disjoint buckets. If a discount price isn't defined for this model,
41
57
  // the tokens were still billed by the provider — charge them at the
42
58
  // full input rate so totalCost stays honest.
43
- const cachedRate = model.cachedInputTokenCost ?? model.inputTokenCost ?? 0;
44
- const cacheCreationRate = model.cacheCreationInputTokenCost ?? model.inputTokenCost ?? 0;
45
- const inputCost = round((usage.inputTokens * (model.inputTokenCost || 0)) / TOKEN_COST_UNIT, 6);
46
- const outputCost = round((usage.outputTokens * (model.outputTokenCost || 0)) / TOKEN_COST_UNIT, 6);
59
+ const cachedRate = rates.cachedInputTokenCost ?? rates.inputTokenCost ?? 0;
60
+ const cacheCreationRate = rates.cacheCreationInputTokenCost ?? rates.inputTokenCost ?? 0;
61
+ const inputCost = round((usage.inputTokens * (rates.inputTokenCost || 0)) / TOKEN_COST_UNIT, 6);
62
+ const outputCost = round((usage.outputTokens * (rates.outputTokenCost || 0)) / TOKEN_COST_UNIT, 6);
47
63
  const audioInTokens = usage.inputAudioTokens ?? 0;
48
64
  const audioOutTokens = usage.outputAudioTokens ?? 0;
49
65
  // Fall back to the text rate if no audio rate is defined so the total stays honest.
50
- const audioInRate = model.inputAudioTokenCost ?? model.inputTokenCost ?? 0;
51
- const audioOutRate = model.outputAudioTokenCost ?? model.outputTokenCost ?? 0;
66
+ const audioInRate = rates.inputAudioTokenCost ?? rates.inputTokenCost ?? 0;
67
+ const audioOutRate = rates.outputAudioTokenCost ?? rates.outputTokenCost ?? 0;
52
68
  const audioInCost = round((audioInTokens * audioInRate) / TOKEN_COST_UNIT, 6);
53
69
  const audioOutCost = round((audioOutTokens * audioOutRate) / TOKEN_COST_UNIT, 6);
54
70
  // Only expose cachedInputCost / cacheCreationInputCost when the model
@@ -59,7 +75,7 @@ export class Model {
59
75
  let foldedInputDollars = 0;
60
76
  if (cachedTokens > 0) {
61
77
  const dollars = (cachedTokens * cachedRate) / 1_000_000;
62
- if (model.cachedInputTokenCost != null) {
78
+ if (rates.cachedInputTokenCost != null) {
63
79
  cachedInputCost = round(dollars, 6);
64
80
  }
65
81
  else {
@@ -68,7 +84,7 @@ export class Model {
68
84
  }
69
85
  if (cacheCreationTokens > 0) {
70
86
  const dollars = (cacheCreationTokens * cacheCreationRate) / 1_000_000;
71
- if (model.cacheCreationInputTokenCost != null) {
87
+ if (rates.cacheCreationInputTokenCost != null) {
72
88
  cacheCreationInputCost = round(dollars, 6);
73
89
  }
74
90
  else {
@@ -115,7 +131,10 @@ export function calculateTranscriptionCost(model, durationSeconds) {
115
131
  if (model.perMinuteCost === undefined || durationSeconds === undefined || durationSeconds === null) {
116
132
  return undefined;
117
133
  }
118
- const inputCost = round((durationSeconds / 60) * model.perMinuteCost, 6);
134
+ // Providers may bill a minimum duration regardless of actual length
135
+ // (e.g. Groq rounds up to 10s), so a shorter clip isn't understated.
136
+ const billedSeconds = Math.max(durationSeconds, model.minimumBillableSeconds ?? 0);
137
+ const inputCost = round((billedSeconds / 60) * model.perMinuteCost, 6);
119
138
  return { inputCost, outputCost: 0, totalCost: inputCost, currency: "USD" };
120
139
  }
121
140
  /** Per-code-point TTS pricing from a registry entry; same omission semantics. */
package/dist/models.d.ts CHANGED
@@ -1,6 +1,6 @@
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", "openrouter", "deepinfra", "litellm", "openai-compat"];
3
+ export declare const providers: readonly ["ollama", "openai", "openai-responses", "anthropic", "google", "replicate", "modal", "openrouter", "deepinfra", "litellm", "openai-compat", "groq"];
4
4
  export declare const ProviderSchema: z.ZodEnum<{
5
5
  openai: "openai";
6
6
  anthropic: "anthropic";
@@ -13,6 +13,7 @@ export declare const ProviderSchema: z.ZodEnum<{
13
13
  deepinfra: "deepinfra";
14
14
  litellm: "litellm";
15
15
  "openai-compat": "openai-compat";
16
+ groq: "groq";
16
17
  }>;
17
18
  export type Provider = z.infer<typeof ProviderSchema>;
18
19
  export type BaseModel = {
@@ -23,6 +24,8 @@ export type BaseModel = {
23
24
  cachedInputTokenCost?: number;
24
25
  cacheCreationInputTokenCost?: number;
25
26
  outputTokenCost?: number;
27
+ inputAudioTokenCost?: number;
28
+ outputAudioTokenCost?: number;
26
29
  disabled?: boolean;
27
30
  costUnit?: "tokens" | "characters" | "minutes";
28
31
  knowledge?: string;
@@ -34,6 +37,8 @@ export type BaseModel = {
34
37
  export type SpeechToTextModel = BaseModel & {
35
38
  type: "speech-to-text";
36
39
  perMinuteCost?: number;
40
+ /** Provider's minimum billable duration in seconds (e.g. Groq bills >= 10s). */
41
+ minimumBillableSeconds?: number;
37
42
  /** Canonical MIME types accepted after alias normalization through AUDIO_FORMATS. */
38
43
  supportedMimeTypes?: readonly string[];
39
44
  /** Provider upload cap in bytes. */
@@ -90,10 +95,11 @@ export type TextModel = BaseModel & {
90
95
  input: string[];
91
96
  output: string[];
92
97
  };
98
+ /** Audio-input constraints when this multimodal model is used for transcription. */
99
+ supportedMimeTypes?: readonly string[];
100
+ maxBytes?: number;
93
101
  structuredOutput?: boolean;
94
102
  temperatureSupported?: boolean;
95
- inputAudioTokenCost?: number;
96
- outputAudioTokenCost?: number;
97
103
  /** Pricing that applies above a context-size threshold (e.g. Gemini >200k). */
98
104
  longContext?: {
99
105
  thresholdTokens: number;
@@ -116,6 +122,22 @@ export declare const speechToTextModels: readonly [{
116
122
  readonly provider: "openai";
117
123
  readonly supportedMimeTypes: readonly ["audio/flac", "audio/mpeg", "audio/mp4", "audio/m4a", "audio/ogg", "audio/wav", "audio/webm"];
118
124
  readonly maxBytes: number;
125
+ }, {
126
+ readonly type: "speech-to-text";
127
+ readonly modelName: "whisper-large-v3";
128
+ readonly provider: "groq";
129
+ readonly perMinuteCost: 0.00185;
130
+ readonly minimumBillableSeconds: 10;
131
+ readonly supportedMimeTypes: readonly ["audio/flac", "audio/mpeg", "audio/mp4", "audio/m4a", "audio/ogg", "audio/wav", "audio/webm"];
132
+ readonly maxBytes: number;
133
+ }, {
134
+ readonly type: "speech-to-text";
135
+ readonly modelName: "whisper-large-v3-turbo";
136
+ readonly provider: "groq";
137
+ readonly perMinuteCost: 0.000667;
138
+ readonly minimumBillableSeconds: 10;
139
+ readonly supportedMimeTypes: readonly ["audio/flac", "audio/mpeg", "audio/mp4", "audio/m4a", "audio/ogg", "audio/wav", "audio/webm"];
140
+ readonly maxBytes: number;
119
141
  }];
120
142
  export declare const textToSpeechModels: readonly [{
121
143
  readonly type: "text-to-speech";
@@ -139,6 +161,34 @@ export declare const textToSpeechModels: readonly [{
139
161
  readonly max: 4;
140
162
  };
141
163
  readonly formats: readonly ["mp3", "opus", "aac", "flac", "wav", "pcm"];
164
+ }, {
165
+ readonly type: "text-to-speech";
166
+ readonly modelName: "canopylabs/orpheus-v1-english";
167
+ readonly provider: "groq";
168
+ readonly perCharacterCost: 0.000022;
169
+ readonly maxInputChars: 200;
170
+ readonly formats: readonly ["wav"];
171
+ }, {
172
+ readonly type: "text-to-speech";
173
+ readonly modelName: "canopylabs/orpheus-arabic-saudi";
174
+ readonly provider: "groq";
175
+ readonly perCharacterCost: 0.00004;
176
+ readonly maxInputChars: 200;
177
+ readonly formats: readonly ["wav"];
178
+ }, {
179
+ readonly type: "text-to-speech";
180
+ readonly modelName: "gemini-2.5-flash-preview-tts";
181
+ readonly provider: "google";
182
+ readonly inputTokenCost: 0.5;
183
+ readonly outputAudioTokenCost: 10;
184
+ readonly formats: readonly ["pcm", "wav"];
185
+ }, {
186
+ readonly type: "text-to-speech";
187
+ readonly modelName: "gemini-2.5-pro-preview-tts";
188
+ readonly provider: "google";
189
+ readonly inputTokenCost: 1;
190
+ readonly outputAudioTokenCost: 20;
191
+ readonly formats: readonly ["pcm", "wav"];
142
192
  }];
143
193
  export declare const textModels: readonly [{
144
194
  readonly type: "text";
@@ -1203,6 +1253,8 @@ export declare const textModels: readonly [{
1203
1253
  readonly input: readonly ["text", "image", "audio", "video", "pdf"];
1204
1254
  readonly output: readonly ["text"];
1205
1255
  };
1256
+ readonly supportedMimeTypes: readonly ["audio/wav", "audio/mpeg", "audio/aac", "audio/ogg", "audio/flac", "audio/aiff"];
1257
+ readonly maxBytes: 14000000;
1206
1258
  readonly knowledge: "2025-01";
1207
1259
  readonly releaseDate: "2025-06-17";
1208
1260
  readonly lastUpdated: "2025-06-17";
@@ -1771,5 +1823,11 @@ export declare function isImageModel(model: ModelType): model is ImageModel;
1771
1823
  export declare function isTextModel(model: ModelType): model is TextModel;
1772
1824
  export declare function isSpeechToTextModel(model: ModelType): model is SpeechToTextModel;
1773
1825
  export declare function isTextToSpeechModel(model: ModelType): model is TextToSpeechModel;
1826
+ /** Audio-input constraints, readable off either a dedicated STT model or a
1827
+ * multimodal text model. Empty for any other model type. */
1828
+ export declare function audioInputConstraints(model: ModelType): {
1829
+ maxBytes?: number;
1830
+ supportedMimeTypes?: readonly string[];
1831
+ };
1774
1832
  export declare function isEmbeddingsModel(model: ModelType): model is EmbeddingsModel;
1775
1833
  export declare const ModelNameSchema: z.ZodString;
package/dist/models.js CHANGED
@@ -12,6 +12,7 @@ export const providers = [
12
12
  "deepinfra",
13
13
  "litellm",
14
14
  "openai-compat",
15
+ "groq",
15
16
  ];
16
17
  export const ProviderSchema = z.enum(providers);
17
18
  export const speechToTextModels = [
@@ -26,6 +27,32 @@ export const speechToTextModels = [
26
27
  ],
27
28
  maxBytes: 25 * 1024 * 1024,
28
29
  },
30
+ {
31
+ type: "speech-to-text",
32
+ modelName: "whisper-large-v3",
33
+ provider: "groq",
34
+ perMinuteCost: 0.00185, // $0.111/hr, verified 2026-08-09
35
+ minimumBillableSeconds: 10, // Groq bills a 10s minimum per request
36
+ supportedMimeTypes: [
37
+ "audio/flac", "audio/mpeg", "audio/mp4", "audio/m4a", "audio/ogg",
38
+ "audio/wav", "audio/webm",
39
+ ],
40
+ // Conservative free-tier / direct-attachment cap; Groq's developer tier
41
+ // allows 100 MB, but a single baked-in record cannot vary by account tier.
42
+ maxBytes: 25 * 1024 * 1024,
43
+ },
44
+ {
45
+ type: "speech-to-text",
46
+ modelName: "whisper-large-v3-turbo",
47
+ provider: "groq",
48
+ perMinuteCost: 0.000667, // $0.04/hr, verified 2026-08-09
49
+ minimumBillableSeconds: 10, // Groq bills a 10s minimum per request
50
+ supportedMimeTypes: [
51
+ "audio/flac", "audio/mpeg", "audio/mp4", "audio/m4a", "audio/ogg",
52
+ "audio/wav", "audio/webm",
53
+ ],
54
+ maxBytes: 25 * 1024 * 1024,
55
+ },
29
56
  ];
30
57
  export const textToSpeechModels = [
31
58
  {
@@ -46,6 +73,40 @@ export const textToSpeechModels = [
46
73
  speedRange: { min: 0.25, max: 4 },
47
74
  formats: ["mp3", "opus", "aac", "flac", "wav", "pcm"],
48
75
  },
76
+ {
77
+ type: "text-to-speech",
78
+ modelName: "canopylabs/orpheus-v1-english",
79
+ provider: "groq",
80
+ perCharacterCost: 0.000022, // $22 / 1M chars, verified 2026-08-09
81
+ maxInputChars: 200,
82
+ formats: ["wav"],
83
+ },
84
+ {
85
+ type: "text-to-speech",
86
+ modelName: "canopylabs/orpheus-arabic-saudi",
87
+ provider: "groq",
88
+ perCharacterCost: 0.00004, // $40 / 1M chars, verified 2026-08-09
89
+ maxInputChars: 200,
90
+ formats: ["wav"],
91
+ },
92
+ // Gemini TTS is token-billed (text input + audio output). No maxInputChars:
93
+ // Gemini documents a 32k-token context, and characters are not a sound proxy.
94
+ {
95
+ type: "text-to-speech",
96
+ modelName: "gemini-2.5-flash-preview-tts",
97
+ provider: "google",
98
+ inputTokenCost: 0.5, // $/1M text-input tokens, verified 2026-08-09
99
+ outputAudioTokenCost: 10.0, // $/1M audio-output tokens
100
+ formats: ["pcm", "wav"],
101
+ },
102
+ {
103
+ type: "text-to-speech",
104
+ modelName: "gemini-2.5-pro-preview-tts",
105
+ provider: "google",
106
+ inputTokenCost: 1.0, // $/1M text-input tokens, verified 2026-08-09
107
+ outputAudioTokenCost: 20.0, // $/1M audio-output tokens
108
+ formats: ["pcm", "wav"],
109
+ },
49
110
  ];
50
111
  export const textModels = [
51
112
  {
@@ -1148,6 +1209,11 @@ export const textModels = [
1148
1209
  input: ["text", "image", "audio", "video", "pdf"],
1149
1210
  output: ["text"],
1150
1211
  },
1212
+ // Audio-input (transcription) constraints. maxBytes is a conservative raw cap
1213
+ // leaving room for base64 expansion + instructions under Gemini's 20 MB total
1214
+ // inline request limit; the client also checks the encoded request size.
1215
+ supportedMimeTypes: ["audio/wav", "audio/mpeg", "audio/aac", "audio/ogg", "audio/flac", "audio/aiff"],
1216
+ maxBytes: 14_000_000,
1151
1217
  knowledge: "2025-01",
1152
1218
  releaseDate: "2025-06-17",
1153
1219
  lastUpdated: "2025-06-17",
@@ -1975,6 +2041,14 @@ export function isSpeechToTextModel(model) {
1975
2041
  export function isTextToSpeechModel(model) {
1976
2042
  return model.type === "text-to-speech";
1977
2043
  }
2044
+ /** Audio-input constraints, readable off either a dedicated STT model or a
2045
+ * multimodal text model. Empty for any other model type. */
2046
+ export function audioInputConstraints(model) {
2047
+ if (model.type === "speech-to-text" || model.type === "text") {
2048
+ return { maxBytes: model.maxBytes, supportedMimeTypes: model.supportedMimeTypes };
2049
+ }
2050
+ return {};
2051
+ }
1978
2052
  export function isEmbeddingsModel(model) {
1979
2053
  return model.type === "embeddings";
1980
2054
  }
@@ -1,4 +1,5 @@
1
1
  import type { ModelDataBlob } from "../modelData.js";
2
+ import type { SmolConfig } from "../types.js";
2
3
  import { Result } from "../types/result.js";
3
4
  import type { SpeechResult } from "../speech.js";
4
5
  export type SpeechClientConfig = {
@@ -7,12 +8,16 @@ export type SpeechClientConfig = {
7
8
  provider: string;
8
9
  /** Resolved API key; empty string when none was found. */
9
10
  apiKey: string;
11
+ /** Base-URL map (for OpenAI-compatible providers); read via resolveBaseUrl. */
12
+ baseUrl?: SmolConfig["baseUrl"];
10
13
  voice: string;
11
14
  modelData?: ModelDataBlob;
12
15
  /** Output format; provider-specific vocabulary (OpenAI: mp3/opus/aac/flac/wav/pcm). */
13
16
  format?: string;
14
17
  speed?: number;
15
18
  metadata?: Record<string, unknown>;
19
+ /** Abort the in-flight provider request when this signal fires. */
20
+ abortSignal?: AbortSignal;
16
21
  };
17
22
  /**
18
23
  * Shared TTS behavior, mirroring BaseClient for text generation: the public
@@ -1,5 +1,5 @@
1
1
  import { getModelForProvider, isTextToSpeechModel, } from "../models.js";
2
- import { calculateSpeechCost } from "../model.js";
2
+ import { Model, calculateSpeechCost } from "../model.js";
3
3
  import { failure } from "../types/result.js";
4
4
  import { redactSecret } from "../util/redact.js";
5
5
  import { getLogger } from "../util/logger.js";
@@ -49,6 +49,10 @@ export class BaseSpeechClient {
49
49
  this.config = config;
50
50
  }
51
51
  async speak(text) {
52
+ // Already-aborted signal: stop before doing any paid work.
53
+ if (this.config.abortSignal?.aborted) {
54
+ return failure("Request was aborted");
55
+ }
52
56
  try {
53
57
  const model = getModelForProvider(this.config.provider, this.config.model, this.config.modelData);
54
58
  if (model !== undefined && !isTextToSpeechModel(model)) {
@@ -75,17 +79,32 @@ export class BaseSpeechClient {
75
79
  `Supported: ${model.formats.join(", ")}.`);
76
80
  }
77
81
  }
82
+ // Re-check after preflight validation: the signal may have fired during
83
+ // it, and we must not dispatch a request once cancelled.
84
+ if (this.config.abortSignal?.aborted) {
85
+ return failure("Request was aborted");
86
+ }
78
87
  const result = await this._speak(text);
79
88
  if (!result.success) {
80
89
  return result;
81
90
  }
82
- const cost = calculateSpeechCost(model, [...text].length);
91
+ let cost = calculateSpeechCost(model, [...text].length);
92
+ if (cost === undefined && result.value.usage !== undefined) {
93
+ // Token-billed providers (Gemini) price through the shared cost engine.
94
+ cost =
95
+ new Model(this.config.model, this.config.provider, this.config.modelData).calculateCost(result.value.usage) ?? undefined;
96
+ }
83
97
  if (cost !== undefined) {
84
98
  result.value.cost = cost;
85
99
  }
86
100
  return result;
87
101
  }
88
102
  catch (err) {
103
+ // Caller-initiated cancellation surfaces as a distinguishable failure
104
+ // (matching the chat path), not a redacted provider error.
105
+ if (this.config.abortSignal?.aborted) {
106
+ return failure("Request was aborted");
107
+ }
89
108
  let msg = "speak() failed";
90
109
  if (err instanceof Error) {
91
110
  msg = err.message;
@@ -0,0 +1,6 @@
1
+ import { Result } from "../types/result.js";
2
+ import { BaseSpeechClient } from "./baseSpeechClient.js";
3
+ import type { SpeechResult } from "../speech.js";
4
+ export declare class GoogleSpeechClient extends BaseSpeechClient {
5
+ protected _speak(text: string): Promise<Result<SpeechResult>>;
6
+ }
@@ -0,0 +1,54 @@
1
+ import { GoogleGenAI } from "@google/genai";
2
+ import { success, failure } from "../types/result.js";
3
+ import { pcmToWav } from "../util/audioMime.js";
4
+ import { normalizeGoogleAudioUsage } from "../util/googleAudioUsage.js";
5
+ import { BaseSpeechClient } from "./baseSpeechClient.js";
6
+ const GEMINI_PCM = { sampleRateHz: 24000, sampleFormat: "s16le", channels: 1 };
7
+ export class GoogleSpeechClient extends BaseSpeechClient {
8
+ // No try/catch: BaseSpeechClient.speak() is the exception boundary.
9
+ async _speak(text) {
10
+ if (!this.config.apiKey) {
11
+ return failure("No Google API key provided. Set apiKey.google or GEMINI_API_KEY.");
12
+ }
13
+ // Gemini controls pacing via prompt style, not a numeric speed parameter.
14
+ if (this.config.speed !== undefined) {
15
+ return failure("Gemini TTS does not support the 'speed' option; control pacing via the prompt text.");
16
+ }
17
+ const format = this.config.format ?? "pcm";
18
+ if (format !== "pcm" && format !== "wav") {
19
+ return failure(`Gemini TTS only produces raw PCM. Supported formats: pcm (default), wav. Got "${format}".`);
20
+ }
21
+ const ai = new GoogleGenAI({ apiKey: this.config.apiKey });
22
+ const res = await ai.models.generateContent({
23
+ model: this.config.model,
24
+ contents: [{ role: "user", parts: [{ text }] }],
25
+ config: {
26
+ responseModalities: ["AUDIO"],
27
+ speechConfig: {
28
+ voiceConfig: { prebuiltVoiceConfig: { voiceName: this.config.voice } },
29
+ },
30
+ // Client-only cancellation: tears down the request, but Gemini still
31
+ // bills server-side work.
32
+ abortSignal: this.config.abortSignal,
33
+ },
34
+ });
35
+ const dataB64 = res.candidates?.[0]?.content?.parts?.find((part) => part.inlineData?.data !== undefined)?.inlineData?.data;
36
+ if (!dataB64) {
37
+ return failure("Gemini returned no audio data.");
38
+ }
39
+ const pcm = new Uint8Array(Buffer.from(dataB64, "base64"));
40
+ let audio = pcm;
41
+ let mimeType = "application/octet-stream";
42
+ if (format === "wav") {
43
+ audio = pcmToWav(pcm, { sampleRateHz: 24000, channels: 1, bitsPerSample: 16 });
44
+ mimeType = "audio/wav";
45
+ }
46
+ const usage = normalizeGoogleAudioUsage(res.usageMetadata, "output");
47
+ const result = { audio, mimeType, raw: res };
48
+ if (format === "pcm")
49
+ result.pcm = GEMINI_PCM;
50
+ if (usage)
51
+ result.usage = usage;
52
+ return success(result);
53
+ }
54
+ }
@@ -0,0 +1,11 @@
1
+ import OpenAI from "openai";
2
+ import { OpenAISpeechClient } from "./openai.js";
3
+ import type { SpeakFormat } from "../util/audioMime.js";
4
+ /**
5
+ * Groq exposes OpenAI-compatible Orpheus TTS and supports WAV only.
6
+ */
7
+ export declare class GroqSpeechClient extends OpenAISpeechClient {
8
+ protected makeClient(): OpenAI;
9
+ protected defaultFormat(): SpeakFormat;
10
+ protected noKeyMessage(): string;
11
+ }
@@ -0,0 +1,19 @@
1
+ import OpenAI from "openai";
2
+ import { OpenAISpeechClient } from "./openai.js";
3
+ /**
4
+ * Groq exposes OpenAI-compatible Orpheus TTS and supports WAV only.
5
+ */
6
+ export class GroqSpeechClient extends OpenAISpeechClient {
7
+ makeClient() {
8
+ return new OpenAI({
9
+ apiKey: this.config.apiKey,
10
+ baseURL: "https://api.groq.com/openai/v1",
11
+ });
12
+ }
13
+ defaultFormat() {
14
+ return "wav";
15
+ }
16
+ noKeyMessage() {
17
+ return "No Groq API key provided. Set apiKey.groq or GROQ_API_KEY.";
18
+ }
19
+ }
@@ -1,6 +1,14 @@
1
+ import OpenAI from "openai";
1
2
  import { Result } from "../types/result.js";
3
+ import { type SpeakFormat } from "../util/audioMime.js";
2
4
  import { BaseSpeechClient } from "./baseSpeechClient.js";
3
5
  import type { SpeechResult } from "../speech.js";
4
6
  export declare class OpenAISpeechClient extends BaseSpeechClient {
7
+ /** Build the OpenAI SDK client. Subclasses override to point at a compatible base URL. */
8
+ protected makeClient(): OpenAI;
9
+ /** Provider default used when the declarative call omits format. */
10
+ protected defaultFormat(): SpeakFormat;
11
+ /** Provider-specific diagnostic when no API key is resolved. Subclasses override. */
12
+ protected noKeyMessage(): string;
5
13
  protected _speak(text: string): Promise<Result<SpeechResult>>;
6
14
  }
@@ -3,22 +3,34 @@ import { success, failure } from "../types/result.js";
3
3
  import { SPEECH_FORMAT_TO_MIME, isSpeakFormat, } from "../util/audioMime.js";
4
4
  import { BaseSpeechClient } from "./baseSpeechClient.js";
5
5
  export class OpenAISpeechClient extends BaseSpeechClient {
6
+ /** Build the OpenAI SDK client. Subclasses override to point at a compatible base URL. */
7
+ makeClient() {
8
+ return new OpenAI({ apiKey: this.config.apiKey });
9
+ }
10
+ /** Provider default used when the declarative call omits format. */
11
+ defaultFormat() {
12
+ return "mp3";
13
+ }
14
+ /** Provider-specific diagnostic when no API key is resolved. Subclasses override. */
15
+ noKeyMessage() {
16
+ return "No OpenAI API key provided. Set apiKey.openAi or OPENAI_API_KEY.";
17
+ }
6
18
  // No try/catch here: BaseSpeechClient.speak() is the single
7
19
  // redacting/logging exception boundary.
8
20
  async _speak(text) {
9
21
  if (!this.config.apiKey) {
10
- return failure("No OpenAI API key provided. Set apiKey.openAi or OPENAI_API_KEY.");
22
+ return failure(this.noKeyMessage());
11
23
  }
12
24
  // The shared contract carries format as a plain string; narrow to OpenAI's
13
25
  // closed union at runtime before indexing the MIME table.
14
- const requestedFormat = this.config.format ?? "mp3";
26
+ const requestedFormat = this.config.format ?? this.defaultFormat();
15
27
  if (!isSpeakFormat(requestedFormat)) {
16
28
  return failure(`Format "${requestedFormat}" is not a supported OpenAI speech format. ` +
17
29
  `Supported: ${Object.keys(SPEECH_FORMAT_TO_MIME).join(", ")}.`);
18
30
  }
19
31
  const format = requestedFormat;
20
32
  const mimeType = SPEECH_FORMAT_TO_MIME[format];
21
- const client = new OpenAI({ apiKey: this.config.apiKey });
33
+ const client = this.makeClient();
22
34
  const params = {
23
35
  model: this.config.model,
24
36
  voice: this.config.voice,
@@ -28,7 +40,7 @@ export class OpenAISpeechClient extends BaseSpeechClient {
28
40
  if (this.config.speed !== undefined) {
29
41
  params.speed = this.config.speed;
30
42
  }
31
- const res = await client.audio.speech.create(params);
43
+ const res = await client.audio.speech.create(params, { signal: this.config.abortSignal });
32
44
  const audio = new Uint8Array(await res.arrayBuffer());
33
45
  const result = { audio, mimeType };
34
46
  if (format === "pcm") {
@@ -0,0 +1,13 @@
1
+ import OpenAI from "openai";
2
+ import { OpenAISpeechClient } from "./openai.js";
3
+ /**
4
+ * Generic OpenAI-compatible speech (TTS) client. Point it at any provider
5
+ * exposing an OpenAI-shaped /audio/speech endpoint via
6
+ * `config.baseUrl.openAiCompat` (or OPENAI_COMPAT_BASE_URL) and
7
+ * `config.apiKey.openAiCompat` (or OPENAI_COMPAT_API_KEY). Mirrors the chat
8
+ * `SmolOpenAiCompat` client. Inherits OpenAI's `mp3` default format.
9
+ */
10
+ export declare class OpenAiCompatSpeechClient extends OpenAISpeechClient {
11
+ protected makeClient(): OpenAI;
12
+ protected noKeyMessage(): string;
13
+ }
@@ -0,0 +1,22 @@
1
+ import OpenAI from "openai";
2
+ import { OpenAISpeechClient } from "./openai.js";
3
+ import { resolveBaseUrl } from "../util/provider.js";
4
+ /**
5
+ * Generic OpenAI-compatible speech (TTS) client. Point it at any provider
6
+ * exposing an OpenAI-shaped /audio/speech endpoint via
7
+ * `config.baseUrl.openAiCompat` (or OPENAI_COMPAT_BASE_URL) and
8
+ * `config.apiKey.openAiCompat` (or OPENAI_COMPAT_API_KEY). Mirrors the chat
9
+ * `SmolOpenAiCompat` client. Inherits OpenAI's `mp3` default format.
10
+ */
11
+ export class OpenAiCompatSpeechClient extends OpenAISpeechClient {
12
+ makeClient() {
13
+ const baseURL = resolveBaseUrl("openai-compat", { baseUrl: this.config.baseUrl });
14
+ if (!baseURL) {
15
+ throw new Error("openai-compat: base URL required (config.baseUrl.openAiCompat or OPENAI_COMPAT_BASE_URL).");
16
+ }
17
+ return new OpenAI({ apiKey: this.config.apiKey, baseURL });
18
+ }
19
+ noKeyMessage() {
20
+ return "No API key provided. Set apiKey.openAiCompat or OPENAI_COMPAT_API_KEY.";
21
+ }
22
+ }