mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 17:07:16 +00:00
feat: add BytePlus image and video providers
Extends the BytePlus ModelArk integration to the puter-image-generation and puter-video-generation drivers, reusing the same services.byteplus API key and regional apiBaseUrl as the chat provider. Image (Seedream/SeedEdit via OpenAI-compatible /images/generations): - dola-seedream-5-0-pro (pixel-tier pricing + billed input images from the 2nd on), seedream-5-0-lite, 4-5, 4-0, and seededit-3-0-i2i - quality tiers 1K/1.5K/2K; aspect ratios resolve to Ark's documented pixel sizes; explicit WxH passes through with Ark's bounds enforced Video (Seedance via Ark's async /contents/generations/tasks + polling): - Seedance 2.0 / 2.0 Fast / 2.0 Mini / 1.5 Pro / 1.0 Pro / 1.0 Pro Fast (2.5 is priced but its API isn't live yet, so it's excluded) - per-video-token billing from usage.completion_tokens, with per-second estimates feeding the credit cap; audio vs silent rates for 1.5 Pro - first/last frame and reference-image inputs; generate_audio param added to IGenerateVideoParams Pricing and capabilities hardcoded from the official docs (ModelArk pages 1544106, 1330310, 1520757, 1521309, 1541523). Offline unit tests mock the SDK / global fetch; integration tests are env-gated on PUTER_TEST_AI_BYTEPLUS_API_KEY.
This commit is contained in:
@@ -341,7 +341,9 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://llm.onerouter.pro/v1"
|
||||
},
|
||||
// BytePlus ModelArk. `apiBaseUrl` selects the region; see
|
||||
// BytePlus ModelArk. One key powers chat (Seed/GLM/DeepSeek), image
|
||||
// generation (Seedream) and video generation (Seedance); `apiBaseUrl`
|
||||
// selects the region; see
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1330310 for options.
|
||||
"byteplus": {
|
||||
"apiKey": "",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { BytePlusImageProvider } from './providers/byteplus/BytePlusImageProvider.js';
|
||||
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
|
||||
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
|
||||
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
|
||||
@@ -59,6 +60,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
'cloudflare-image-generation',
|
||||
'xai-image-generation',
|
||||
'replicate-image-generation',
|
||||
'byteplus-image-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -290,8 +292,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
const cloudflare = (providers['cloudflare-image-generation'] ??
|
||||
providers['cloudflare-workers-ai-image'] ??
|
||||
providers['cloudflare-workers-ai']) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const cfToken =
|
||||
(cloudflare?.apiToken as string | undefined) ??
|
||||
(cloudflare?.apiKey as string | undefined) ??
|
||||
@@ -306,8 +307,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
apiToken: cfToken,
|
||||
accountId: cfAccount,
|
||||
apiBaseUrl: cloudflare?.apiBaseUrl as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
@@ -332,6 +332,26 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
m,
|
||||
);
|
||||
}
|
||||
|
||||
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
|
||||
// selects the ModelArk region, same as the chat provider.
|
||||
const byteplusCfg = (providers['byteplus-image-generation'] ??
|
||||
providers['byteplus']) as Record<string, unknown> | undefined;
|
||||
const byteplusKey = readKey(
|
||||
providers['byteplus-image-generation'],
|
||||
providers['byteplus'],
|
||||
);
|
||||
if (byteplusKey) {
|
||||
this.#providers['byteplus-image-generation'] =
|
||||
new BytePlusImageProvider(
|
||||
{
|
||||
apiKey: byteplusKey,
|
||||
apiBaseUrl: byteplusCfg?.apiBaseUrl as
|
||||
string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #buildModelMap() {
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the BytePlus ModelArk image provider.
|
||||
*
|
||||
* Generates one 1K image on seedream-4-0 (the cheapest catalog entry,
|
||||
* $0.03/image). Skipped when `PUTER_TEST_AI_BYTEPLUS_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { BytePlusImageProvider } from './BytePlusImageProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_BYTEPLUS_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'BytePlusImageProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates an image URL from seedream-4-0',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new BytePlusImageProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'seedream-4-0',
|
||||
prompt: 'a single red dot on a white background',
|
||||
quality: '1k',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(typeof url).toBe('string');
|
||||
expect(
|
||||
url.startsWith('https://') || url.startsWith('data:'),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for BytePlusImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs BytePlusImageProvider directly against the
|
||||
* live wired `MeteringService` so the recording side is exercised
|
||||
* end-to-end. Ark's image API is OpenAI-compatible so the OpenAI SDK
|
||||
* is mocked at the module boundary; that's the real network egress
|
||||
* point.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { BYTEPLUS_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { BytePlusImageProvider } from './BytePlusImageProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { generateMock, openAICtor } = vi.hoisted(() => ({
|
||||
generateMock: vi.fn(),
|
||||
openAICtor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
opts: unknown,
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.images = { generate: generateMock };
|
||||
// Some sibling providers boot through the same SDK module.
|
||||
this.chat = { completions: { create: vi.fn() } };
|
||||
this.post = vi.fn();
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
});
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = (
|
||||
config: { apiKey?: string; apiBaseUrl?: string } = {},
|
||||
) =>
|
||||
new BytePlusImageProvider(
|
||||
{
|
||||
apiKey: config.apiKey ?? 'test-key',
|
||||
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
|
||||
},
|
||||
server.services.metering,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
generateMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const sampleResponse = { data: [{ url: 'https://ark.example/img/1' }] };
|
||||
|
||||
const findModel = (id: string) =>
|
||||
BYTEPLUS_IMAGE_GENERATION_MODELS.find((m) => m.id === id)!;
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider construction', () => {
|
||||
it('defaults the OpenAI SDK to the ap-southeast ModelArk base URL', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://ark.ap-southeast.bytepluses.com/api/v3',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a configured apiBaseUrl (region selection)', () => {
|
||||
makeProvider({
|
||||
apiBaseUrl: 'https://ark.eu-west.bytepluses.com/api/v3',
|
||||
});
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://ark.eu-west.bytepluses.com/api/v3',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() =>
|
||||
new BytePlusImageProvider(
|
||||
{ apiKey: '' },
|
||||
server.services.metering,
|
||||
),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider model catalog', () => {
|
||||
it('returns seedream-5-0-lite as the default', () => {
|
||||
expect(makeProvider().getDefaultModel()).toBe(
|
||||
'seedream-5-0-lite-260128',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the static catalog verbatim', () => {
|
||||
expect(makeProvider().models()).toBe(
|
||||
BYTEPLUS_IMAGE_GENERATION_MODELS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── test_mode / validation / credit gate ────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate gates', () => {
|
||||
it('returns the canned sample URL in test_mode without side effects', async () => {
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'x', test_mode: true }),
|
||||
);
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 on a missing or blank prompt', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: undefined as unknown as string,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: ' ' })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 402 BEFORE hitting ModelArk when the actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model resolution ────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate model resolution', () => {
|
||||
it('falls back to the default model for unknown ids', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ model: 'nope', prompt: 'hi' }),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe(
|
||||
'seedream-5-0-lite-260128',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves series and byteplus-prefixed aliases to the dated id', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'byteplus/seedream-4-0',
|
||||
prompt: 'hi',
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe(
|
||||
'seedream-4-0-250828',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the undated seedream-5-0 alias to the lite snapshot', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ model: 'seedream-5-0', prompt: 'hi' }),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].model).toBe(
|
||||
'seedream-5-0-lite-260128',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate request shape', () => {
|
||||
it('sends the tier keyword size (Ark default 2K), url format and no watermark', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a red dot' }),
|
||||
);
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.size).toBe('2K');
|
||||
expect(sent.response_format).toBe('url');
|
||||
expect(sent.watermark).toBe(false);
|
||||
expect(sent.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps quality to the tier keyword case-insensitively', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi', quality: '1.5K' }),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('1.5K');
|
||||
});
|
||||
|
||||
it('resolves an aspect ratio + tier to the documented pixel size', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
quality: '1k',
|
||||
ratio: { w: 16, h: 9 },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
|
||||
});
|
||||
|
||||
it('passes explicit pixel dimensions straight through', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
ratio: { w: 2048, h: 1024 },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('2048x1024');
|
||||
});
|
||||
|
||||
it('rejects explicit pixel dimensions outside Ark limits', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
ratio: { w: 6000, h: 6000 },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits size for the i2i-only seededit model', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seededit-3-0-i2i',
|
||||
prompt: 'add a hat',
|
||||
input_image: 'https://example.com/cat.png',
|
||||
}),
|
||||
);
|
||||
const sent = generateMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('seededit-3-0-i2i-250628');
|
||||
expect(sent.size).toBeUndefined();
|
||||
expect(sent.image).toBe('https://example.com/cat.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Input images ────────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate input images', () => {
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
it('sends a single input image as a string and multiple as an array', async () => {
|
||||
generateMock.mockResolvedValue(sampleResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi', input_images: [PNG] }),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].image).toBe(PNG);
|
||||
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
input_images: [PNG, PNG],
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[1]![0].image).toEqual([PNG, PNG]);
|
||||
});
|
||||
|
||||
it('wraps raw base64 into a data URI using the mime hint', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
input_image: 'AAAA',
|
||||
input_image_mime_type: 'image/webp',
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].image).toBe(
|
||||
'data:image/webp;base64,AAAA',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects more input images than the model accepts', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seededit-3-0-i2i',
|
||||
prompt: 'hi',
|
||||
input_images: [PNG, PNG],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires an input image for seededit', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seededit-3-0-i2i',
|
||||
prompt: 'hi',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate metering', () => {
|
||||
it('meters flat per-image models at their catalog rate', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ model: 'seedream-4-0', prompt: 'hi' }),
|
||||
);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
usageType:
|
||||
'byteplus-image-generation:seedream-4-0-250828:per-image',
|
||||
usageAmount: 1,
|
||||
costOverride:
|
||||
findModel('seedream-4-0-250828').costs['per-image'] *
|
||||
1_000_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bills the pro model at the high tier for 2K output', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'dola-seedream-5-0-pro',
|
||||
prompt: 'hi',
|
||||
}),
|
||||
);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const pro = findModel('dola-seedream-5-0-pro-260628');
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
usageType:
|
||||
'byteplus-image-generation:dola-seedream-5-0-pro-260628:output:2k',
|
||||
usageAmount: 1,
|
||||
costOverride: pro.costs['output:2k'] * 1_000_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bills the pro model at the low tier for 1k/1.5k output', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'dola-seedream-5-0-pro',
|
||||
prompt: 'hi',
|
||||
quality: '1.5k',
|
||||
}),
|
||||
);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(
|
||||
(entries as Array<{ usageType: string }>)[0].usageType,
|
||||
).toBe(
|
||||
'byteplus-image-generation:dola-seedream-5-0-pro-260628:output:1.5k',
|
||||
);
|
||||
});
|
||||
|
||||
it('bills pro input images from the second one on (first is free)', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'dola-seedream-5-0-pro',
|
||||
prompt: 'hi',
|
||||
input_images: [PNG, PNG, PNG],
|
||||
}),
|
||||
);
|
||||
const pro = findModel('dola-seedream-5-0-pro-260628');
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const inputEntry = (
|
||||
entries as Array<{
|
||||
usageType: string;
|
||||
usageAmount: number;
|
||||
costOverride: number;
|
||||
}>
|
||||
).find((e) => e.usageType.endsWith(':input_image'))!;
|
||||
expect(inputEntry.usageAmount).toBe(2);
|
||||
expect(inputEntry.costOverride).toBe(
|
||||
2 * pro.costs.input_image * 1_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not bill input images on flat-rate models', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedream-4-5',
|
||||
prompt: 'hi',
|
||||
input_images: [PNG, PNG, PNG],
|
||||
}),
|
||||
);
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Response handling ───────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusImageProvider.generate response handling', () => {
|
||||
it('falls back to a data URI when the response carries b64_json', async () => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ b64_json: 'AAAA', output_format: 'png' }],
|
||||
});
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi' }),
|
||||
);
|
||||
expect(result).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
it('surfaces a per-image upstream error as 400 without metering', async () => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ error: { code: 'x', message: 'moderated' } }],
|
||||
});
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the response has no usable image data', async () => {
|
||||
generateMock.mockResolvedValueOnce({ data: [{}] });
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toThrow(/Failed to extract image URL/);
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IGenerateParams,
|
||||
IImageModel,
|
||||
IImageProvider,
|
||||
} from '../../types.js';
|
||||
import { isHttpUrl } from '../../inputImage.js';
|
||||
import {
|
||||
BYTEPLUS_IMAGE_GENERATION_MODELS,
|
||||
SEEDREAM_RESOLUTION_MAP,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_MODEL = 'seedream-5-0-lite-260128';
|
||||
|
||||
// Ark's explicit-pixel `size` bounds ("method 2"): total pixels within
|
||||
// [1280x720, 2048x2048x1.1025] and aspect ratio within [1/16, 16].
|
||||
const MIN_TOTAL_PIXELS = 921_600;
|
||||
const MAX_TOTAL_PIXELS = 4_624_220;
|
||||
// dola-seedream-5-0-pro's price break: ≤ 2.61MP bills the "1.5K or lower"
|
||||
// rate, above it the higher rate.
|
||||
const PRO_TIER_BREAK_PIXELS = 2_610_000;
|
||||
|
||||
// Per-request reference image caps, per the API reference.
|
||||
const MAX_INPUT_IMAGES_PRO = 10;
|
||||
const MAX_INPUT_IMAGES_SEEDREAM = 14;
|
||||
|
||||
type BytePlusImageConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
interface ArkImageResponse {
|
||||
data?: Array<{
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
output_format?: string;
|
||||
error?: { code?: string; message?: string };
|
||||
}>;
|
||||
usage?: { generated_images?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* BytePlus ModelArk image generation provider (Seedream / SeedEdit).
|
||||
*
|
||||
* Ark's `POST /images/generations` is OpenAI-compatible enough to reuse the
|
||||
* OpenAI SDK (same client/auth as BytePlusProvider in ai-chat); Ark-specific
|
||||
* params (`image`, `watermark`, tier-style `size`) pass through the SDK
|
||||
* untouched. https://docs.byteplus.com/en/docs/ModelArk/1541523
|
||||
*/
|
||||
export class BytePlusImageProvider implements IImageProvider {
|
||||
#client: OpenAI;
|
||||
#meteringService: MeteringService;
|
||||
|
||||
constructor(config: BytePlusImageConfig, meteringService: MeteringService) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error('BytePlus image generation requires an API key');
|
||||
}
|
||||
this.#meteringService = meteringService;
|
||||
this.#client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL:
|
||||
config.apiBaseUrl ??
|
||||
'https://ark.ap-southeast.bytepluses.com/api/v3',
|
||||
});
|
||||
}
|
||||
|
||||
models(): IImageModel[] {
|
||||
return BYTEPLUS_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const { prompt, test_mode, model, ratio, quality } = params;
|
||||
let { input_images } = params;
|
||||
const { input_image, input_image_mime_type } = params;
|
||||
|
||||
const selectedModel = this.#getModel(model);
|
||||
const isSeedEdit = selectedModel.id.startsWith('seededit-');
|
||||
const isPro = selectedModel.pricing_unit === 'per-tier';
|
||||
|
||||
if (test_mode) {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
||||
throw new HttpError(400, '`prompt` must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Backwards compat: fold singular `input_image` into `input_images`.
|
||||
if (input_image && (!input_images || input_images.length === 0)) {
|
||||
input_images = [input_image];
|
||||
}
|
||||
const maxInputImages = isSeedEdit
|
||||
? 1
|
||||
: isPro
|
||||
? MAX_INPUT_IMAGES_PRO
|
||||
: MAX_INPUT_IMAGES_SEEDREAM;
|
||||
if (input_images && input_images.length > maxInputImages) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${selectedModel.id} accepts at most ${maxInputImages} input image(s)`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (isSeedEdit && (!input_images || input_images.length === 0)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${selectedModel.id} is image-to-image only; pass an input image via input_image`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const inputImageCount = input_images?.length ?? 0;
|
||||
|
||||
const tier = this.#normalizeTier(quality);
|
||||
const size = isSeedEdit ? undefined : this.#resolveSize(tier, ratio);
|
||||
|
||||
// The pro model bills by output pixel count; everything else is a
|
||||
// flat per-image rate.
|
||||
let outputCostKey: string;
|
||||
if (isPro) {
|
||||
const pixels = this.#sizePixels(size!);
|
||||
outputCostKey =
|
||||
pixels !== undefined
|
||||
? pixels > PRO_TIER_BREAK_PIXELS
|
||||
? 'output:2k'
|
||||
: 'output:1k'
|
||||
: `output:${tier}`;
|
||||
} else {
|
||||
outputCostKey = 'per-image';
|
||||
}
|
||||
const outputCents = selectedModel.costs[outputCostKey];
|
||||
if (outputCents === undefined) {
|
||||
throw new Error(
|
||||
`Model ${selectedModel.id} missing '${outputCostKey}' cost`,
|
||||
);
|
||||
}
|
||||
// First input image is free on the pro model; the rest are billed.
|
||||
const inputImageCents = selectedModel.costs.input_image ?? 0;
|
||||
const billableInputs =
|
||||
inputImageCents > 0 ? Math.max(0, inputImageCount - 1) : 0;
|
||||
const estimatedCents = outputCents + billableInputs * inputImageCents;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const usageAllowed = await this.#meteringService.hasEnoughCredits(
|
||||
actor,
|
||||
estimatedCents * 1_000_000,
|
||||
);
|
||||
if (!usageAllowed) {
|
||||
throw new HttpError(
|
||||
402,
|
||||
'Insufficient credits for image generation',
|
||||
{ legacyCode: 'insufficient_funds' },
|
||||
);
|
||||
}
|
||||
|
||||
const image =
|
||||
inputImageCount > 0
|
||||
? input_images!.map((img) =>
|
||||
this.#toImageRef(img, input_image_mime_type),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const response = (await this.#client.images.generate({
|
||||
model: selectedModel.id,
|
||||
prompt,
|
||||
// Ark-specific params not in the OpenAI type; passed through.
|
||||
...(size ? { size } : {}),
|
||||
...(image ? { image: image.length === 1 ? image[0] : image } : {}),
|
||||
response_format: 'url',
|
||||
watermark: false,
|
||||
} as Parameters<OpenAI['images']['generate']>[0])) as ArkImageResponse;
|
||||
|
||||
const first = response.data?.[0];
|
||||
if (first?.error) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
first.error.message ?? 'Image generation failed',
|
||||
{
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { provider: 'byteplus' },
|
||||
},
|
||||
);
|
||||
}
|
||||
const url =
|
||||
first?.url ||
|
||||
(first?.b64_json
|
||||
? `data:image/${first.output_format ?? 'jpeg'};base64,${first.b64_json}`
|
||||
: undefined);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Failed to extract image URL from BytePlus response',
|
||||
);
|
||||
}
|
||||
|
||||
const usageEntries = [
|
||||
{
|
||||
usageType: `byteplus-image-generation:${selectedModel.id}:${outputCostKey}`,
|
||||
usageAmount: 1,
|
||||
costOverride: outputCents * 1_000_000,
|
||||
},
|
||||
];
|
||||
if (billableInputs > 0) {
|
||||
usageEntries.push({
|
||||
usageType: `byteplus-image-generation:${selectedModel.id}:input_image`,
|
||||
usageAmount: billableInputs,
|
||||
costOverride: billableInputs * inputImageCents * 1_000_000,
|
||||
});
|
||||
}
|
||||
this.#meteringService.batchIncrementUsages(actor, usageEntries);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Ark accepts a public URL or a `data:image/...;base64,` URI; wrap raw
|
||||
// base64 payloads so they're valid.
|
||||
#toImageRef(img: string, mimeHint?: string): string {
|
||||
return isHttpUrl(img) || img.startsWith('data:')
|
||||
? img
|
||||
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
|
||||
}
|
||||
|
||||
#normalizeTier(quality?: string): '1k' | '1.5k' | '2k' {
|
||||
const q = (quality ?? '').toLowerCase();
|
||||
// Ark's default size is 2K when unspecified.
|
||||
return q === '1k' || q === '1.5k' || q === '2k' ? q : '2k';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `size` request param:
|
||||
*
|
||||
* - `ratio` holding real pixel dimensions → explicit `WxH` (method 2)
|
||||
* - `ratio` holding an aspect ratio with a known tier mapping → the
|
||||
* documented `WxH` for (aspect, tier)
|
||||
* - Otherwise → the tier keyword (`1K`/`1.5K`/`2K`, method 1)
|
||||
*/
|
||||
#resolveSize(
|
||||
tier: '1k' | '1.5k' | '2k',
|
||||
ratio?: { w: number; h: number },
|
||||
): string {
|
||||
if (ratio?.w && ratio?.h) {
|
||||
const pixels = ratio.w * ratio.h;
|
||||
if (pixels >= MIN_TOTAL_PIXELS) {
|
||||
const aspect = ratio.w / ratio.h;
|
||||
if (
|
||||
pixels > MAX_TOTAL_PIXELS ||
|
||||
aspect < 1 / 16 ||
|
||||
aspect > 16
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Requested size ${ratio.w}x${ratio.h} is outside BytePlus limits ` +
|
||||
`(total pixels ≤ ${MAX_TOTAL_PIXELS}, aspect ratio within [1/16, 16])`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return `${ratio.w}x${ratio.h}`;
|
||||
}
|
||||
const mapped =
|
||||
SEEDREAM_RESOLUTION_MAP[`${ratio.w}:${ratio.h}`]?.[tier];
|
||||
if (mapped) return `${mapped.w}x${mapped.h}`;
|
||||
}
|
||||
return { '1k': '1K', '1.5k': '1.5K', '2k': '2K' }[tier];
|
||||
}
|
||||
|
||||
/** Pixel count of an explicit `WxH` size; undefined for tier keywords. */
|
||||
#sizePixels(size: string): number | undefined {
|
||||
const m = /^(\d+)x(\d+)$/.exec(size);
|
||||
if (!m) return undefined;
|
||||
return Number(m[1]) * Number(m[2]);
|
||||
}
|
||||
|
||||
#getModel(model?: string) {
|
||||
const models = this.models();
|
||||
const wanted = (model ?? '').trim().toLowerCase();
|
||||
const found = models.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.some((a) => a.toLowerCase() === wanted),
|
||||
);
|
||||
return found || models.find((m) => m.id === DEFAULT_MODEL)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
// Ark's `size` "method 1" tiers and the pixel dimensions each (tier, aspect
|
||||
// ratio) pair resolves to, from the image generation API reference:
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1541523
|
||||
// The table is identical for dola-seedream-5-0-pro, seedream-5-0-lite,
|
||||
// seedream-4-5 and seedream-4-0.
|
||||
export const SEEDREAM_RESOLUTION_MAP: Record<
|
||||
string,
|
||||
Record<string, { w: number; h: number }>
|
||||
> = {
|
||||
'1:1': {
|
||||
'1k': { w: 1024, h: 1024 },
|
||||
'1.5k': { w: 1536, h: 1536 },
|
||||
'2k': { w: 2048, h: 2048 },
|
||||
},
|
||||
'4:3': {
|
||||
'1k': { w: 1152, h: 864 },
|
||||
'1.5k': { w: 1792, h: 1344 },
|
||||
'2k': { w: 2368, h: 1776 },
|
||||
},
|
||||
'3:4': {
|
||||
'1k': { w: 864, h: 1152 },
|
||||
'1.5k': { w: 1344, h: 1792 },
|
||||
'2k': { w: 1776, h: 2368 },
|
||||
},
|
||||
'16:9': {
|
||||
'1k': { w: 1424, h: 800 },
|
||||
'1.5k': { w: 2048, h: 1152 },
|
||||
'2k': { w: 2816, h: 1584 },
|
||||
},
|
||||
'9:16': {
|
||||
'1k': { w: 800, h: 1424 },
|
||||
'1.5k': { w: 1152, h: 2048 },
|
||||
'2k': { w: 1584, h: 2816 },
|
||||
},
|
||||
'3:2': {
|
||||
'1k': { w: 1248, h: 832 },
|
||||
'1.5k': { w: 1872, h: 1248 },
|
||||
'2k': { w: 2496, h: 1664 },
|
||||
},
|
||||
'2:3': {
|
||||
'1k': { w: 832, h: 1248 },
|
||||
'1.5k': { w: 1248, h: 1872 },
|
||||
'2k': { w: 1664, h: 2496 },
|
||||
},
|
||||
'21:9': {
|
||||
'1k': { w: 1568, h: 672 },
|
||||
'1.5k': { w: 2352, h: 1008 },
|
||||
'2k': { w: 3136, h: 1344 },
|
||||
},
|
||||
};
|
||||
|
||||
const SEEDREAM_QUALITY_LEVELS = ['1k', '1.5k', '2k'];
|
||||
|
||||
// Costs are in usd-cents per image, hardcoded from
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing) and
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1330310 (catalog).
|
||||
//
|
||||
// dola-seedream-5-0-pro bills by output pixel count — ≤ 2.61MP ("1.5K or
|
||||
// lower") vs above — plus a per-input-image rate from the second reference
|
||||
// image onward (the first is free). Every other model is a flat per-image
|
||||
// rate with free image input.
|
||||
export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
{
|
||||
puterId: 'byteplus:byteplus/dola-seedream-5-0-pro-260628',
|
||||
id: 'dola-seedream-5-0-pro-260628',
|
||||
aliases: [
|
||||
'byteplus/dola-seedream-5-0-pro-260628',
|
||||
'dola-seedream-5-0-pro',
|
||||
'byteplus/dola-seedream-5-0-pro',
|
||||
'seedream-5-0-pro',
|
||||
],
|
||||
name: 'Dola Seedream 5.0 Pro',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-tier',
|
||||
index_cost_key: 'output:1k',
|
||||
costs: {
|
||||
'output:1k': 4.5, // $0.045 per image ≤ 2.61MP
|
||||
'output:1.5k': 4.5, // same price as 1K, better quality
|
||||
'output:2k': 9, // $0.09 per image > 2.61MP
|
||||
input_image: 0.3, // $0.003 per input image from the 2nd on
|
||||
},
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
// The catalog lists `seedream-5-0-260128` as the same service
|
||||
// ("also supports: seedream-5-0-lite-260128"); billing is published
|
||||
// under the -lite id, so that's the primary id here.
|
||||
puterId: 'byteplus:byteplus/seedream-5-0-lite-260128',
|
||||
id: 'seedream-5-0-lite-260128',
|
||||
aliases: [
|
||||
'byteplus/seedream-5-0-lite-260128',
|
||||
'seedream-5-0-lite',
|
||||
'byteplus/seedream-5-0-lite',
|
||||
'seedream-5-0-260128',
|
||||
'seedream-5-0',
|
||||
],
|
||||
name: 'Seedream 5.0 Lite',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 3.5 },
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
puterId: 'byteplus:byteplus/seedream-4-5-251128',
|
||||
id: 'seedream-4-5-251128',
|
||||
aliases: [
|
||||
'byteplus/seedream-4-5-251128',
|
||||
'seedream-4-5',
|
||||
'byteplus/seedream-4-5',
|
||||
],
|
||||
name: 'Seedream 4.5',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 4 },
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
puterId: 'byteplus:byteplus/seedream-4-0-250828',
|
||||
id: 'seedream-4-0-250828',
|
||||
aliases: [
|
||||
'byteplus/seedream-4-0-250828',
|
||||
'seedream-4-0',
|
||||
'byteplus/seedream-4-0',
|
||||
],
|
||||
name: 'Seedream 4.0',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 3 },
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
// Image-to-image only; output keeps the source image's size, so the
|
||||
// Seedream size/tier machinery doesn't apply.
|
||||
puterId: 'byteplus:byteplus/seededit-3-0-i2i-250628',
|
||||
id: 'seededit-3-0-i2i-250628',
|
||||
aliases: [
|
||||
'byteplus/seededit-3-0-i2i-250628',
|
||||
'seededit-3-0-i2i',
|
||||
'byteplus/seededit-3-0-i2i',
|
||||
'seededit-3-0',
|
||||
],
|
||||
name: 'SeedEdit 3.0 (image-to-image)',
|
||||
costs_currency: 'usd-cents',
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 3 },
|
||||
},
|
||||
];
|
||||
@@ -25,6 +25,7 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { BytePlusVideoProvider } from './providers/byteplus/BytePlusVideoProvider.js';
|
||||
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
|
||||
import { OpenAIVideoProvider } from './providers/openai/OpenAIVideoProvider.js';
|
||||
import { TogetherVideoProvider } from './providers/together/TogetherVideoProvider.js';
|
||||
@@ -56,6 +57,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
'openai-video-generation',
|
||||
'together-video-generation',
|
||||
'gemini-video-generation',
|
||||
'byteplus-video-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -290,6 +292,26 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
this.#providers['gemini-video-generation'] =
|
||||
new GeminiVideoProvider({ apiKey: geminiKey }, m);
|
||||
}
|
||||
|
||||
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
|
||||
// selects the ModelArk region, same as the chat provider.
|
||||
const byteplusCfg = (providers['byteplus-video-generation'] ??
|
||||
providers['byteplus']) as Record<string, unknown> | undefined;
|
||||
const byteplusKey = readKey(
|
||||
providers['byteplus-video-generation'],
|
||||
providers['byteplus'],
|
||||
);
|
||||
if (byteplusKey) {
|
||||
this.#providers['byteplus-video-generation'] =
|
||||
new BytePlusVideoProvider(
|
||||
{
|
||||
apiKey: byteplusKey,
|
||||
apiBaseUrl: byteplusCfg?.apiBaseUrl as
|
||||
string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Model map -----------------------------------------------------------
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the BytePlus ModelArk video provider.
|
||||
*
|
||||
* Generates a 2s 480p clip on seedance-1-0-pro-fast (~$0.02). Video tasks
|
||||
* poll for completion, so the timeout is generous. Skipped when
|
||||
* `PUTER_TEST_AI_BYTEPLUS_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { BytePlusVideoProvider } from './BytePlusVideoProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_BYTEPLUS_API_KEY';
|
||||
// Task-based generation routinely takes a couple of minutes.
|
||||
const VIDEO_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'BytePlusVideoProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates a video URL from seedance-1-0-pro-fast',
|
||||
{ timeout: VIDEO_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new BytePlusVideoProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'seedance-1-0-pro-fast',
|
||||
prompt: 'a red ball rolls to the right',
|
||||
resolution: '480p',
|
||||
seconds: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(typeof url).toBe('string');
|
||||
expect((url as string).startsWith('https://')).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for BytePlusVideoProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs BytePlusVideoProvider directly against the
|
||||
* live wired `MeteringService`. Ark's task-based video API has no
|
||||
* SDK — the provider hits it via global `fetch`, which we stub;
|
||||
* that's the real network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { BYTEPLUS_VIDEO_GENERATION_MODELS } from './models.js';
|
||||
import { BytePlusVideoProvider } from './BytePlusVideoProvider.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = (
|
||||
config: { apiKey?: string; apiBaseUrl?: string } = {},
|
||||
) =>
|
||||
new BytePlusVideoProvider(
|
||||
{
|
||||
apiKey: config.apiKey ?? 'test-key',
|
||||
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
server.services.metering,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
remainingUsageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'getRemainingUsage',
|
||||
);
|
||||
// Plenty of credit unless a test says otherwise.
|
||||
remainingUsageSpy.mockResolvedValue(Number.MAX_SAFE_INTEGER);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const succeededTask = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'cgt-test-1',
|
||||
status: 'succeeded',
|
||||
content: { video_url: 'https://ark.example/video.mp4' },
|
||||
usage: { completion_tokens: 108_000, total_tokens: 108_000 },
|
||||
resolution: '720p',
|
||||
duration: 5,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Queue up the POST-create response followed by GET-poll responses. */
|
||||
const mockTaskFlow = (...pollBodies: unknown[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: 'cgt-test-1' }));
|
||||
for (const body of pollBodies) {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(body));
|
||||
}
|
||||
};
|
||||
|
||||
const sentBody = (callIndex = 0): Record<string, unknown> =>
|
||||
JSON.parse(
|
||||
(fetchSpy.mock.calls[callIndex]![1] as RequestInit).body as string,
|
||||
);
|
||||
|
||||
const findModel = (id: string) =>
|
||||
BYTEPLUS_VIDEO_GENERATION_MODELS.find((m) => m.id === id)!;
|
||||
|
||||
// ── Construction / catalog ──────────────────────────────────────────
|
||||
|
||||
describe('BytePlusVideoProvider construction and catalog', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() =>
|
||||
new BytePlusVideoProvider(
|
||||
{ apiKey: '' },
|
||||
server.services.metering,
|
||||
),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('does not call out at construction (lazy fetch)', () => {
|
||||
makeProvider();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to seedance 2.0 mini', () => {
|
||||
expect(makeProvider().getDefaultModel()).toBe(
|
||||
'dreamina-seedance-2-0-mini-260615',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the static catalog', async () => {
|
||||
expect(await makeProvider().models()).toBe(
|
||||
BYTEPLUS_VIDEO_GENERATION_MODELS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusVideoProvider.generate gates', () => {
|
||||
it('returns the canned sample URL in test_mode without network calls', async () => {
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'x', test_mode: true }),
|
||||
);
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 on a missing or blank prompt', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: undefined as unknown as string,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 402 BEFORE creating a task when even the shortest clip is unaffordable', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(0);
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusVideoProvider.generate request shape', () => {
|
||||
it('POSTs a create-task request with text content and defaults', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a kitten yawns' }),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://ark.example/video.mp4');
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe(
|
||||
'https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks',
|
||||
);
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
expect(
|
||||
(init as RequestInit & { headers: Record<string, string> })
|
||||
.headers.Authorization,
|
||||
).toBe('Bearer test-key');
|
||||
|
||||
const body = sentBody();
|
||||
expect(body.model).toBe('dreamina-seedance-2-0-mini-260615');
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'a kitten yawns' },
|
||||
]);
|
||||
expect(body.resolution).toBe('720p');
|
||||
expect(body.duration).toBe(5);
|
||||
expect(body.watermark).toBe(false);
|
||||
expect(body.generate_audio).toBe(true);
|
||||
|
||||
// Poll goes to GET tasks/{id}.
|
||||
const [pollUrl, pollInit] = fetchSpy.mock.calls[1]!;
|
||||
expect(String(pollUrl)).toBe(
|
||||
'https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks/cgt-test-1',
|
||||
);
|
||||
expect((pollInit as RequestInit).method).toBe('GET');
|
||||
});
|
||||
|
||||
it('resolves aliases, uppercases 4k, and clamps duration to the model range', async () => {
|
||||
mockTaskFlow(succeededTask({ resolution: '4k' }));
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-2-0',
|
||||
prompt: 'hi',
|
||||
resolution: '4k',
|
||||
seconds: 99,
|
||||
}),
|
||||
);
|
||||
const body = sentBody();
|
||||
expect(body.model).toBe('dreamina-seedance-2-0-260128');
|
||||
expect(body.resolution).toBe('4K');
|
||||
expect(body.duration).toBe(15);
|
||||
});
|
||||
|
||||
it('omits generate_audio for models without audio and passes seed for 1.x', async () => {
|
||||
mockTaskFlow(succeededTask({ resolution: '1080p' }));
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-1-0-pro',
|
||||
prompt: 'hi',
|
||||
seed: 11,
|
||||
}),
|
||||
);
|
||||
const body = sentBody();
|
||||
expect(body.generate_audio).toBeUndefined();
|
||||
expect(body.seed).toBe(11);
|
||||
expect(body.resolution).toBe('1080p');
|
||||
});
|
||||
|
||||
it('derives a supported ratio from width/height and omits unsupported ones', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
}),
|
||||
);
|
||||
expect(sentBody().ratio).toBe('16:9');
|
||||
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi', width: 999, height: 100 }),
|
||||
);
|
||||
expect(sentBody(2).ratio).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps input_reference/last_frame to first/last frame roles', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
input_reference: 'https://example.com/first.png',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
}),
|
||||
);
|
||||
expect(sentBody().content).toEqual([
|
||||
{ type: 'text', text: 'hi' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/first.png' },
|
||||
role: 'first_frame',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/last.png' },
|
||||
role: 'last_frame',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps reference_images for the 2.0 series and rejects them elsewhere', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-2-0',
|
||||
prompt: 'hi',
|
||||
reference_images: ['https://example.com/ref.png'],
|
||||
}),
|
||||
);
|
||||
expect(sentBody().content).toContainEqual({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/ref.png' },
|
||||
role: 'reference_image',
|
||||
});
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-1-0-pro',
|
||||
prompt: 'hi',
|
||||
reference_images: ['https://example.com/ref.png'],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects last_frame without a first frame', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Polling / outcomes ──────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusVideoProvider.generate polling and outcomes', () => {
|
||||
it('keeps polling while the task is queued/running', async () => {
|
||||
mockTaskFlow(
|
||||
{ id: 'cgt-test-1', status: 'queued' },
|
||||
{ id: 'cgt-test-1', status: 'running' },
|
||||
succeededTask(),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi' }),
|
||||
);
|
||||
expect(result).toBe('https://ark.example/video.mp4');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4); // 1 create + 3 polls
|
||||
});
|
||||
|
||||
it('throws 400 upstream_failed when the task fails, without metering', async () => {
|
||||
mockTaskFlow({
|
||||
id: 'cgt-test-1',
|
||||
status: 'failed',
|
||||
error: { code: 'moderation', message: 'blocked' },
|
||||
});
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 400, message: 'blocked' });
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps upstream 5xx on create to a 502', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ error: { message: 'boom' } }, 500),
|
||||
);
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 502 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
|
||||
describe('BytePlusVideoProvider.generate metering', () => {
|
||||
it('bills the tokens the task reports at the resolution rate', async () => {
|
||||
mockTaskFlow(succeededTask({ resolution: '1080p' }));
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-2-0',
|
||||
prompt: 'hi',
|
||||
resolution: '1080p',
|
||||
}),
|
||||
);
|
||||
const model = findModel('dreamina-seedance-2-0-260128');
|
||||
const rate = model.costs!['video_tokens:1080p'];
|
||||
expect(incrementUsageSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'byteplus-video-generation:dreamina-seedance-2-0-260128:video_tokens:1080p',
|
||||
108_000,
|
||||
108_000 * rate * 1_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('bills seedance 1.5 pro at the silent rate when generate_audio is false', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedance-1-5-pro',
|
||||
prompt: 'hi',
|
||||
generate_audio: false,
|
||||
}),
|
||||
);
|
||||
expect(sentBody().generate_audio).toBe(false);
|
||||
const model = findModel('seedance-1-5-pro-251215');
|
||||
const rate = model.costs!['video_tokens:silent'];
|
||||
expect(incrementUsageSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'byteplus-video-generation:seedance-1-5-pro-251215:video_tokens:silent',
|
||||
108_000,
|
||||
108_000 * rate * 1_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps the clip to what remaining credit buys', async () => {
|
||||
// seedance 2.0 mini @720p: 1280×720×24/1024 = 21600 tokens/s at
|
||||
// 0.00035¢/token → 7.56¢/s. Grant ~15¢ ≈ 2s... below the 4s
|
||||
// minimum → 402. Grant ~40¢ → 5s requested, affordable.
|
||||
const perSecondMicroCents = 21_600 * 0.00035 * 1_000_000;
|
||||
remainingUsageSpy.mockResolvedValue(4.5 * perSecondMicroCents);
|
||||
mockTaskFlow(succeededTask());
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi', seconds: 10 }),
|
||||
);
|
||||
expect(sentBody().duration).toBe(4);
|
||||
|
||||
remainingUsageSpy.mockResolvedValue(2 * perSecondMicroCents);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi', seconds: 10 }),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
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 {
|
||||
BYTEPLUS_VIDEO_GENERATION_MODELS,
|
||||
BYTEPLUS_VIDEO_SPECS,
|
||||
type BytePlusVideoSpec,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
|
||||
const DEFAULT_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3';
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_MODEL = 'dreamina-seedance-2-0-mini-260615';
|
||||
// Seedance 2.0 multimodal reference accepts up to 9 reference images.
|
||||
const MAX_REFERENCE_IMAGES = 9;
|
||||
|
||||
const ARK_RATIOS = ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
|
||||
|
||||
type BytePlusVideoConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
/** Test hook — polling cadence for task status checks. */
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
interface ArkVideoTask {
|
||||
id: string;
|
||||
status:
|
||||
'queued' | 'running' | 'cancelled' | 'succeeded' | 'failed' | 'expired';
|
||||
content?: { video_url?: string };
|
||||
usage?: { completion_tokens?: number; total_tokens?: number };
|
||||
resolution?: string;
|
||||
duration?: number;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BytePlus ModelArk video generation provider (Seedance).
|
||||
*
|
||||
* Ark's video API is task-based rather than OpenAI-shaped: POST
|
||||
* `/contents/generations/tasks` returns a task id, which is then polled via GET
|
||||
* until it leaves queued/running. Billing is per video token (≈ duration ×
|
||||
* width × height × fps / 1024) with the authoritative count in the final task's
|
||||
* `usage.completion_tokens`.
|
||||
* https://docs.byteplus.com/en/docs/ModelArk/1520757
|
||||
*/
|
||||
export class BytePlusVideoProvider extends VideoProvider {
|
||||
#apiKey: string;
|
||||
#baseUrl: string;
|
||||
#pollIntervalMs: number;
|
||||
#meteringService: MeteringService;
|
||||
|
||||
constructor(config: BytePlusVideoConfig, meteringService: MeteringService) {
|
||||
super();
|
||||
if (!config.apiKey) {
|
||||
throw new Error('BytePlus video generation requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#baseUrl = (config.apiBaseUrl ?? DEFAULT_BASE_URL).replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
this.#pollIntervalMs =
|
||||
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async models(): Promise<IVideoModel[]> {
|
||||
return BYTEPLUS_VIDEO_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateVideoParams): Promise<unknown> {
|
||||
const {
|
||||
prompt,
|
||||
model: requestedModel,
|
||||
seconds,
|
||||
duration,
|
||||
size,
|
||||
resolution,
|
||||
width,
|
||||
height,
|
||||
seed,
|
||||
generate_audio: generateAudio,
|
||||
input_reference: inputReference,
|
||||
last_frame: lastFrame,
|
||||
reference_images: referenceImages,
|
||||
test_mode: testMode,
|
||||
} = params ?? {};
|
||||
|
||||
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||
throw new HttpError(400, 'prompt must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const model = this.#getModel(requestedModel);
|
||||
const spec = BYTEPLUS_VIDEO_SPECS[model.id];
|
||||
|
||||
if (testMode) {
|
||||
return DEFAULT_TEST_VIDEO_URL;
|
||||
}
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
const resolutionKey = this.#normalizeResolution(
|
||||
size ?? resolution,
|
||||
model,
|
||||
);
|
||||
// Audio is priced in for 1.5 Pro, so always send an explicit value to
|
||||
// keep billing deterministic; Ark's own default is true.
|
||||
const audioOn = spec.supportsAudio && generateAudio !== false;
|
||||
const costKey = this.#costKey(model, resolutionKey, audioOn);
|
||||
const centsPerToken = model.costs?.[costKey];
|
||||
if (!centsPerToken) {
|
||||
throw new Error(
|
||||
`No pricing configured for video model ${model.id} at ${resolutionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dims = spec.dims[resolutionKey];
|
||||
const tokensPerSecond = (dims.w * dims.h * 24) / 1024;
|
||||
const perSecondMicroCents = tokensPerSecond * centsPerToken * 1_000_000;
|
||||
|
||||
const requestedSeconds = Math.min(
|
||||
this.#coerceSeconds(seconds ?? duration) ?? spec.duration.default,
|
||||
spec.duration.max,
|
||||
);
|
||||
// Ark durations are a contiguous integer range, so no ladder —
|
||||
// clamp to whatever whole seconds the actor can still afford.
|
||||
const cappedSeconds = await capSecondsToRemainingCredits({
|
||||
metering: this.#meteringService,
|
||||
actor,
|
||||
perSecondMicroCents,
|
||||
requestedSeconds,
|
||||
allowedSeconds: null,
|
||||
minSeconds: spec.duration.min,
|
||||
modelId: model.id,
|
||||
});
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
content: this.#buildContent(prompt, spec, model.id, {
|
||||
inputReference,
|
||||
lastFrame,
|
||||
referenceImages,
|
||||
}),
|
||||
resolution: resolutionKey === '4k' ? '4K' : resolutionKey,
|
||||
duration: cappedSeconds,
|
||||
watermark: false,
|
||||
};
|
||||
if (spec.supportsAudio) {
|
||||
body.generate_audio = audioOn;
|
||||
}
|
||||
const ratio = this.#deriveRatio(width, height);
|
||||
if (ratio) {
|
||||
body.ratio = ratio;
|
||||
}
|
||||
if (
|
||||
spec.supportsSeed &&
|
||||
typeof seed === 'number' &&
|
||||
Number.isFinite(seed)
|
||||
) {
|
||||
body.seed = Math.round(seed);
|
||||
}
|
||||
|
||||
const task = await this.#createTask(body);
|
||||
const finalTask = await this.#pollUntilComplete(task.id);
|
||||
|
||||
if (finalTask.status !== 'succeeded') {
|
||||
const errorMessage =
|
||||
finalTask.error?.message ??
|
||||
`Video generation ${finalTask.status}`;
|
||||
// Ark's `failed` covers both user-input issues (content
|
||||
// moderation) and upstream outages — same ambiguity as the
|
||||
// Together provider, so expose it the same way: a 400 with
|
||||
// `upstream_failed` that the alarm gate skips.
|
||||
throw new HttpError(400, errorMessage, {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { provider: 'byteplus' },
|
||||
});
|
||||
}
|
||||
|
||||
const videoUrl = finalTask.content?.video_url;
|
||||
if (typeof videoUrl !== 'string' || !videoUrl.trim()) {
|
||||
throw new Error('BytePlus response did not include a video URL');
|
||||
}
|
||||
|
||||
// Bill the tokens the task actually reports; fall back to the
|
||||
// pre-flight estimate if usage is missing.
|
||||
const finalResolutionKey = this.#normalizeResolution(
|
||||
finalTask.resolution,
|
||||
model,
|
||||
resolutionKey,
|
||||
);
|
||||
const finalCostKey = this.#costKey(model, finalResolutionKey, audioOn);
|
||||
const finalCentsPerToken = model.costs?.[finalCostKey] ?? centsPerToken;
|
||||
const tokens =
|
||||
finalTask.usage?.completion_tokens ??
|
||||
Math.round(tokensPerSecond * cappedSeconds);
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
`byteplus-video-generation:${model.id}:${finalCostKey}`,
|
||||
tokens,
|
||||
tokens * finalCentsPerToken * 1_000_000,
|
||||
);
|
||||
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
#buildContent(
|
||||
prompt: string,
|
||||
spec: BytePlusVideoSpec,
|
||||
modelId: string,
|
||||
images: {
|
||||
inputReference?: unknown;
|
||||
lastFrame?: string;
|
||||
referenceImages?: string[];
|
||||
},
|
||||
): Array<Record<string, unknown>> {
|
||||
const { inputReference, lastFrame, referenceImages } = images;
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: 'text', text: prompt },
|
||||
];
|
||||
|
||||
const firstFrame =
|
||||
typeof inputReference === 'string' && inputReference.trim()
|
||||
? inputReference
|
||||
: undefined;
|
||||
const hasReferenceImages =
|
||||
Array.isArray(referenceImages) && referenceImages.length > 0;
|
||||
|
||||
// Ark treats first/last-frame and reference-image generation as
|
||||
// mutually exclusive scenarios.
|
||||
if (hasReferenceImages && (firstFrame || lastFrame)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'reference_images cannot be combined with input_reference/last_frame',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
if (hasReferenceImages) {
|
||||
if (!spec.supportsReferenceImages) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${modelId} does not support reference_images`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
for (const img of referenceImages!.slice(0, MAX_REFERENCE_IMAGES)) {
|
||||
if (typeof img !== 'string' || !img.trim()) continue;
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img },
|
||||
role: 'reference_image',
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
if (lastFrame && !firstFrame) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'last_frame requires a first-frame image via input_reference',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (firstFrame) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: firstFrame },
|
||||
...(lastFrame ? { role: 'first_frame' } : {}),
|
||||
});
|
||||
}
|
||||
if (lastFrame) {
|
||||
if (!spec.supportsLastFrame) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${modelId} does not support last_frame`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: lastFrame },
|
||||
role: 'last_frame',
|
||||
});
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async #createTask(body: Record<string, unknown>): Promise<ArkVideoTask> {
|
||||
return (await this.#request('POST', '/contents/generations/tasks', {
|
||||
body,
|
||||
})) as ArkVideoTask;
|
||||
}
|
||||
|
||||
async #pollUntilComplete(taskId: string): Promise<ArkVideoTask> {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
const task = (await this.#request(
|
||||
'GET',
|
||||
`/contents/generations/tasks/${taskId}`,
|
||||
)) as ArkVideoTask;
|
||||
if (task.status !== 'queued' && task.status !== 'running') {
|
||||
return task;
|
||||
}
|
||||
if (Date.now() - start > DEFAULT_TIMEOUT_MS) {
|
||||
throw new Error(
|
||||
'Timed out waiting for BytePlus video generation to complete',
|
||||
);
|
||||
}
|
||||
await this.#delay(this.#pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async #request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: { body?: Record<string, unknown> } = {},
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${this.#baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
((payload.error as Record<string, unknown>)
|
||||
?.message as string) ??
|
||||
`BytePlus video API error (status ${response.status})`;
|
||||
throw new HttpError(response.status >= 500 ? 502 : 400, message, {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields: { provider: 'byteplus' },
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async #delay(ms: number): Promise<void> {
|
||||
return await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
#getModel(requestedModel?: string): IVideoModel {
|
||||
const wanted = (requestedModel ?? '').trim().toLowerCase();
|
||||
const found = BYTEPLUS_VIDEO_GENERATION_MODELS.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.some((a) => a.toLowerCase() === wanted),
|
||||
);
|
||||
return (
|
||||
found ??
|
||||
BYTEPLUS_VIDEO_GENERATION_MODELS.find(
|
||||
(m) => m.id === DEFAULT_MODEL,
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
/** '480p' | '720p' | '1080p' | '4k', falling back to the model default. */
|
||||
#normalizeResolution(
|
||||
candidate: unknown,
|
||||
model: IVideoModel,
|
||||
fallback?: string,
|
||||
): string {
|
||||
const spec = BYTEPLUS_VIDEO_SPECS[model.id];
|
||||
if (typeof candidate === 'string') {
|
||||
const normalized = candidate.trim().toLowerCase();
|
||||
if (spec.dims[normalized]) return normalized;
|
||||
}
|
||||
return fallback ?? model.dimensions![0].toLowerCase();
|
||||
}
|
||||
|
||||
#costKey(
|
||||
model: IVideoModel,
|
||||
resolutionKey: string,
|
||||
audioOn: boolean,
|
||||
): string {
|
||||
const costs = model.costs ?? {};
|
||||
if (costs[`video_tokens:${resolutionKey}`] !== undefined) {
|
||||
return `video_tokens:${resolutionKey}`;
|
||||
}
|
||||
const audioKey = audioOn ? 'video_tokens:audio' : 'video_tokens:silent';
|
||||
if (costs[audioKey] !== undefined) {
|
||||
return audioKey;
|
||||
}
|
||||
return 'video_tokens';
|
||||
}
|
||||
|
||||
#coerceSeconds(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const rounded = Math.round(value);
|
||||
return rounded > 0 ? rounded : undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const numeric = Number.parseInt(value, 10);
|
||||
return Number.isFinite(numeric) && numeric > 0
|
||||
? numeric
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Snap width/height to one of Ark's supported aspect-ratio strings. */
|
||||
#deriveRatio(width?: number, height?: number): string | undefined {
|
||||
if (
|
||||
typeof width !== 'number' ||
|
||||
typeof height !== 'number' ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const gcd = (a: number, b: number): number =>
|
||||
b === 0 ? a : gcd(b, a % b);
|
||||
const d = gcd(Math.round(width), Math.round(height)) || 1;
|
||||
const candidate = `${Math.round(width) / d}:${Math.round(height) / d}`;
|
||||
// Unsupported ratios are omitted so Ark's `adaptive` default applies.
|
||||
return ARK_RATIOS.includes(candidate) ? candidate : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import type { IVideoModel } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Ark-specific model behavior the shared IVideoModel shape can't carry. Keyed
|
||||
* by model id.
|
||||
*/
|
||||
export interface BytePlusVideoSpec {
|
||||
/** Valid `duration` range (integer seconds) and Ark's default. */
|
||||
duration: { min: number; max: number; default: number };
|
||||
/**
|
||||
* 16:9 output dimensions per resolution key, from the create-task API's
|
||||
* ratio table. Used only for pre-flight cost estimates — actual billing
|
||||
* uses the `usage.completion_tokens` the task reports.
|
||||
*/
|
||||
dims: Record<string, { w: number; h: number }>;
|
||||
/** Supports `generate_audio` (Seedance 2.0 series + 1.5 Pro). */
|
||||
supportsAudio: boolean;
|
||||
/** Supports first+last frame image-to-video. */
|
||||
supportsLastFrame: boolean;
|
||||
/** Supports multimodal `reference_image` inputs (Seedance 2.0 series). */
|
||||
supportsReferenceImages: boolean;
|
||||
/** Supports the `seed` param (not the Seedance 2.0 series). */
|
||||
supportsSeed: boolean;
|
||||
}
|
||||
|
||||
// Duration ladder for the driver's normalization: element 0 is the fallback
|
||||
// default, the rest enumerate the model's contiguous valid range.
|
||||
const seconds = (def: number, min: number, max: number): number[] => [
|
||||
def,
|
||||
...Array.from({ length: max - min + 1 }, (_, i) => min + i).filter(
|
||||
(s) => s !== def,
|
||||
),
|
||||
];
|
||||
|
||||
const FPS = [24];
|
||||
|
||||
// USD per million tokens → usd-cents per token.
|
||||
const perMToken = (usd: number): number => (usd * 100) / 1_000_000;
|
||||
|
||||
// Hardcoded from https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing,
|
||||
// "online inference / input without video" rates — reference-video input is
|
||||
// not exposed through this driver) and the create-task API reference
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1520757 (capabilities).
|
||||
//
|
||||
// Video is billed per token: tokens ≈ duration × width × height × fps / 1024,
|
||||
// with the authoritative count returned as `usage.completion_tokens`.
|
||||
// `default-duration-per-video` is the estimated cents for a 5s clip at the
|
||||
// model's default resolution — it exists for cross-provider cost sorting and
|
||||
// display, not billing.
|
||||
//
|
||||
// dreamina-seedance-2-5-260628 is priced on the pricing page but the API
|
||||
// reference still lists its API access as "available soon", so it's
|
||||
// deliberately absent here.
|
||||
export const BYTEPLUS_VIDEO_GENERATION_MODELS: IVideoModel[] = [
|
||||
{
|
||||
id: 'dreamina-seedance-2-0-260128',
|
||||
puterId: 'byteplus:byteplus/dreamina-seedance-2-0-260128',
|
||||
aliases: [
|
||||
'dreamina-seedance-2-0',
|
||||
'byteplus/dreamina-seedance-2-0',
|
||||
'seedance-2-0',
|
||||
],
|
||||
name: 'Dreamina Seedance 2.0',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'video_tokens:480p': perMToken(7.0),
|
||||
'video_tokens:720p': perMToken(7.0),
|
||||
'video_tokens:1080p': perMToken(7.7),
|
||||
'video_tokens:4k': perMToken(4.0),
|
||||
'default-duration-per-video': 76,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
// '4K' repeats Ark's documented casing so the driver's strict
|
||||
// `includes` match accepts either spelling.
|
||||
dimensions: ['720p', '480p', '1080p', '4k', '4K'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:dreamina-seedance-2-0-260128:video_tokens:720p',
|
||||
},
|
||||
{
|
||||
id: 'dreamina-seedance-2-0-fast-260128',
|
||||
puterId: 'byteplus:byteplus/dreamina-seedance-2-0-fast-260128',
|
||||
aliases: [
|
||||
'dreamina-seedance-2-0-fast',
|
||||
'byteplus/dreamina-seedance-2-0-fast',
|
||||
'seedance-2-0-fast',
|
||||
],
|
||||
name: 'Dreamina Seedance 2.0 Fast',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
video_tokens: perMToken(5.6),
|
||||
'default-duration-per-video': 60,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['720p', '480p'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:dreamina-seedance-2-0-fast-260128:video_tokens',
|
||||
},
|
||||
{
|
||||
id: 'dreamina-seedance-2-0-mini-260615',
|
||||
puterId: 'byteplus:byteplus/dreamina-seedance-2-0-mini-260615',
|
||||
aliases: [
|
||||
'dreamina-seedance-2-0-mini',
|
||||
'byteplus/dreamina-seedance-2-0-mini',
|
||||
'seedance-2-0-mini',
|
||||
],
|
||||
name: 'Dreamina Seedance 2.0 Mini',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
video_tokens: perMToken(3.5),
|
||||
'default-duration-per-video': 38,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['720p', '480p'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:dreamina-seedance-2-0-mini-260615:video_tokens',
|
||||
},
|
||||
{
|
||||
id: 'seedance-1-5-pro-251215',
|
||||
puterId: 'byteplus:byteplus/seedance-1-5-pro-251215',
|
||||
aliases: ['seedance-1-5-pro', 'byteplus/seedance-1-5-pro'],
|
||||
name: 'Seedance 1.5 Pro',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'video_tokens:audio': perMToken(2.4),
|
||||
'video_tokens:silent': perMToken(1.2),
|
||||
'default-duration-per-video': 26,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 12),
|
||||
dimensions: ['720p', '480p', '1080p'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:seedance-1-5-pro-251215:video_tokens:audio',
|
||||
},
|
||||
{
|
||||
id: 'seedance-1-0-pro-250528',
|
||||
puterId: 'byteplus:byteplus/seedance-1-0-pro-250528',
|
||||
aliases: ['seedance-1-0-pro', 'byteplus/seedance-1-0-pro'],
|
||||
name: 'Seedance 1.0 Pro',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
video_tokens: perMToken(2.5),
|
||||
'default-duration-per-video': 61,
|
||||
},
|
||||
durationSeconds: seconds(5, 2, 12),
|
||||
dimensions: ['1080p', '480p', '720p'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:seedance-1-0-pro-250528:video_tokens',
|
||||
},
|
||||
{
|
||||
id: 'seedance-1-0-pro-fast-251015',
|
||||
puterId: 'byteplus:byteplus/seedance-1-0-pro-fast-251015',
|
||||
aliases: ['seedance-1-0-pro-fast', 'byteplus/seedance-1-0-pro-fast'],
|
||||
name: 'Seedance 1.0 Pro Fast',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
video_tokens: perMToken(1.0),
|
||||
'default-duration-per-video': 24,
|
||||
},
|
||||
durationSeconds: seconds(5, 2, 12),
|
||||
dimensions: ['1080p', '480p', '720p'],
|
||||
fps: FPS,
|
||||
defaultUsageKey:
|
||||
'byteplus-video-generation:seedance-1-0-pro-fast-251015:video_tokens',
|
||||
},
|
||||
];
|
||||
|
||||
const SEEDANCE_2_0_DIMS = {
|
||||
'480p': { w: 864, h: 496 },
|
||||
'720p': { w: 1280, h: 720 },
|
||||
'1080p': { w: 1920, h: 1080 },
|
||||
'4k': { w: 3840, h: 2160 },
|
||||
};
|
||||
|
||||
const SEEDANCE_1_0_DIMS = {
|
||||
'480p': { w: 864, h: 480 },
|
||||
'720p': { w: 1248, h: 704 },
|
||||
'1080p': { w: 1920, h: 1088 },
|
||||
};
|
||||
|
||||
export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
|
||||
'dreamina-seedance-2-0-260128': {
|
||||
duration: { min: 4, max: 15, default: 5 },
|
||||
dims: SEEDANCE_2_0_DIMS,
|
||||
supportsAudio: true,
|
||||
supportsLastFrame: true,
|
||||
supportsReferenceImages: true,
|
||||
supportsSeed: false,
|
||||
},
|
||||
'dreamina-seedance-2-0-fast-260128': {
|
||||
duration: { min: 4, max: 15, default: 5 },
|
||||
dims: SEEDANCE_2_0_DIMS,
|
||||
supportsAudio: true,
|
||||
supportsLastFrame: true,
|
||||
supportsReferenceImages: true,
|
||||
supportsSeed: false,
|
||||
},
|
||||
'dreamina-seedance-2-0-mini-260615': {
|
||||
duration: { min: 4, max: 15, default: 5 },
|
||||
dims: SEEDANCE_2_0_DIMS,
|
||||
supportsAudio: true,
|
||||
supportsLastFrame: true,
|
||||
supportsReferenceImages: true,
|
||||
supportsSeed: false,
|
||||
},
|
||||
'seedance-1-5-pro-251215': {
|
||||
duration: { min: 4, max: 12, default: 5 },
|
||||
dims: SEEDANCE_2_0_DIMS,
|
||||
supportsAudio: true,
|
||||
supportsLastFrame: true,
|
||||
supportsReferenceImages: false,
|
||||
supportsSeed: true,
|
||||
},
|
||||
'seedance-1-0-pro-250528': {
|
||||
duration: { min: 2, max: 12, default: 5 },
|
||||
dims: SEEDANCE_1_0_DIMS,
|
||||
supportsAudio: false,
|
||||
supportsLastFrame: true,
|
||||
supportsReferenceImages: false,
|
||||
supportsSeed: true,
|
||||
},
|
||||
'seedance-1-0-pro-fast-251015': {
|
||||
duration: { min: 2, max: 12, default: 5 },
|
||||
dims: SEEDANCE_1_0_DIMS,
|
||||
supportsAudio: false,
|
||||
supportsLastFrame: false,
|
||||
supportsReferenceImages: false,
|
||||
supportsSeed: true,
|
||||
},
|
||||
};
|
||||
@@ -60,6 +60,7 @@ export interface IGenerateVideoParams {
|
||||
output_format?: string;
|
||||
output_quality?: number;
|
||||
negative_prompt?: string;
|
||||
generate_audio?: boolean;
|
||||
reference_images?: string[];
|
||||
frame_images?: object[];
|
||||
last_frame?: string;
|
||||
|
||||
Reference in New Issue
Block a user