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.
- package/README.md +40 -6
- package/dist/model.js +30 -11
- package/dist/models.d.ts +61 -3
- package/dist/models.js +74 -0
- package/dist/speech/baseSpeechClient.d.ts +5 -0
- package/dist/speech/baseSpeechClient.js +21 -2
- package/dist/speech/google.d.ts +6 -0
- package/dist/speech/google.js +54 -0
- package/dist/speech/groq.d.ts +11 -0
- package/dist/speech/groq.js +19 -0
- package/dist/speech/openai.d.ts +8 -0
- package/dist/speech/openai.js +16 -4
- package/dist/speech/openaiCompat.d.ts +13 -0
- package/dist/speech/openaiCompat.js +22 -0
- package/dist/speech.d.ts +5 -0
- package/dist/speech.js +6 -0
- package/dist/transcription/baseTranscriptionClient.d.ts +5 -0
- package/dist/transcription/baseTranscriptionClient.js +44 -18
- package/dist/transcription/google.d.ts +6 -0
- package/dist/transcription/google.js +56 -0
- package/dist/transcription/groq.d.ts +10 -0
- package/dist/transcription/groq.js +17 -0
- package/dist/transcription/openai.d.ts +5 -0
- package/dist/transcription/openai.js +11 -3
- package/dist/transcription/openaiCompat.d.ts +13 -0
- package/dist/transcription/openaiCompat.js +22 -0
- package/dist/transcription.d.ts +3 -0
- package/dist/transcription.js +6 -0
- package/dist/types.d.ts +1 -0
- package/dist/util/audioMime.d.ts +17 -0
- package/dist/util/audioMime.js +42 -1
- package/dist/util/googleAudioUsage.d.ts +14 -0
- package/dist/util/googleAudioUsage.js +52 -0
- package/dist/util/mime.js +2 -0
- package/dist/util/provider.d.ts +1 -0
- package/dist/util/provider.js +2 -0
- 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
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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`
|
|
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,
|
|
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
|
|
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 =
|
|
44
|
-
const cacheCreationRate =
|
|
45
|
-
const inputCost = round((usage.inputTokens * (
|
|
46
|
-
const outputCost = round((usage.outputTokens * (
|
|
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 =
|
|
51
|
-
const audioOutRate =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/speech/openai.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/speech/openai.js
CHANGED
|
@@ -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(
|
|
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 ??
|
|
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 =
|
|
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
|
+
}
|