mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c31670453 | ||
|
|
16d0ce8b3e | ||
|
|
8c64f54a87 | ||
|
|
7abd7b4417 | ||
|
|
29e0e1e2aa | ||
|
|
4857898af4 | ||
|
|
526a250c95 |
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.
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user