mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
fix: open ai cost (#3488)
This commit is contained in:
@@ -150,6 +150,9 @@ beforeEach(() => {
|
||||
togetherVideosRetrieveMock.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
|
||||
100_000_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
|
||||
export interface ICapSecondsParams {
|
||||
metering: MeteringService;
|
||||
actor: Actor;
|
||||
/** Price of one second of output, in micro-cents. */
|
||||
perSecondMicroCents: number;
|
||||
/** Duration the provider resolved from the request, in seconds. */
|
||||
requestedSeconds: number;
|
||||
/**
|
||||
* Durations the model actually accepts. When present the cap snaps _down_
|
||||
* to the longest supported duration the actor can pay for; when absent any
|
||||
* whole number of seconds down to `minSeconds` is allowed.
|
||||
*/
|
||||
allowedSeconds?: readonly number[] | null;
|
||||
/** Floor for models with no discrete ladder. Defaults to 1. */
|
||||
minSeconds?: number;
|
||||
/** Model id, for the 402 message. */
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a video's duration to what the actor's remaining credit actually buys.
|
||||
*
|
||||
* Video is the only AI modality where a single request can cost multiples of a
|
||||
* whole monthly allowance (Sora 2 Pro at 1080p is $0.70/second — a 12s clip is
|
||||
* $8.40), so an all-or-nothing affordability check leaves the entire request
|
||||
* cost as slop above the budget. This is the video analogue of the `max_tokens`
|
||||
* clamp in `ChatCompletionDriver`: shorten the output to fit the wallet, and
|
||||
* only reject outright when even the shortest supported clip is unaffordable.
|
||||
*
|
||||
* Returns the duration the caller must actually request upstream — callers MUST
|
||||
* use the returned value both for the upstream call and for metering, or the
|
||||
* cap buys nothing.
|
||||
*/
|
||||
export async function capSecondsToRemainingCredits({
|
||||
metering,
|
||||
actor,
|
||||
perSecondMicroCents,
|
||||
requestedSeconds,
|
||||
allowedSeconds,
|
||||
minSeconds,
|
||||
modelId,
|
||||
}: ICapSecondsParams): Promise<number> {
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
// Unpriced or free output — nothing to clamp against.
|
||||
if (!Number.isFinite(perSecondMicroCents) || perSecondMicroCents <= 0) {
|
||||
return requestedSeconds;
|
||||
}
|
||||
|
||||
const remaining = await metering.getRemainingUsage(actor);
|
||||
const affordableSeconds = Math.floor(remaining / perSecondMicroCents);
|
||||
|
||||
const ladder = (allowedSeconds ?? [])
|
||||
.filter((s) => Number.isFinite(s) && s > 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const usd = (microCents: number) => (microCents / 1e8).toFixed(2);
|
||||
const insufficient = (shortest: number) =>
|
||||
new HttpError(
|
||||
402,
|
||||
`Insufficient funds: the shortest ${modelId ?? 'video'} clip is ` +
|
||||
`${shortest}s ($${usd(shortest * perSecondMicroCents)}), ` +
|
||||
`more than the $${usd(remaining)} remaining.`,
|
||||
{ legacyCode: 'insufficient_funds' },
|
||||
);
|
||||
|
||||
if (ladder.length > 0) {
|
||||
// A sub-ladder request already gets rounded up to the shortest
|
||||
// supported duration by every provider, so price it that way here too.
|
||||
const ceiling = Math.min(
|
||||
Math.max(requestedSeconds, ladder[0]),
|
||||
affordableSeconds,
|
||||
);
|
||||
for (let i = ladder.length - 1; i >= 0; i--) {
|
||||
if (ladder[i] <= ceiling) return ladder[i];
|
||||
}
|
||||
throw insufficient(ladder[0]);
|
||||
}
|
||||
|
||||
const floor = Math.max(1, minSeconds ?? 1);
|
||||
const capped = Math.min(requestedSeconds, affordableSeconds);
|
||||
if (capped < floor) throw insufficient(floor);
|
||||
return capped;
|
||||
}
|
||||
@@ -79,7 +79,10 @@ vi.mock('@google/genai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
|
||||
// Plenty of credit for every test that isn't specifically about the gate.
|
||||
const AMPLE_CREDIT = 100_000_000_000;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -118,8 +121,8 @@ beforeEach(() => {
|
||||
generateVideosMock.mockReset();
|
||||
getVideosOperationMock.mockReset();
|
||||
googleAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
|
||||
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -178,7 +181,7 @@ describe('GeminiVideoProvider.generate test_mode', () => {
|
||||
provider.generate({ prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(remainingUsageSpy).not.toHaveBeenCalled();
|
||||
expect(generateVideosMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -203,13 +206,53 @@ describe('GeminiVideoProvider.generate argument validation', () => {
|
||||
describe('GeminiVideoProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Gemini when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
remainingUsageSpy.mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(generateVideosMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// veo-3.1 is 40 usd-cents/second at 720p and only accepts 4s / 6s / 8s.
|
||||
it('caps the clip to the longest supported duration the credit buys', async () => {
|
||||
const provider = makeProvider();
|
||||
remainingUsageSpy.mockResolvedValueOnce(7 * 40 * 1_000_000);
|
||||
generateVideosMock.mockResolvedValueOnce(completedOperation());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
prompt: 'hi',
|
||||
model: 'veo-3.1-generate-preview',
|
||||
size: '1280x720',
|
||||
seconds: 8,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generateVideosMock.mock.calls[0][0].config).toMatchObject({
|
||||
durationSeconds: 6,
|
||||
});
|
||||
const [, , count, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(count).toBe(6);
|
||||
expect(cost).toBe(6 * 40 * 1_000_000);
|
||||
});
|
||||
|
||||
it('stays all-or-nothing for 1080p, which upstream locks to 8s', async () => {
|
||||
const provider = makeProvider();
|
||||
remainingUsageSpy.mockResolvedValueOnce(7 * 40 * 1_000_000);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
prompt: 'hi',
|
||||
model: 'veo-3.1-generate-preview',
|
||||
size: '1920x1080',
|
||||
seconds: 8,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(generateVideosMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape & parameter mapping ──────────────────────────────
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
|
||||
import { capSecondsToRemainingCredits } from '../../creditCap.js';
|
||||
import { VideoProvider } from '../VideoProvider.js';
|
||||
import { GEMINI_VIDEO_GENERATION_MODELS, IGeminiVideoModel } from './models.js';
|
||||
|
||||
@@ -133,8 +135,7 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
`No per-second cost configured for video model '${selectedModel.id}'`,
|
||||
);
|
||||
}
|
||||
const costCents = perSecondCents * durationSeconds;
|
||||
const costInMicroCents = Math.ceil(costCents * 1_000_000);
|
||||
const perSecondMicroCents = Math.ceil(perSecondCents * 1_000_000);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
@@ -143,15 +144,21 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
});
|
||||
}
|
||||
|
||||
const usageAllowed = await this.#meteringService.hasEnoughCredits(
|
||||
// Clamp the clip to what remaining credit buys instead of rejecting
|
||||
// the request outright. 1080p/4k and reference-image renders are
|
||||
// locked to 8s by the upstream API, so those stay all-or-nothing.
|
||||
durationSeconds = await capSecondsToRemainingCredits({
|
||||
metering: this.#meteringService,
|
||||
actor,
|
||||
costInMicroCents,
|
||||
);
|
||||
if (!usageAllowed) {
|
||||
throw new HttpError(402, 'Insufficient funds', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
}
|
||||
perSecondMicroCents,
|
||||
requestedSeconds: durationSeconds,
|
||||
allowedSeconds:
|
||||
isHighRes || hasRefImages
|
||||
? [durationSeconds]
|
||||
: selectedModel.durationSeconds,
|
||||
modelId: selectedModel.id,
|
||||
});
|
||||
const costInMicroCents = perSecondMicroCents * durationSeconds;
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
numberOfVideos: 1,
|
||||
|
||||
@@ -86,9 +86,12 @@ vi.mock('openai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
// Plenty of credit for every test that isn't specifically about the gate.
|
||||
const AMPLE_CREDIT = 100_000_000_000;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
@@ -128,8 +131,8 @@ beforeEach(() => {
|
||||
videosRetrieveMock.mockReset();
|
||||
videosDownloadContentMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
|
||||
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -184,7 +187,7 @@ describe('OpenAIVideoProvider.generate test_mode', () => {
|
||||
}),
|
||||
);
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(remainingUsageSpy).not.toHaveBeenCalled();
|
||||
expect(videosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -221,9 +224,13 @@ describe('OpenAIVideoProvider.generate argument validation', () => {
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
|
||||
describe('OpenAIVideoProvider.generate credit gate', () => {
|
||||
// sora-2 is 10 usd-cents/second, i.e. 10_000_000 micro-cents/second, and
|
||||
// only accepts 4s / 8s / 12s clips.
|
||||
const PER_SECOND = 10_000_000;
|
||||
|
||||
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
remainingUsageSpy.mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -232,6 +239,85 @@ describe('OpenAIVideoProvider.generate credit gate', () => {
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(videosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 402 when credit falls one micro-cent short of the shortest clip', async () => {
|
||||
const provider = makeProvider();
|
||||
remainingUsageSpy.mockResolvedValueOnce(4 * PER_SECOND - 1);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
seconds: 4,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(videosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// remaining credit, requested seconds, seconds actually requested upstream
|
||||
[4 * PER_SECOND, 12, '4'],
|
||||
[7 * PER_SECOND, 12, '4'],
|
||||
[10 * PER_SECOND, 12, '8'],
|
||||
[12 * PER_SECOND, 12, '12'],
|
||||
[4 * PER_SECOND, 8, '4'],
|
||||
])(
|
||||
'caps a %i micro-cent balance asking for %is down to %ss',
|
||||
async (remaining, requested, expected) => {
|
||||
const provider = makeProvider();
|
||||
remainingUsageSpy.mockResolvedValueOnce(remaining);
|
||||
videosCreateMock.mockResolvedValueOnce(
|
||||
completedJob({ seconds: expected }),
|
||||
);
|
||||
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({
|
||||
prompt: 'hi',
|
||||
model: 'sora-2',
|
||||
seconds: requested,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(videosCreateMock.mock.calls[0][0]).toMatchObject({
|
||||
seconds: expected,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('never meters more than the capped clip costs', async () => {
|
||||
const provider = makeProvider();
|
||||
// $1.00 — enough for 8s, not the 12s the caller asked for.
|
||||
remainingUsageSpy.mockResolvedValueOnce(10 * PER_SECOND);
|
||||
videosCreateMock.mockResolvedValueOnce(completedJob({ seconds: '8' }));
|
||||
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 12 }),
|
||||
);
|
||||
|
||||
const [, , count, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(count).toBe(8);
|
||||
expect(cost).toBe(8 * PER_SECOND);
|
||||
expect(cost).toBeLessThanOrEqual(10 * PER_SECOND);
|
||||
});
|
||||
|
||||
it('leaves an affordable request untouched', async () => {
|
||||
const provider = makeProvider();
|
||||
remainingUsageSpy.mockResolvedValueOnce(AMPLE_CREDIT);
|
||||
videosCreateMock.mockResolvedValueOnce(completedJob({ seconds: '12' }));
|
||||
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 12 }),
|
||||
);
|
||||
|
||||
expect(videosCreateMock.mock.calls[0][0]).toMatchObject({
|
||||
seconds: '12',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape & parameter mapping ──────────────────────────────
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
@@ -22,6 +23,7 @@ import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
|
||||
import { capSecondsToRemainingCredits } from '../../creditCap.js';
|
||||
import { VideoProvider } from '../VideoProvider.js';
|
||||
import { OPENAI_VIDEO_MODELS, OPENAI_VIDEO_ALLOWED_SECONDS } from './models.js';
|
||||
import { Readable } from 'stream';
|
||||
@@ -102,24 +104,28 @@ export class OpenAIVideoProvider extends VideoProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const estimatedUnits =
|
||||
this.#parseSeconds(normalizedSeconds) ?? DEFAULT_DURATION_SECONDS;
|
||||
const actor = Context.get('actor');
|
||||
const costInMicroCents = costPerSecondCents * 1_000_000;
|
||||
const usageAllowed = await this.#meteringService.hasEnoughCredits(
|
||||
|
||||
// Clamp the clip to what the actor's remaining credit buys rather than
|
||||
// rejecting the whole request — a 12s Sora 2 Pro clip is $8.40, so
|
||||
// all-or-nothing leaves the entire request cost as slop above budget.
|
||||
const estimatedUnits = await capSecondsToRemainingCredits({
|
||||
metering: this.#meteringService,
|
||||
actor,
|
||||
costInMicroCents * estimatedUnits,
|
||||
);
|
||||
if (!usageAllowed) {
|
||||
throw new HttpError(402, 'Insufficient funds', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
}
|
||||
perSecondMicroCents: costInMicroCents,
|
||||
requestedSeconds:
|
||||
this.#parseSeconds(normalizedSeconds) ??
|
||||
DEFAULT_DURATION_SECONDS,
|
||||
allowedSeconds:
|
||||
selectedModel.durationSeconds ?? OPENAI_VIDEO_ALLOWED_SECONDS,
|
||||
modelId: selectedModel.id,
|
||||
});
|
||||
|
||||
const createParams: OpenAI.VideoCreateParams = {
|
||||
prompt,
|
||||
model: selectedModel.id,
|
||||
seconds: normalizedSeconds as OpenAI.VideoSeconds,
|
||||
seconds: String(estimatedUnits) as OpenAI.VideoSeconds,
|
||||
size: normalizedSize as OpenAI.VideoSize,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,30 +3,31 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared utilities for AI provider integration tests.
|
||||
*
|
||||
* Each test reads its credentials from `PUTER_TEST_AI_*` env vars
|
||||
* (loaded by the vitest config's `PUTER_` prefix) and skips itself
|
||||
* when the var is missing — tests run only on developer machines and
|
||||
* in CI environments that supply the right secrets.
|
||||
* Each test reads its credentials from `PUTER_TEST_AI_*` env vars (loaded by
|
||||
* the vitest config's `PUTER_` prefix) and skips itself when the var is missing
|
||||
* — tests run only on developer machines and in CI environments that supply the
|
||||
* right secrets.
|
||||
*
|
||||
* Filename intentionally omits `.test.` so vitest does not treat this
|
||||
* helper as a test file.
|
||||
* Filename intentionally omits `.test.` so vitest does not treat this helper as
|
||||
* a test file.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../core/actor.js';
|
||||
@@ -35,8 +36,8 @@ import { runWithContext } from '../core/context.js';
|
||||
import type { MeteringService } from '../services/metering/MeteringService.js';
|
||||
|
||||
/**
|
||||
* Returns the env var value, or `undefined` if missing/empty.
|
||||
* Used as the gate for `describe.skipIf` blocks.
|
||||
* Returns the env var value, or `undefined` if missing/empty. Used as the gate
|
||||
* for `describe.skipIf` blocks.
|
||||
*/
|
||||
export const optionalEnv = (name: string): string | undefined => {
|
||||
const v = process.env[name];
|
||||
@@ -44,24 +45,23 @@ export const optionalEnv = (name: string): string | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the env var is unset, signaling the test block
|
||||
* should be skipped. Pair with `describe.skipIf(skipUnlessEnv(...))`.
|
||||
* Returns true when the env var is unset, signaling the test block should be
|
||||
* skipped. Pair with `describe.skipIf(skipUnlessEnv(...))`.
|
||||
*/
|
||||
export const skipUnlessEnv = (name: string): boolean => !optionalEnv(name);
|
||||
|
||||
/**
|
||||
* Per-test timeout for provider integration tests. The default 5s
|
||||
* vitest timeout is way too short for real API calls — image
|
||||
* generation in particular routinely takes 15–30s. Pass this as the
|
||||
* third argument to `it(...)`.
|
||||
* Per-test timeout for provider integration tests. The default 5s vitest
|
||||
* timeout is way too short for real API calls — image generation in particular
|
||||
* routinely takes 15–30s. Pass this as the third argument to `it(...)`.
|
||||
*/
|
||||
export const INTEGRATION_TEST_TIMEOUT_MS = 90_000;
|
||||
|
||||
/**
|
||||
* Returns a no-op MeteringService stub. Real metering would write to
|
||||
* DynamoDB / Redis, which integration tests for AI providers don't
|
||||
* care about — we just need the provider's metering calls to not
|
||||
* throw and to short-circuit credit checks.
|
||||
* Returns a no-op MeteringService stub. Real metering would write to DynamoDB /
|
||||
* Redis, which integration tests for AI providers don't care about — we just
|
||||
* need the provider's metering calls to not throw and to short-circuit credit
|
||||
* checks.
|
||||
*/
|
||||
export const makeMeteringStub = (): MeteringService =>
|
||||
({
|
||||
@@ -69,13 +69,14 @@ export const makeMeteringStub = (): MeteringService =>
|
||||
incrementUsage: () => Promise.resolve({} as never),
|
||||
batchIncrementUsages: () => Promise.resolve([] as never),
|
||||
hasEnoughCredits: () => Promise.resolve(true),
|
||||
getRemainingUsage: () => Promise.resolve(Number.MAX_SAFE_INTEGER),
|
||||
getReportedCosts: () => [],
|
||||
}) as unknown as MeteringService;
|
||||
|
||||
/**
|
||||
* Run `fn` inside a request-scoped context with `SYSTEM_ACTOR` set,
|
||||
* which is what providers expect (`Context.get('actor')`). The system
|
||||
* actor bypasses metering / quota gates by design.
|
||||
* Run `fn` inside a request-scoped context with `SYSTEM_ACTOR` set, which is
|
||||
* what providers expect (`Context.get('actor')`). The system actor bypasses
|
||||
* metering / quota gates by design.
|
||||
*/
|
||||
export const withTestActor = <T>(
|
||||
fn: () => T | Promise<T>,
|
||||
|
||||
Reference in New Issue
Block a user