mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-20 10:32:36 +00:00
Settle reported Gemini video usage before validating output
This commit is contained in:
1 parent
ee8778f8df
commit
30068ef763
4 files changed
+420
-125
No files matched your search
@@ -20,12 +20,12 @@
|
||||
/**
|
||||
* Offline unit tests for the Gemini Omni video provider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis)
|
||||
* with Gemini and Together keys and drives the unified
|
||||
* `puter-video-generation` driver, so routing, file loading, credit holds,
|
||||
* usage recording and `puter_output_path` run end to end. Global `fetch` is
|
||||
* stubbed with canned countTokens and Interactions replies, and `secureFetch`
|
||||
* with a canned image, the real network egress points.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) with
|
||||
* Gemini and Together keys and drives the unified `puter-video-generation`
|
||||
* driver, so routing, file loading, credit holds, usage recording and
|
||||
* `puter_output_path` run end to end. Global `fetch` is stubbed with canned
|
||||
* countTokens and Interactions replies, and `secureFetch` with a canned image,
|
||||
* the real network egress points.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
const { secureFetchMock } = vi.hoisted(() => ({ secureFetchMock: vi.fn() }));
|
||||
|
||||
vi.mock('../../../../util/secureHttp.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../util/secureHttp.js')>()),
|
||||
...(await importOriginal<
|
||||
typeof import('../../../../util/secureHttp.js')
|
||||
>()),
|
||||
secureFetch: secureFetchMock,
|
||||
}));
|
||||
|
||||
@@ -208,6 +210,14 @@ const USAGE_360P_IMAGE = {
|
||||
total_tokens: 21287,
|
||||
};
|
||||
|
||||
/** A reply that produced text and thinking but no video. */
|
||||
const USAGE_TEXT_ONLY = {
|
||||
total_input_tokens: 18,
|
||||
total_output_tokens: 12,
|
||||
output_tokens_by_modality: [{ modality: 'text', tokens: 12 }],
|
||||
total_thought_tokens: 240,
|
||||
};
|
||||
|
||||
const videoBlock = (bytes: Buffer = MP4) => ({
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
@@ -374,15 +384,27 @@ describe('GeminiVideoProvider routing', () => {
|
||||
const model = (await videoDriver.models()).find(
|
||||
(entry) => entry.provider === 'together-video-generation',
|
||||
)!.id;
|
||||
await expect(Promise.resolve(runWithContext({
|
||||
actor: TEST_MODE_ACTOR,
|
||||
...(selection === 'driver' ? { driverName: PROVIDER } : {}),
|
||||
}, () => videoDriver.generate({
|
||||
prompt: PROMPT,
|
||||
model,
|
||||
test_mode: true,
|
||||
...(selection === 'provider' ? { provider: PROVIDER } : {}),
|
||||
})))).rejects.toMatchObject({
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext(
|
||||
{
|
||||
actor: TEST_MODE_ACTOR,
|
||||
...(selection === 'driver'
|
||||
? { driverName: PROVIDER }
|
||||
: {}),
|
||||
},
|
||||
() =>
|
||||
videoDriver.generate({
|
||||
prompt: PROMPT,
|
||||
model,
|
||||
test_mode: true,
|
||||
...(selection === 'provider'
|
||||
? { provider: PROVIDER }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: `Model not found: ${model}`,
|
||||
});
|
||||
@@ -394,9 +416,15 @@ describe('GeminiVideoProvider routing', () => {
|
||||
const model = (await videoDriver.models()).find(
|
||||
(entry) => entry.provider === 'together-video-generation',
|
||||
)!.id;
|
||||
await expect(withActor(TEST_MODE_ACTOR, () => videoDriver.generate({
|
||||
prompt: PROMPT, model, test_mode: true,
|
||||
}))).resolves.toBe(SAMPLE_URL);
|
||||
await expect(
|
||||
withActor(TEST_MODE_ACTOR, () =>
|
||||
videoDriver.generate({
|
||||
prompt: PROMPT,
|
||||
model,
|
||||
test_mode: true,
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(SAMPLE_URL);
|
||||
});
|
||||
|
||||
it('keeps Together as the default when both are configured', async () => {
|
||||
@@ -441,16 +469,30 @@ describe('GeminiVideoProvider registration', () => {
|
||||
providers: { 'together-video-generation': { apiKey: 'tg-key' } },
|
||||
} as never);
|
||||
try {
|
||||
const driver = bare.drivers.aiVideo as unknown as VideoGenerationDriver;
|
||||
const driver = bare.drivers
|
||||
.aiVideo as unknown as VideoGenerationDriver;
|
||||
expect(
|
||||
(await driver.models()).some((m) => m.provider === PROVIDER),
|
||||
).toBe(false);
|
||||
await expect(withActor(TEST_MODE_ACTOR, () => driver.generate({
|
||||
prompt: PROMPT, provider: PROVIDER,
|
||||
}))).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(Promise.resolve(runWithContext({
|
||||
actor: TEST_MODE_ACTOR, driverName: PROVIDER,
|
||||
}, () => driver.generate({ prompt: PROMPT })))).rejects.toMatchObject({
|
||||
await expect(
|
||||
withActor(TEST_MODE_ACTOR, () =>
|
||||
driver.generate({
|
||||
prompt: PROMPT,
|
||||
provider: PROVIDER,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext(
|
||||
{
|
||||
actor: TEST_MODE_ACTOR,
|
||||
driverName: PROVIDER,
|
||||
},
|
||||
() => driver.generate({ prompt: PROMPT }),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
@@ -470,16 +512,30 @@ describe('GeminiVideoProvider registration', () => {
|
||||
},
|
||||
} as never);
|
||||
try {
|
||||
const driver = custom.drivers.aiVideo as unknown as VideoGenerationDriver;
|
||||
const driver = custom.drivers
|
||||
.aiVideo as unknown as VideoGenerationDriver;
|
||||
expect(
|
||||
(await driver.models()).some((m) => m.provider === PROVIDER),
|
||||
).toBe(false);
|
||||
await expect(withActor(TEST_MODE_ACTOR, () => driver.generate({
|
||||
prompt: PROMPT, provider: PROVIDER,
|
||||
}))).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(Promise.resolve(runWithContext({
|
||||
actor: TEST_MODE_ACTOR, driverName: PROVIDER,
|
||||
}, () => driver.generate({ prompt: PROMPT })))).rejects.toMatchObject({
|
||||
await expect(
|
||||
withActor(TEST_MODE_ACTOR, () =>
|
||||
driver.generate({
|
||||
prompt: PROMPT,
|
||||
provider: PROVIDER,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext(
|
||||
{
|
||||
actor: TEST_MODE_ACTOR,
|
||||
driverName: PROVIDER,
|
||||
},
|
||||
() => driver.generate({ prompt: PROMPT }),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
@@ -491,9 +547,17 @@ describe('GeminiVideoProvider registration', () => {
|
||||
statusCode: 400,
|
||||
message: `Model not found: ${MODEL}`,
|
||||
});
|
||||
await expect(Promise.resolve(runWithContext({
|
||||
actor: TEST_MODE_ACTOR, driverName: PROVIDER,
|
||||
}, () => driver.generate({ prompt: PROMPT })))).rejects.toMatchObject({
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runWithContext(
|
||||
{
|
||||
actor: TEST_MODE_ACTOR,
|
||||
driverName: PROVIDER,
|
||||
},
|
||||
() => driver.generate({ prompt: PROMPT }),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
@@ -518,9 +582,9 @@ describe('GeminiVideoProvider test_mode', () => {
|
||||
SAMPLE_URL,
|
||||
);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
testMode({ input_reference: 42 }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(testMode({ input_reference: 42 })).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -585,14 +649,26 @@ describe('GeminiVideoProvider option validation', () => {
|
||||
['a size with spaces', { size: '1280 x 720' }],
|
||||
['a non-string size', { size: 720 }],
|
||||
['an empty resolution', { resolution: ' ' }],
|
||||
['conflicting size and resolution', { size: '720p', resolution: '1080p' }],
|
||||
[
|
||||
'conflicting size and resolution',
|
||||
{ size: '720p', resolution: '1080p' },
|
||||
],
|
||||
['width without height', { width: 1280 }],
|
||||
['height without width', { height: 720 }],
|
||||
['an unpublished width and height', { width: 1000, height: 500 }],
|
||||
['string width and height', { width: '1280', height: '720' }],
|
||||
['width and height against a pixel size', { size: '1280x720', width: 1920, height: 1080 }],
|
||||
['width and height against a resolution', { size: '720p', width: 640, height: 360 }],
|
||||
['a flipped width and height', { size: '1280x720', width: 720, height: 1280 }],
|
||||
[
|
||||
'width and height against a pixel size',
|
||||
{ size: '1280x720', width: 1920, height: 1080 },
|
||||
],
|
||||
[
|
||||
'width and height against a resolution',
|
||||
{ size: '720p', width: 640, height: 360 },
|
||||
],
|
||||
[
|
||||
'a flipped width and height',
|
||||
{ size: '1280x720', width: 720, height: 1280 },
|
||||
],
|
||||
])('refuses %s, test mode included', async (_label, args) => {
|
||||
await expect(testMode(args)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
@@ -727,7 +803,11 @@ describe('GeminiVideoProvider request shape', () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const path = `/${actor.user.username}/frame.png`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: { path, size: PNG.byteLength, contentType: 'image/png' },
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: PNG.byteLength,
|
||||
contentType: 'image/png',
|
||||
},
|
||||
fileContent: PNG,
|
||||
});
|
||||
reply();
|
||||
@@ -740,7 +820,11 @@ describe('GeminiVideoProvider request shape', () => {
|
||||
const intruder = await makeUser();
|
||||
const path = `/${owner.actor.user.username}/private.png`;
|
||||
await server.services.fs.write(owner.userId, {
|
||||
fileMetadata: { path, size: PNG.byteLength, contentType: 'image/png' },
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: PNG.byteLength,
|
||||
contentType: 'image/png',
|
||||
},
|
||||
fileContent: PNG,
|
||||
});
|
||||
const err = await withActor(intruder.actor, () =>
|
||||
@@ -773,7 +857,10 @@ describe('GeminiVideoProvider request shape', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['text bytes', `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`],
|
||||
[
|
||||
'text bytes',
|
||||
`data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
|
||||
],
|
||||
['a GIF', 'data:image/gif;base64,R0lGODlhAQABAAAAACw='],
|
||||
])('refuses %s as the image', async (_label, input_reference) => {
|
||||
const { actor } = await makeUser();
|
||||
@@ -813,7 +900,10 @@ describe('GeminiVideoProvider response', () => {
|
||||
reply(
|
||||
interaction({
|
||||
steps: [
|
||||
{ type: 'thought', content: [videoBlock(Buffer.from('x'))] },
|
||||
{
|
||||
type: 'thought',
|
||||
content: [videoBlock(Buffer.from('x'))],
|
||||
},
|
||||
{
|
||||
type: 'model_output',
|
||||
content: [
|
||||
@@ -842,36 +932,106 @@ describe('GeminiVideoProvider response', () => {
|
||||
expect(saved.mimeType).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('fails a reply with no video as 400 upstream_bad_request without charging', async () => {
|
||||
it('charges the reported usage of a reply with no video, then fails it as 400 upstream_bad_request', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ content: [{ type: 'text', text: 'No.' }] }));
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
message: 'Gemini did not return a video',
|
||||
});
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([
|
||||
18,
|
||||
57920,
|
||||
58539 - 57920 + 418,
|
||||
]);
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(batchSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
released.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it('charges text-only usage of a reply with no video as input and text', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(
|
||||
interaction({
|
||||
content: [{ type: 'text', text: 'No.' }],
|
||||
usage: USAGE_TEXT_ONLY,
|
||||
}),
|
||||
);
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
expect(metered()).toEqual([
|
||||
{
|
||||
usageType: `${PROVIDER}:${MODEL}:input`,
|
||||
usageAmount: 18,
|
||||
costOverride: 18 * INPUT_RATE,
|
||||
},
|
||||
{
|
||||
usageType: `${PROVIDER}:${MODEL}:output:text`,
|
||||
usageAmount: 12 + 240,
|
||||
costOverride: (12 + 240) * TEXT_RATE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-MP4 MIME type', { ...videoBlock(), mime_type: 'video/webm' }],
|
||||
['a URI instead of bytes', { type: 'video', mime_type: 'video/mp4', uri: 'https://example.com/v.mp4' }],
|
||||
['invalid base64', { type: 'video', mime_type: 'video/mp4', data: 'not base64!' }],
|
||||
[
|
||||
'a URI instead of bytes',
|
||||
{
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
uri: 'https://example.com/v.mp4',
|
||||
},
|
||||
],
|
||||
[
|
||||
'invalid base64',
|
||||
{ type: 'video', mime_type: 'video/mp4', data: 'not base64!' },
|
||||
],
|
||||
['bytes that are not an MP4', videoBlock(Buffer.from('plain bytes!'))],
|
||||
])('fails a video with %s as 502 without charging', async (_label, block) => {
|
||||
])(
|
||||
'charges the reported usage once and fails a video with %s as 502',
|
||||
async (_label, block) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ content: [block] }));
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
message: 'Gemini video generation returned a malformed video',
|
||||
});
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([
|
||||
18,
|
||||
57920,
|
||||
58539 - 57920 + 418,
|
||||
]);
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('fails a malformed video with unusable usage without charging', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(interaction({ content: [block] }));
|
||||
reply(
|
||||
interaction({
|
||||
content: [videoBlock(Buffer.from('plain bytes!'))],
|
||||
usage: null,
|
||||
}),
|
||||
);
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
message: 'Gemini video generation returned a malformed video',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['incomplete', 'failed', 'cancelled', 'in_progress'])(
|
||||
'fails a %s interaction as 502 without charging',
|
||||
'charges the reported usage of a %s interaction once, then fails it as 502',
|
||||
async (status) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
@@ -879,12 +1039,97 @@ describe('GeminiVideoProvider response', () => {
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
message: 'Gemini video generation did not complete the request',
|
||||
});
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([
|
||||
18,
|
||||
57920,
|
||||
58539 - 57920 + 418,
|
||||
]);
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(batchSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
released.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('charges text-only usage of a failed interaction without inventing video', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(
|
||||
interaction({
|
||||
status: 'failed',
|
||||
content: [],
|
||||
usage: USAGE_TEXT_ONLY,
|
||||
}),
|
||||
);
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
message: 'Gemini video generation did not complete the request',
|
||||
});
|
||||
expect(metered().map((u) => [u.usageType, u.usageAmount])).toEqual([
|
||||
[`${PROVIDER}:${MODEL}:input`, 18],
|
||||
[`${PROVIDER}:${MODEL}:output:text`, 12 + 240],
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges input and thinking of a failed interaction that reports no output and no breakdown', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(
|
||||
interaction({
|
||||
status: 'failed',
|
||||
content: [],
|
||||
usage: {
|
||||
total_input_tokens: 18,
|
||||
total_output_tokens: 0,
|
||||
total_thought_tokens: 240,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
message: 'Gemini video generation did not complete the request',
|
||||
});
|
||||
expect(metered().map((u) => [u.usageType, u.usageAmount])).toEqual([
|
||||
[`${PROVIDER}:${MODEL}:input`, 18],
|
||||
[`${PROVIDER}:${MODEL}:output:text`, 240],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing usage', undefined],
|
||||
[
|
||||
'output without a modality breakdown',
|
||||
{ ...USAGE_TEXT_ONLY, output_tokens_by_modality: undefined },
|
||||
],
|
||||
])(
|
||||
'fails a failed interaction with %s as not completed, without charging',
|
||||
async (_label, usage) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ status: 'failed', content: [], usage }));
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
message: 'Gemini video generation did not complete the request',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('releases the hold when settling a failed interaction throws', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
batchSpy.mockRejectedValueOnce(new Error('metering down'));
|
||||
reply(interaction({ status: 'failed' }));
|
||||
await expect(withActor(actor, () => generate())).rejects.toThrow(
|
||||
'metering down',
|
||||
);
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops reading a reply over 64 MiB', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
@@ -1062,9 +1307,9 @@ describe('GeminiVideoProvider credit hold', () => {
|
||||
const { actor } = await makeUser();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
fetchSpy.mockResolvedValueOnce(Response.json({ totalTokens: 18 }));
|
||||
await expect(
|
||||
withActor(actor, () => generate()),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
await expect(withActor(actor, () => generate())).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
});
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(HOLD);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
@@ -1145,12 +1390,18 @@ describe('GeminiVideoProvider usage', () => {
|
||||
|
||||
it('settles verified 4K usage beyond the requested output cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
reply(interaction({ usage: {
|
||||
total_input_tokens: 14,
|
||||
total_output_tokens: 174289,
|
||||
output_tokens_by_modality: [{ modality: 'video', tokens: 173760 }],
|
||||
total_thought_tokens: 175,
|
||||
} }));
|
||||
reply(
|
||||
interaction({
|
||||
usage: {
|
||||
total_input_tokens: 14,
|
||||
total_output_tokens: 174289,
|
||||
output_tokens_by_modality: [
|
||||
{ modality: 'video', tokens: 173760 },
|
||||
],
|
||||
total_thought_tokens: 175,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await withActor(actor, () => generate({ size: '4k' }));
|
||||
expect(metered().map((u) => [u.usageAmount, u.costOverride])).toEqual([
|
||||
[14, 14 * INPUT_RATE],
|
||||
@@ -1165,7 +1416,8 @@ describe('GeminiVideoProvider usage', () => {
|
||||
interaction({
|
||||
usage: {
|
||||
total_input_tokens: 1107,
|
||||
input_tokens_by_modality: USAGE_360P_IMAGE.input_tokens_by_modality,
|
||||
input_tokens_by_modality:
|
||||
USAGE_360P_IMAGE.input_tokens_by_modality,
|
||||
total_output_tokens: 301_000,
|
||||
output_tokens_by_modality: [
|
||||
{ modality: 'video', tokens: 300_000 },
|
||||
@@ -1191,7 +1443,9 @@ describe('GeminiVideoProvider usage', () => {
|
||||
generate({ input_reference: pngDataUri, size: '360x640' }),
|
||||
);
|
||||
expect(metered().map((u) => u.usageAmount)).toEqual([
|
||||
1107, 19310, 19960 - 19310 + 220,
|
||||
1107,
|
||||
19310,
|
||||
19960 - 19310 + 220,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1243,14 +1497,28 @@ describe('GeminiVideoProvider usage', () => {
|
||||
{ ...USAGE_720P, output_tokens_by_modality: undefined },
|
||||
],
|
||||
[
|
||||
'no video modality',
|
||||
'no video tokens for the returned video',
|
||||
{
|
||||
...USAGE_720P,
|
||||
output_tokens_by_modality: [{ modality: 'text', tokens: 10 }],
|
||||
},
|
||||
],
|
||||
['a missing input total', { ...USAGE_720P, total_input_tokens: undefined }],
|
||||
['a missing output total', { ...USAGE_720P, total_output_tokens: undefined }],
|
||||
[
|
||||
'no video tokens and no breakdown',
|
||||
{
|
||||
...USAGE_720P,
|
||||
total_output_tokens: 0,
|
||||
output_tokens_by_modality: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
'a missing input total',
|
||||
{ ...USAGE_720P, total_input_tokens: undefined },
|
||||
],
|
||||
[
|
||||
'a missing output total',
|
||||
{ ...USAGE_720P, total_output_tokens: undefined },
|
||||
],
|
||||
[
|
||||
'a negative video count',
|
||||
{
|
||||
@@ -1269,10 +1537,15 @@ describe('GeminiVideoProvider usage', () => {
|
||||
'a string video count',
|
||||
{
|
||||
...USAGE_720P,
|
||||
output_tokens_by_modality: [{ modality: 'video', tokens: '57920' }],
|
||||
output_tokens_by_modality: [
|
||||
{ modality: 'video', tokens: '57920' },
|
||||
],
|
||||
},
|
||||
],
|
||||
['video over the output total', { ...USAGE_720P, total_output_tokens: 57919 }],
|
||||
[
|
||||
'video over the output total',
|
||||
{ ...USAGE_720P, total_output_tokens: 57919 },
|
||||
],
|
||||
[
|
||||
'modalities that sum past the output total',
|
||||
{
|
||||
@@ -1283,7 +1556,10 @@ describe('GeminiVideoProvider usage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
['a negative thinking count', { ...USAGE_720P, total_thought_tokens: -1 }],
|
||||
[
|
||||
'a negative thinking count',
|
||||
{ ...USAGE_720P, total_thought_tokens: -1 },
|
||||
],
|
||||
['a string input count', { ...USAGE_720P, total_input_tokens: '18' }],
|
||||
[
|
||||
'an unsafe cost',
|
||||
@@ -1306,19 +1582,16 @@ describe('GeminiVideoProvider usage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
])(
|
||||
'fails %s as 502 without fabricating usage',
|
||||
async (_label, usage) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ usage }));
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
message: 'Gemini video generation returned unusable usage',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
])('fails %s as 502 without fabricating usage', async (_label, usage) => {
|
||||
const { actor } = await makeUser();
|
||||
const { released } = trackHolds();
|
||||
reply(interaction({ usage }));
|
||||
expect(await callerError(() => generate(), actor)).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
message: 'Gemini video generation returned unusable usage',
|
||||
});
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
expect(released).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -162,8 +162,7 @@ interface Interaction {
|
||||
}
|
||||
|
||||
type CountTokensPart =
|
||||
| { text: string }
|
||||
| { inlineData: { mimeType: string; data: string } };
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } };
|
||||
|
||||
interface CountTokensRequest {
|
||||
contents: { parts: CountTokensPart[] }[];
|
||||
@@ -183,10 +182,11 @@ const isSet = (value: unknown): boolean =>
|
||||
* Billable tokens from the reply's usage: input of every modality, video
|
||||
* output, and the rest of the output plus thinking as text. Undefined when a
|
||||
* count is missing, malformed, inconsistent or would cost more than a safe
|
||||
* integer.
|
||||
* integer, or when a returned video has no video tokens.
|
||||
*/
|
||||
const billedTokens = (
|
||||
usage: InteractionUsage | undefined,
|
||||
returnedVideo: boolean,
|
||||
): { input: number; video: number; text: number } | undefined => {
|
||||
if (!usage || typeof usage !== 'object') return undefined;
|
||||
const input = tokenCount(usage.total_input_tokens);
|
||||
@@ -195,7 +195,11 @@ const billedTokens = (
|
||||
usage.total_thought_tokens === undefined
|
||||
? 0
|
||||
: tokenCount(usage.total_thought_tokens);
|
||||
const byModality = usage.output_tokens_by_modality;
|
||||
// Only an empty output can go without a modality breakdown.
|
||||
const byModality =
|
||||
usage.output_tokens_by_modality === undefined && output === 0
|
||||
? []
|
||||
: usage.output_tokens_by_modality;
|
||||
if (
|
||||
input === undefined ||
|
||||
output === undefined ||
|
||||
@@ -213,7 +217,7 @@ const billedTokens = (
|
||||
if (String(entry.modality).toLowerCase() === 'video') video += tokens;
|
||||
}
|
||||
// `total_output_tokens` excludes thinking, so the modalities fit inside it.
|
||||
if (video === 0 || listed > output) return undefined;
|
||||
if ((returnedVideo && video === 0) || listed > output) return undefined;
|
||||
|
||||
const text = output - video + thoughts;
|
||||
const costs = GEMINI_OMNI_COSTS;
|
||||
@@ -335,37 +339,46 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const videoBlock = this.#findVideo(interaction);
|
||||
|
||||
// Usage the reply reports is charged before the reply is checked,
|
||||
// so a failed or videoless generation still pays for its tokens.
|
||||
const billed = billedTokens(
|
||||
interaction.usage,
|
||||
videoBlock !== undefined,
|
||||
);
|
||||
if (billed) {
|
||||
const usages: UsageInput[] = [
|
||||
{
|
||||
usageType: `${usagePrefix}:input`,
|
||||
usageAmount: billed.input,
|
||||
costOverride: billed.input * costs.input,
|
||||
},
|
||||
{
|
||||
usageType: `${usagePrefix}:output:video`,
|
||||
usageAmount: billed.video,
|
||||
costOverride: billed.video * costs.outputVideo,
|
||||
},
|
||||
{
|
||||
usageType: `${usagePrefix}:output:text`,
|
||||
usageAmount: billed.text,
|
||||
costOverride: billed.text * costs.outputText,
|
||||
},
|
||||
].filter((usage) => usage.usageAmount > 0);
|
||||
await metering.batchIncrementUsages(actor, usages);
|
||||
}
|
||||
|
||||
if (interaction.status !== 'completed') {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini video generation did not complete the request',
|
||||
);
|
||||
}
|
||||
const video = this.#readVideo(interaction);
|
||||
|
||||
const billed = billedTokens(interaction.usage);
|
||||
const video = this.#readVideo(videoBlock);
|
||||
if (!billed) {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini video generation returned unusable usage',
|
||||
);
|
||||
}
|
||||
const usages: UsageInput[] = [
|
||||
{
|
||||
usageType: `${usagePrefix}:input`,
|
||||
usageAmount: billed.input,
|
||||
costOverride: billed.input * costs.input,
|
||||
},
|
||||
{
|
||||
usageType: `${usagePrefix}:output:video`,
|
||||
usageAmount: billed.video,
|
||||
costOverride: billed.video * costs.outputVideo,
|
||||
},
|
||||
{
|
||||
usageType: `${usagePrefix}:output:text`,
|
||||
usageAmount: billed.text,
|
||||
costOverride: billed.text * costs.outputText,
|
||||
},
|
||||
].filter((usage) => usage.usageAmount > 0);
|
||||
await metering.batchIncrementUsages(actor, usages);
|
||||
|
||||
return `data:video/mp4;base64,${video}`;
|
||||
} finally {
|
||||
@@ -394,7 +407,10 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
throw badRequest(`${key} is not supported by Gemini Omni`);
|
||||
}
|
||||
}
|
||||
if (isSet(params.output_format) && params.output_format !== 'video/mp4') {
|
||||
if (
|
||||
isSet(params.output_format) &&
|
||||
params.output_format !== 'video/mp4'
|
||||
) {
|
||||
throw badRequest('output_format must be video/mp4');
|
||||
}
|
||||
const inputReference = params.input_reference;
|
||||
@@ -416,9 +432,9 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* `size` and `resolution` take a resolution or an exact pixel size;
|
||||
* `width` with `height` take an exact pixel size. Controls that are given
|
||||
* together must agree. Defaults to 720p at 16:9.
|
||||
* `size` and `resolution` take a resolution or an exact pixel size; `width`
|
||||
* with `height` take an exact pixel size. Controls that are given together
|
||||
* must agree. Defaults to 720p at 16:9.
|
||||
*/
|
||||
#readSize(params: IGenerateVideoParams): OmniSize {
|
||||
const unsupported = () =>
|
||||
@@ -499,7 +515,9 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
).buffer;
|
||||
const mimeType = sniffImageMime(bytes);
|
||||
if (!mimeType || !IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
throw badRequest('input_reference must be a PNG, JPEG or WebP image');
|
||||
throw badRequest(
|
||||
'input_reference must be a PNG, JPEG or WebP image',
|
||||
);
|
||||
}
|
||||
return { mimeType, data: bytes.toString('base64') };
|
||||
}
|
||||
@@ -568,10 +586,10 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The base64 MP4 of the last video the model returned. */
|
||||
#readVideo(interaction: Interaction): string {
|
||||
/** The last video block the model returned, not yet checked. */
|
||||
#findVideo(interaction: Interaction): InteractionContent | undefined {
|
||||
const steps = Array.isArray(interaction.steps) ? interaction.steps : [];
|
||||
const video = steps
|
||||
return steps
|
||||
.flatMap((step) =>
|
||||
step?.type === 'model_output' && Array.isArray(step.content)
|
||||
? step.content
|
||||
@@ -579,6 +597,10 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
)
|
||||
.filter((content) => content?.type === 'video')
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
/** The base64 MP4 held by the video block. */
|
||||
#readVideo(video: InteractionContent | undefined): string {
|
||||
if (!video) {
|
||||
throw new HttpError(400, 'Gemini did not return a video', {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
|
||||
@@ -215,9 +215,9 @@ Every successful generation is charged to the user's AI credits according to the
|
||||
|
||||
- **Seedance** is priced per second. If the balance cannot cover the requested length, Puter shortens the clip to the longest duration the model supports that the balance does cover, and rejects with `insufficient_funds` only when even the shortest clip is unaffordable. The response does not flag a shortened clip, so read `video.duration` once metadata has loaded if the exact length matters.
|
||||
- **Together AI** prices its older models per clip, so those requests are either accepted at the model's duration or rejected with `insufficient_funds`. Its per-second models (marked in the Together AI table), including the default Wan 2.7, are shortened like Seedance, and the final charge is the amount Together AI reports for the job.
|
||||
- **Gemini Omni** is priced per token and has no length control, so it's never shortened. Before generating, Puter counts the input tokens and holds an estimate of about $3.63 plus the input. The estimate covers 173,760 video tokens and 65,536 text and thinking tokens. A balance that can't cover it fails with `insufficient_funds` before generation starts. The charge is for the tokens Google reports for the clip. It can be higher than the estimate, because Google can go past the requested output limit.
|
||||
- **Gemini Omni** is priced per token and has no length control, so it's never shortened. Before generating, Puter counts the input tokens and holds an estimate of about $3.63 plus the input. The estimate covers 173,760 video tokens and 65,536 text and thinking tokens. A balance that can't cover it fails with `insufficient_funds` before generation starts. The charge is for the tokens Google reports for the clip. It can be higher than the estimate, because Google can go past the requested output limit. When Google's reply reports token usage but fails or has no usable clip, those tokens are charged and the request still fails.
|
||||
|
||||
A request that fails or times out is not charged. The request and concurrency limits that apply to every AI call are listed in [Rate limits and quotas](/rate-limits-and-quotas/).
|
||||
Apart from that Gemini Omni case, a request that fails or times out is not charged. The request and concurrency limits that apply to every AI call are listed in [Rate limits and quotas](/rate-limits-and-quotas/).
|
||||
|
||||
#### How long it takes
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ See [`txt2img()`](/AI/txt2img) for provider-specific options and supported model
|
||||
|
||||
A longer prompt, a larger image, or input over the token limit fails with `bad_request` (`413 input_too_large` for an image file or `data:` URI over 5 MiB) before generation starts. A reply over 64 MiB fails with `502 upstream_failed`, and a generation that runs past 10 minutes fails with `504 upstream_timeout`. Neither is charged.
|
||||
|
||||
The reservation is a conservative estimate based on verified generation usage, not a maximum charge. Google can return more output than the requested 65,536 tokens, and the charge is for the input, video, output and thinking tokens Google reports, even when that's more than the reservation.
|
||||
The reservation is a conservative estimate based on verified generation usage, not a maximum charge. Google can return more output than the requested 65,536 tokens, and the charge is for the input, video, output and thinking tokens Google reports, even when that's more than the reservation. A reply that reports token usage is charged for it even when the reply failed or has no usable clip.
|
||||
|
||||
### Remote chat image inlining
|
||||
|
||||
|
||||
Reference in new issue
Block a user