Compare commits

...
15 changed files with 191 additions and 14 deletions

No files matched your search

@@ -51,6 +51,10 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { ZAI_MODELS } from './models.js';
import { ZAIProvider } from './ZAIProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import {
modelSupportsModality,
requiredInputModalities,
} from '../../utils/mediaParts.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
//
@@ -211,6 +215,47 @@ describe('ZAIProvider model catalog', () => {
cached_tokens: 7.5,
});
});
it('advertises image input, and no video or file input, only on the GLM-5.3 flash models', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const imageIds = models
.filter((m) => modelSupportsModality(m, 'image'))
.map((m) => m.id);
expect(imageIds).toEqual(['glm-5.3-flash', 'glm-5.3-flashx']);
for (const m of models) {
expect(m.modalities?.output).toEqual(['text']);
expect(modelSupportsModality(m, 'video')).toBe(false);
expect(modelSupportsModality(m, 'file')).toBe(false);
}
});
it('passes the driver input gate for a puter.js image message on the flash models only', async () => {
const { provider } = makeProvider();
const models = await provider.models();
// The SDK image overload omits an explicit part type.
const required = requiredInputModalities([
{
content: [
'what is this?',
{ image_url: { url: 'https://cdn.test/cat.png' } },
],
},
]);
expect(required).toEqual(['image']);
const accepts = (id: string) =>
required.every((modality) =>
modelSupportsModality(
models.find((m) => m.id === id)!,
modality,
),
);
expect(accepts('glm-5.3-flash')).toBe(true);
expect(accepts('glm-5.3-flashx')).toBe(true);
expect(accepts('glm-5.3')).toBe(false);
});
});
// ── Request shape (OpenAI-compat quirks specific to GLM) ────────────
@@ -348,6 +393,40 @@ describe('ZAIProvider.complete request shape', () => {
expect(args.response_format).toEqual({ type: 'json_object' });
});
it.each(['glm-5.3-flash', 'zai/glm-5.3-flashx'])(
'forwards image_url parts for %s unchanged, by URL and by data URL',
async (requested) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const content = [
{ type: 'text', text: 'compare these' },
{
type: 'image_url',
image_url: { url: 'https://cdn.test/cat.png' },
},
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
];
await withTestActor(() =>
provider.complete({
model: requested,
messages: [
{ role: 'user', content: structuredClone(content) },
],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe(requested.replace(/^zai\//, ''));
// Z.AI reads http(s) and data URLs itself, so neither is fetched or
// rewritten on the way through.
expect(args.messages).toEqual([{ role: 'user', content }]);
},
);
it('strips Anthropic-style cache_control from messages before sending', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
@@ -22,18 +22,22 @@ import { usdPerMToken } from '../../utils/pricing.js';
const K = 1_000;
// Advertise only media types handled by the chat adapter.
const imageInput = ['text', 'image'];
const textModel = (
id: string,
name: string,
context: number,
maxTokens: number,
costs: IChatModel['costs'],
input: string[] = ['text'],
): IChatModel => ({
puterId: `z-ai:z-ai/${id}`,
id,
name,
aliases: [`z-ai/${id}`, `zai/${id}`],
modalities: { input: ['text'], output: ['text'] },
modalities: { input, output: ['text'] },
open_weights: false,
tool_call: true,
context,
@@ -62,6 +66,7 @@ export const ZAI_MODELS: IChatModel[] = [
1_000 * K,
128 * K,
usdPerMToken(0.15, 0.5, 0.03),
imageInput,
),
textModel(
'glm-5.3-flashx',
@@ -69,6 +74,7 @@ export const ZAI_MODELS: IChatModel[] = [
1_000 * K,
128 * K,
usdPerMToken(0.37, 1.25, 0.075),
imageInput,
),
textModel(
'glm-5.2',
@@ -522,6 +522,24 @@ describe('SpeechToTextDriver model selection and payload shape', () => {
expect(sent.timestamp_granularities).toBeUndefined();
});
it('accepts but does not forward chunking or known-speaker options', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
}),
);
expect(result).toEqual({ text: 'x' });
const sent = transcriptionsCreateMock.mock.calls[0]![0];
expect(sent.chunking_strategy).toBeUndefined();
expect(sent.known_speaker_names).toBeUndefined();
});
it('passes extra_body through verbatim', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
@@ -441,6 +441,31 @@ describe('XAISpeechToTextProvider request shape', () => {
expect(form.get('model')).toBeNull();
});
it('accepts but ignores legacy transcription options', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
}),
);
expect(result).toMatchObject({ text: 'ok' });
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
expect(form.get('timestamp_granularities')).toBeNull();
expect(form.get('timestamp_granularities[]')).toBeNull();
expect(form.get('chunking_strategy')).toBeNull();
expect(form.get('known_speaker_names')).toBeNull();
expect(form.get('known_speaker_names[]')).toBeNull();
});
it('forwards a pinned grok-voice-transcribe version as `model`', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -53,7 +53,6 @@ export interface ITranscribeArgs {
timestamp_granularities?: string[];
chunking_strategy?: string;
known_speaker_names?: string[];
known_speaker_references?: unknown[];
extra_body?: Record<string, unknown>;
stream?: boolean;
test_mode?: boolean;
@@ -218,6 +218,28 @@ describe('ElevenLabsTTSProvider catalog', () => {
);
});
it('prices each engine from the cost table in dollars per 1M chars', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
// toBe considers NaN equal to itself, so assert finiteness separately.
for (const cost of Object.values(ELEVENLABS_TTS_COSTS)) {
expect(Number.isFinite(cost)).toBe(true);
}
for (const engine of engines) {
expect(Number.isFinite(engine.pricing_per_million_chars)).toBe(
true,
);
expect(engine.pricing_per_million_chars).toBe(
ELEVENLABS_TTS_COSTS[engine.id] / 100,
);
}
const byId = Object.fromEntries(
engines.map((e) => [e.id, e.pricing_per_million_chars]),
);
expect(byId.eleven_multilingual_v2).toBe(162);
expect(byId.eleven_flash_v2_5).toBe(81);
});
it.each(['eleven_turbo_v2_5', 'eleven_turbo_v2'])(
'drops the deprecated %s from the listing',
async (model) => {
@@ -271,7 +271,7 @@ export class ElevenLabsTTSProvider implements ITTSProvider {
id: model.id,
name: model.name,
provider: 'elevenlabs',
pricing_per_million_chars: 0,
pricing_per_million_chars: ELEVENLABS_TTS_COSTS[model.id] / 100, // Convert microcents per character to dollars per million characters.
}));
}
@@ -115,15 +115,18 @@ describe('XAITTSProvider catalog', () => {
}
});
it('listEngines reports a single xai-tts engine with pricing_per_million_chars', async () => {
it('listEngines reports a single xai-tts engine priced in dollars per 1M chars', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
expect(engines).toHaveLength(1);
expect(engines[0]).toMatchObject({
id: 'xai-tts',
provider: 'xai',
pricing_per_million_chars: 1500,
pricing_per_million_chars: 15,
});
expect(engines[0].pricing_per_million_chars).toBe(
XAI_TTS_COSTS['xai-tts'] / 100,
);
});
});
@@ -93,7 +93,7 @@ export class XAITTSProvider implements ITTSProvider {
id: 'xai-tts',
name: 'xAI TTS',
provider: 'xai',
pricing_per_million_chars: 1500,
pricing_per_million_chars: XAI_TTS_COSTS['xai-tts'] / 100, // Convert microcents per character to dollars per million characters.
},
];
}
+2 -2
View File
@@ -20,7 +20,7 @@ puter.ai.txt2speech.listEngines(options)
A provider name to query. When passed as a string, this is shorthand for `{ provider }`. Defaults to `'aws-polly'`.
Accepted values: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once.
Accepted values are `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'all'` to list every provider at once.
Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). An unrecognized provider is rejected with a `bad_request` error.
@@ -28,7 +28,7 @@ Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). An unr
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every provider's engines |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. The value `'all'` returns every provider's engines. |
## Return value
+1 -1
View File
@@ -19,7 +19,7 @@ puter.ai.txt2speech.listVoices(options)
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once. Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted values are `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'all'` to list every provider at once. Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). |
| `engine` | `String` | Engine/model filter (provider-specific, ignored by some providers) |
When `options` is a plain string it is treated as an `engine` filter for the default (AWS Polly) provider.
+1 -1
View File
@@ -21,4 +21,4 @@ The provider this engine belongs to, e.g. `'aws-polly'`, `'openai'`, `'elevenlab
#### `pricing_per_million_chars` (Number)
An optional cost per million characters. May be absent when the provider does not expose pricing.
An optional cost in US dollars per million characters. May be absent when the provider does not expose pricing.
+14
View File
@@ -561,6 +561,20 @@ describe('ai.speech2txt driver payloads', () => {
});
});
it('speech2txt still sends options the backend accepts but ignores', async () => {
FakeXHR.respondWith = () => ({ success: true, result: { text: 'hi' } });
const legacy = {
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
};
await ai.speech2txt({ file: 'data:audio/mpeg;base64,QUJD', ...legacy });
expect(lastBody().args).toEqual({
file: 'data:audio/mpeg;base64,QUJD',
...legacy,
});
});
it('speech2txt returns bare text for response_format text', async () => {
FakeXHR.respondWith = () => ({ success: true, result: { text: 'transcribed' } });
const out = await ai.speech2txt({
+3 -4
View File
@@ -480,10 +480,9 @@
* @property {boolean} [translate]
* @property {number} [temperature]
* @property {boolean} [logprobs]
* @property {string[]} [timestamp_granularities]
* @property {string} [chunking_strategy]
* @property {string[]} [known_speaker_names]
* @property {string[]} [known_speaker_references]
* @property {string[]} [timestamp_granularities] Accepted and ignored by all providers.
* @property {string} [chunking_strategy] Accepted and ignored by all providers.
* @property {string[]} [known_speaker_names] Accepted and ignored by all providers.
* @property {Record<string, unknown>} [extra_body]
* @property {boolean} [format]
* @property {boolean} [diarize]
+12
View File
@@ -1300,6 +1300,18 @@ export default suite('ai', {
t.assert.equal(error.message, 'Audio input is required');
},
'speech2txt accepts ignored options in test mode': async (t) => {
useApiToken(t);
const result = (await t.puter.ai.speech2txt({
file: TINY_AUDIO,
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['speaker'],
test_mode: true,
})) as { text?: string };
t.assert.ok((result.text ?? '').includes('sample transcription'));
},
'speech2txt honors the positional test-mode flag': async (t) => {
useApiToken(t);
const result = (await t.puter.ai.speech2txt(