fix: open ai cost (#3488)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-08-01 12:34:36 -07:00
committed by GitHub
parent f6a50c4fb4
commit 3ac6c8532e
7 changed files with 337 additions and 79 deletions
@@ -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(() => {
+112
View File
@@ -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,
};
+31 -30
View File
@@ -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 1530s. 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 1530s. 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>,