Merge remote-tracking branch 'origin/main' into juancastro/put-1412-file-sharing-revive-backend-api

This commit is contained in:
Daniel Salazar
2026-08-15 22:10:48 -07:00
36 changed files with 4094 additions and 150 deletions
+10
View File
@@ -341,6 +341,16 @@
"apiKey": "",
"apiBaseUrl": "https://llm.onerouter.pro/v1"
},
// 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": "",
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
},
"zai": { "apiKey": "" },
"alibaba": { "apiKey": "" },
"together-ai": { "apiKey": "" },
// Local Ollama. `enabled: false` skips the auto-probe at startup
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
+4 -2
View File
@@ -70,8 +70,10 @@ const scaleUsageByType = (
): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(usage)) {
if (key === 'total') {
out.total = toCredits(Number(value) || 0, multiplier);
// `allowanceUsed` is the month's allowance-charged spend — monetary,
// scalar, scaled like the total.
if (key === 'total' || key === 'allowanceUsed') {
out[key] = toCredits(Number(value) || 0, multiplier);
} else if (
value &&
typeof value === 'object' &&
+4 -5
View File
@@ -557,11 +557,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1150,8 +1150,7 @@ export class AuthController extends PuterController {
is_temp: user!.password === null && user!.email === null,
ip:
(req?.headers?.['x-forwarded-for'] as
| string
| undefined) ||
string | undefined) ||
(
req as unknown as {
connection?: { remoteAddress?: string };
@@ -299,8 +299,7 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
| PermissionService
| undefined;
PermissionService | undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -587,8 +587,7 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
)?.content,
),
index: 0,
+1 -2
View File
@@ -413,8 +413,7 @@ export const assertVerifiedAccount = (
*/
export const assertPhoneVerified = (
user:
| { phone?: unknown; requires_phone_verification?: unknown }
| undefined,
{ phone?: unknown; requires_phone_verification?: unknown } | undefined,
): void => {
if (user?.phone && !user?.requires_phone_verification) return;
throw new HttpError(403, 'Please verify your phone number to continue', {
+2 -9
View File
@@ -26,12 +26,7 @@ import type { Actor } from '../actor';
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
/**
* Every route method PuterRouter exposes. Mirrors the express router surface
@@ -441,9 +436,7 @@ export type AuthRequired<O extends RouteOptions> = O extends {
? true
: O extends {
requireSubscription:
| true
| readonly string[]
| string[];
true | readonly string[] | string[];
}
? true
: false;
@@ -73,6 +73,7 @@ const FULL_PROVIDER_CONFIG = {
'together-ai': { apiKey: 'k' },
openrouter: { apiKey: 'k', apiBaseUrl: 'https://openrouter.test' },
infron: { apiKey: 'k' },
byteplus: { apiKey: 'k' },
neuralwatt: { apiKey: 'k' },
// Suppress auto-discovery of a developer's local Ollama.
ollama: { enabled: false },
@@ -184,6 +185,7 @@ describe('ChatCompletionDriver provider registration', () => {
'together-ai',
'openrouter',
'infron',
'byteplus',
'neuralwatt',
'fake-chat',
]) {
@@ -35,6 +35,7 @@ import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
@@ -1251,6 +1252,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const byteplus = providers['byteplus'];
const byteplusKey = readKey(byteplus);
if (byteplusKey) {
this.#providers['byteplus'] = new BytePlusProvider(
{
apiKey: byteplusKey,
apiBaseUrl: byteplus?.apiBaseUrl as string | undefined,
},
metering,
);
}
const neuralwatt = providers['neuralwatt'];
const neuralwattKey = readKey(neuralwatt);
if (neuralwattKey) {
@@ -0,0 +1,63 @@
/*
* 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 provider.
*
* Uses `seed-1-6-flash-250715` with `thinking: disabled` passed through
* `custom`. Ark's seed models default to deep reasoning and route those
* tokens to a `reasoning_content` field, leaving `content` empty under
* tight budgets. Disabling thinking forces a plain text response so the
* usual `message.content` assertion works. 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 { BytePlusProvider } from './BytePlusProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_BYTEPLUS_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))('BytePlusProvider (integration)', () => {
it('returns a non-empty completion from seed-1-6-flash-250715', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new BytePlusProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const result = await withTestActor(() =>
provider.complete({
model: 'seed-1-6-flash-250715',
messages: [{ role: 'user', content: 'Say hi in one word.' }],
max_tokens: 16,
custom: { thinking: { type: 'disabled' } },
}),
);
const text = (result as { message?: { content?: string } }).message
?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
});
});
@@ -0,0 +1,815 @@
/*
* 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 BytePlusProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs BytePlusProvider directly against the live
* wired `MeteringService` so the recording side is exercised
* end-to-end. The OpenAI SDK is mocked at the module boundary
* ModelArk is OpenAI-compatible so the provider talks to it through
* the same client so the provider never reaches the network. The
* companion integration test (BytePlusProvider.integration.test.ts)
* exercises the real ModelArk endpoint.
*/
import { Writable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { BytePlusProvider } from './BytePlusProvider.js';
import { BYTEPLUS_MODELS } from './models.js';
// -- OpenAI SDK mock -------------------------------------------------
//
// `vi.hoisted` lets us share spies between the (hoisted) factory and
// the test body so each test can stub `chat.completions.create` with
// the response shape it cares about. ModelArk uses the OpenAI wire
// shape so the provider talks to it via the OpenAI SDK.
const { createMock, openAICtor } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
});
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.chat = { completions: { create: createMock } };
});
// Some providers (e.g. OllamaChatProvider) import the default export
// and access `.OpenAI` on it, so expose the same constructor under
// both shapes — the test server boots every provider, not just
// BytePlus.
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// -- Test harness ----------------------------------------------------
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = (
config: { apiKey?: string; apiBaseUrl?: string } = {},
) => {
const provider = new BytePlusProvider(
{
apiKey: config.apiKey ?? 'test-key',
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
},
server.services.metering,
);
return { provider };
};
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
async *[Symbol.asyncIterator]() {
for (const item of items) {
yield item;
}
},
});
const makeCapturingChatStream = () => {
const chunks: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString('utf8'));
cb();
},
});
const chatStream = new AIChatStream({ stream: sink });
return {
chatStream,
events: () =>
chunks
.join('')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
};
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
// Spy on the live MeteringService — we don't replace the impl
// (that would skip the recording side we want covered) but we
// capture the calls the provider makes so per-test assertions
// can verify metering shape.
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.restoreAllMocks();
});
// -- Construction ----------------------------------------------------
describe('BytePlusProvider construction', () => {
it('points the OpenAI SDK at the ModelArk base URL with the configured key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://ark.ap-southeast.bytepluses.com/api/v3',
});
});
it('honours a custom apiBaseUrl override (e.g. the eu-west region)', () => {
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',
});
});
});
// -- Model catalog ---------------------------------------------------
describe('BytePlusProvider model catalog', () => {
it('returns seed-2-0-lite-260428 as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('seed-2-0-lite-260428');
});
it('exposes the static BYTEPLUS_MODELS list verbatim from models()', () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(BYTEPLUS_MODELS);
});
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const names = provider.list();
for (const m of BYTEPLUS_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
expect(names).toContain(a);
}
}
// Sanity: undated series aliases resolve alongside canonical ids.
expect(names).toContain('seed-1-6');
expect(names).toContain('byteplus/seed-1-6');
expect(names).toContain('byteplus/seed-1-6-250915');
});
it('never claims the bare deepseek-v4 names owned by the DeepSeek provider', () => {
const { provider } = makeProvider();
const names = provider.list();
expect(names).not.toContain('deepseek-v4-pro');
expect(names).not.toContain('deepseek-v4-flash');
expect(names).toContain('byteplus/deepseek-v4-pro');
expect(names).toContain('byteplus/deepseek-v4-flash');
});
});
// -- Request shape ---------------------------------------------------
describe('BytePlusProvider.complete request shape', () => {
const baseCompletion = {
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('forwards model, messages, and bare-bones request without optional knobs', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('seed-1-6-250915');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
// Optional generation knobs should be absent unless supplied.
expect('max_tokens' in args).toBe(false);
expect('temperature' in args).toBe(false);
expect('top_p' in args).toBe(false);
expect('tools' in args).toBe(false);
expect('tool_choice' in args).toBe(false);
expect('thinking' in args).toBe(false);
});
it('forwards max_tokens, temperature, top_p, tools, and tool_choice when supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const tools = [
{
type: 'function',
function: {
name: 'lookup',
description: 'find a thing',
parameters: {
type: 'object',
properties: { q: { type: 'string' } },
required: ['q'],
},
},
},
];
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
temperature: 0.4,
top_p: 0.9,
tools,
tool_choice: 'auto',
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.max_tokens).toBe(256);
expect(args.temperature).toBe(0.4);
expect(args.top_p).toBe(0.9);
expect(args.tools).toBe(tools);
expect(args.tool_choice).toBe('auto');
});
it('forwards Ark-specific custom params (thinking, stop, response_format)', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
custom: {
thinking: { type: 'disabled' },
stop: ['\n\n'],
response_format: { type: 'json_object' },
},
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.thinking).toEqual({ type: 'disabled' });
expect(args.stop).toEqual(['\n\n']);
expect(args.response_format).toEqual({ type: 'json_object' });
});
it('strips Anthropic-style cache_control from messages before sending', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [
{
role: 'user',
content: 'hi',
cache_control: { type: 'ephemeral' },
} as unknown as { role: string; content: string },
],
}),
);
const [args] = createMock.mock.calls[0]!;
expect('cache_control' in args.messages[0]).toBe(false);
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
// Non-stream path.
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
);
const [nonStreamArgs] = createMock.mock.calls[0]!;
expect(nonStreamArgs.stream).toBe(false);
expect('stream_options' in nonStreamArgs).toBe(false);
// Stream path.
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
);
const [streamArgs] = createMock.mock.calls[1]!;
expect(streamArgs.stream).toBe(true);
expect(streamArgs.stream_options).toEqual({ include_usage: true });
});
});
// -- Model resolution ------------------------------------------------
describe('BytePlusProvider model resolution', () => {
const baseCompletion = {
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('resolves an exact canonical id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'glm-4-7-251222',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('glm-4-7-251222');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'byteplus:glm-4-7-251222',
expect.any(Object),
);
});
it('resolves an undated series alias to the newest snapshot (alias rewriting)', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'seed-1-6',
messages: [{ role: 'user', content: 'hi' }],
}),
);
// The wire model should be the canonical dated id, not the alias.
expect(createMock.mock.calls[0]![0].model).toBe('seed-1-6-250915');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'byteplus:seed-1-6-250915',
expect.any(Object),
);
});
it('falls back to the default model when given an unknown id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'totally-not-a-real-model',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe(
'seed-2-0-lite-260428',
);
});
});
// -- Non-stream completion + reasoning_content normalisation ---------
describe('BytePlusProvider.complete non-stream output', () => {
it('returns the first choice and runs the metered usage calculator', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 10 },
},
});
const result = await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(result).toMatchObject({
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
});
expect((result as { usage: unknown }).usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 10,
});
// Cost overrides scale per-token usage by the per-token cents from
// the model's costs table, so derive expectations from
// BYTEPLUS_MODELS directly to avoid hardcoded float-precision drift.
const seed16 = BYTEPLUS_MODELS.find(
(m) => m.id === 'seed-1-6-250915',
)!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 10,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('byteplus:seed-1-6-250915');
expect(overrides.prompt_tokens).toBeCloseTo(
100 * Number(seed16.costs.prompt_tokens),
5,
);
expect(overrides.completion_tokens).toBeCloseTo(
50 * Number(seed16.costs.completion_tokens),
5,
);
expect(overrides.cached_tokens).toBeCloseTo(
10 * Number(seed16.costs.cached_tokens ?? 0),
5,
);
});
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: '{"q":"puter"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
}),
)) as { message: { tool_calls?: unknown[] }; finish_reason: string };
expect(result.finish_reason).toBe('tool_calls');
expect(result.message.tool_calls).toEqual([
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: '{"q":"puter"}',
},
},
]);
});
it('renames Ark `reasoning_content` to `reasoning` on the message', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: 'final answer',
reasoning_content: 'thinking out loud',
},
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as { message: Record<string, unknown> };
expect(result.message.reasoning).toBe('thinking out loud');
expect('reasoning_content' in result.message).toBe(false);
});
it('does not overwrite an existing `reasoning` field if both are present', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: 'final',
reasoning: 'original',
reasoning_content: 'should-be-dropped',
},
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as { message: Record<string, unknown> };
expect(result.message.reasoning).toBe('original');
expect('reasoning_content' in result.message).toBe(false);
});
});
// -- Streaming deltas ------------------------------------------------
describe('BytePlusProvider.complete streaming', () => {
it('streams text deltas through to text events and meters final usage', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{
choices: [{ delta: {} }],
usage: {
prompt_tokens: 4,
completion_tokens: 2,
prompt_tokens_details: { cached_tokens: 1 },
},
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
);
expect((result as { stream: boolean }).stream).toBe(true);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const textEvents = events.filter((e) => e.type === 'text');
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
const usageEvent = events.find((e) => e.type === 'usage');
expect(usageEvent?.usage).toEqual({
prompt_tokens: 4,
completion_tokens: 2,
cached_tokens: 1,
});
const seed16 = BYTEPLUS_MODELS.find(
(m) => m.id === 'seed-1-6-250915',
)!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('byteplus:seed-1-6-250915');
expect(overrides.prompt_tokens).toBeCloseTo(
4 * Number(seed16.costs.prompt_tokens),
5,
);
expect(overrides.completion_tokens).toBeCloseTo(
2 * Number(seed16.costs.completion_tokens),
5,
);
expect(overrides.cached_tokens).toBeCloseTo(
1 * Number(seed16.costs.cached_tokens ?? 0),
5,
);
});
it('streams reasoning_content deltas as reasoning events', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{
choices: [
{ delta: { reasoning_content: 'pondering...' } },
],
},
{ choices: [{ delta: { content: 'answer' } }] },
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'think hard' }],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const reasoningEvents = events.filter((e) => e.type === 'reasoning');
expect(reasoningEvents.map((e) => e.reasoning)).toEqual([
'pondering...',
]);
const textEvents = events.filter((e) => e.type === 'text');
expect(textEvents.map((e) => e.text)).toEqual(['answer']);
});
it('builds a tool_use block from streamed function-call deltas', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'call_1',
function: {
name: 'lookup',
arguments: '{"q":',
},
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: { arguments: '"puter"}' },
},
],
},
},
],
},
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const toolEvent = events.find((e) => e.type === 'tool_use');
expect(toolEvent).toBeDefined();
expect(toolEvent?.id).toBe('call_1');
expect(toolEvent?.name).toBe('lookup');
expect(toolEvent?.input).toEqual({ q: 'puter' });
});
});
// -- Error mapping ---------------------------------------------------
describe('BytePlusProvider.complete error mapping', () => {
it('rethrows errors raised by the OpenAI client unchanged', async () => {
const { provider } = makeProvider();
const apiError = new Error('ModelArk exploded');
createMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.complete({
model: 'seed-1-6-250915',
messages: [{ role: 'user', content: 'boom' }],
}),
),
).rejects.toBe(apiError);
// No metering should be recorded on a failed call.
expect(recordSpy).not.toHaveBeenCalled();
});
});
// -- Moderation ------------------------------------------------------
describe('BytePlusProvider.checkModeration', () => {
it('throws — BytePlus provider does not implement moderation', () => {
const { provider } = makeProvider();
expect(() => provider.checkModeration('anything')).toThrow(
/not implemented/i,
);
});
});
@@ -0,0 +1,180 @@
/*
* 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 { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { BYTEPLUS_MODELS } from './models.js';
type BytePlusConfig = {
apiKey: string;
apiBaseUrl?: string;
};
type BytePlusCustomParams = {
response_format?: unknown;
stop?: string[];
// Ark-specific toggle for deep reasoning; the seed models reason by
// default and route those tokens to `reasoning_content`.
thinking?: {
type?: 'enabled' | 'disabled' | 'auto';
};
};
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
/**
* BytePlus ModelArk provider an OpenAI-compatible endpoint serving
* ByteDance's Seed models plus hosted third-party models (GLM, DeepSeek,
* GPT-OSS). https://docs.byteplus.com/en/docs/ModelArk/1330626
*/
export class BytePlusProvider implements IChatProvider {
#openai: OpenAI;
#meteringService: MeteringService;
#defaultModel = 'seed-2-0-lite-260428';
constructor(config: BytePlusConfig, meteringService: MeteringService) {
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL:
config.apiBaseUrl ??
'https://ark.ap-southeast.bytepluses.com/api/v3',
});
this.#meteringService = meteringService;
}
getDefaultModel() {
return this.#defaultModel;
}
models() {
return BYTEPLUS_MODELS;
}
list() {
const modelIds: string[] = [];
for (const model of this.models()) {
modelIds.push(model.id);
if (model.aliases) {
modelIds.push(...model.aliases);
}
}
return modelIds;
}
async complete(
params: ICompleteArguments,
): ReturnType<IChatProvider['complete']> {
const {
custom,
max_tokens,
stream,
temperature,
tools,
tool_choice,
top_p,
} = params;
let { messages, model } = params;
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
messages = await OpenAIUtil.process_input_messages(messages);
messages = messages.map((message) => {
delete message.cache_control;
return message;
});
const customParams = asRecord(custom) as BytePlusCustomParams;
const completionParams: ChatCompletionCreateParams = {
messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
...(tool_choice !== undefined ? { tool_choice } : {}),
...(max_tokens !== undefined ? { max_tokens } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(top_p !== undefined ? { top_p } : {}),
...(customParams.response_format
? { response_format: customParams.response_format }
: {}),
...(customParams.stop ? { stop: customParams.stop } : {}),
...(customParams.thinking
? { thinking: customParams.thinking }
: {}),
stream: !!stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
} as ChatCompletionCreateParams;
const completion =
await this.#openai.chat.completions.create(completionParams);
const result = await OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const trackedUsage = usage
? OpenAIUtil.extractMeteredUsage(usage)
: {
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
};
const costsOverride = Object.fromEntries(
Object.entries(trackedUsage).map(([key, value]) => {
return [key, value * Number(modelUsed.costs[key] ?? 0)];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
`byteplus:${modelUsed.id}`,
costsOverride,
);
return trackedUsage;
},
stream,
completion,
});
// Ark's deep-reasoning models return `reasoning_content` (DeepSeek
// wire convention); expose it under `reasoning` like other providers.
OpenAIUtil.normalizeReasoningContent(result);
return result;
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
}
}
@@ -0,0 +1,196 @@
/*
* 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 { IChatModel } from '../../types.js';
import { usdPerMToken } from '../../utils/pricing.js';
const K = 1_000;
const bytePlusModel = (
id: string,
name: string,
context: number,
maxTokens: number,
costs: IChatModel['costs'],
opts: {
input?: string[];
extraAliases?: string[];
openWeights?: boolean;
} = {},
): IChatModel => ({
puterId: `byteplus:byteplus/${id}`,
id,
name,
aliases: [`byteplus/${id}`, ...(opts.extraAliases ?? [])],
modalities: { input: opts.input ?? ['text'], output: ['text'] },
open_weights: opts.openWeights ?? false,
tool_call: true,
context,
max_tokens: maxTokens,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs,
});
// An undated series alias (e.g. `seed-1-6`) points at the newest snapshot of
// that series, mirroring how other providers alias their rolling names.
const series = (alias: string) => [alias, `byteplus/${alias}`];
const VISION = ['text', 'image', 'video'];
// Hardcoded from https://docs.byteplus.com/en/docs/ModelArk/1330310 (catalog)
// and https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing).
//
// Some models bill a higher rate for prompts above 128K tokens; the costs
// below are the base tier. seed-2-0-lite/mini-260428 also accept audio input
// at a much higher per-token rate ModelArk folds into prompt_tokens, so audio
// is deliberately left out of `modalities` to keep metering honest.
export const BYTEPLUS_MODELS: IChatModel[] = [
bytePlusModel(
'dola-seed-2-1-turbo-260628',
'Dola Seed 2.1 Turbo',
256 * K,
256 * K,
usdPerMToken(0.5, 2.5, 0.1),
{ input: VISION, extraAliases: series('dola-seed-2-1-turbo') },
),
bytePlusModel(
'seed-2-0-lite-260428',
'Seed 2.0 Lite',
256 * K,
128 * K,
usdPerMToken(0.25, 2, 0.05),
{ input: VISION, extraAliases: series('seed-2-0-lite') },
),
bytePlusModel(
'seed-2-0-lite-260228',
'Seed 2.0 Lite',
256 * K,
128 * K,
usdPerMToken(0.25, 2, 0.05),
{ input: VISION },
),
bytePlusModel(
'seed-2-0-mini-260428',
'Seed 2.0 Mini',
256 * K,
128 * K,
usdPerMToken(0.1, 0.4, 0.02),
{ input: VISION, extraAliases: series('seed-2-0-mini') },
),
bytePlusModel(
'seed-2-0-mini-260215',
'Seed 2.0 Mini',
256 * K,
128 * K,
usdPerMToken(0.1, 0.4, 0.02),
{ input: VISION },
),
bytePlusModel(
'seed-2-0-pro-260328',
'Seed 2.0 Pro',
256 * K,
128 * K,
usdPerMToken(0.5, 3, 0.1),
{ input: VISION, extraAliases: series('seed-2-0-pro') },
),
bytePlusModel(
'seed-2-0-code-preview-260328',
'Seed 2.0 Code Preview',
256 * K,
128 * K,
usdPerMToken(0.5, 3, 0.1),
{ input: VISION, extraAliases: series('seed-2-0-code-preview') },
),
bytePlusModel(
'seed-1-8-251228',
'Seed 1.8',
256 * K,
64 * K,
usdPerMToken(0.25, 2, 0.05),
{ input: VISION, extraAliases: series('seed-1-8') },
),
bytePlusModel(
'seed-1-6-250915',
'Seed 1.6',
256 * K,
32 * K,
usdPerMToken(0.25, 2, 0.05),
{ input: VISION, extraAliases: series('seed-1-6') },
),
bytePlusModel(
'seed-1-6-flash-250715',
'Seed 1.6 Flash',
256 * K,
32 * K,
usdPerMToken(0.075, 0.3, 0.015),
{ input: VISION, extraAliases: series('seed-1-6-flash') },
),
bytePlusModel(
'glm-5-2-260617',
'GLM-5.2',
1_024 * K,
128 * K,
usdPerMToken(1.4, 4.4, 0.26),
{ extraAliases: series('glm-5-2') },
),
bytePlusModel(
'glm-4-7-251222',
'GLM-4.7',
200 * K,
128 * K,
usdPerMToken(0.6, 2.2, 0.11),
{ extraAliases: series('glm-4-7') },
),
// The bare `deepseek-v4-*` names belong to the first-party DeepSeek
// provider, so these only carry the byteplus-prefixed aliases.
bytePlusModel(
'deepseek-v4-pro-260425',
'DeepSeek V4 Pro',
1_024 * K,
384 * K,
usdPerMToken(1.74, 3.48, 0.145),
{ extraAliases: ['byteplus/deepseek-v4-pro'] },
),
bytePlusModel(
'deepseek-v4-flash-260425',
'DeepSeek V4 Flash',
1_024 * K,
384 * K,
usdPerMToken(0.14, 0.28, 0.028),
{ extraAliases: ['byteplus/deepseek-v4-flash'] },
),
bytePlusModel(
'deepseek-v3-2-251201',
'DeepSeek V3.2',
128 * K,
32 * K,
usdPerMToken(0.28, 0.42, 0.056),
{ extraAliases: series('deepseek-v3-2') },
),
bytePlusModel(
'gpt-oss-120b-250805',
'GPT-OSS 120B',
128 * K,
64 * K,
usdPerMToken(0.1, 0.5),
{ extraAliases: series('gpt-oss-120b'), openWeights: true },
),
];
@@ -95,8 +95,7 @@ export class NeuralwattProvider implements IChatProvider {
async models(): Promise<IChatModel[]> {
let apiModels = kv.get(KV_MODELS_KEY) as
| NeuralwattApiModel[]
| undefined;
NeuralwattApiModel[] | undefined;
if (!apiModels) {
try {
const resp = await axios.request({
@@ -127,16 +126,15 @@ export class NeuralwattProvider implements IChatProvider {
}
/**
* Cached account accounting method (`energy` | `token`) from
* `GET /v1/quota`. Used only to annotate returned usage billing
* always prefers `cost.request_cost_usd` on the completion.
* Cached account accounting method (`energy` | `token`) from `GET
* /v1/quota`. Used only to annotate returned usage billing always prefers
* `cost.request_cost_usd` on the completion.
*/
async getAccountingMethod(): Promise<
NeuralwattAccountingMethod | undefined
> {
let method = kv.get(KV_QUOTA_KEY) as
| NeuralwattAccountingMethod
| undefined;
NeuralwattAccountingMethod | undefined;
if (method === 'energy' || method === 'token') return method;
try {
@@ -187,9 +185,7 @@ export class NeuralwattProvider implements IChatProvider {
if (!modelUsed) {
if (hasImages) {
modelUsed = availableModels.find((m) =>
modelSupportsVision(m),
);
modelUsed = availableModels.find((m) => modelSupportsVision(m));
}
modelUsed =
modelUsed ||
@@ -221,8 +217,7 @@ export class NeuralwattProvider implements IChatProvider {
messages = await OpenAIUtil.process_input_messages(messages);
const requestedReasoningEffort =
reasoning_effort ?? reasoning?.effort;
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
const supportsReasoningEffort = modelUsed.reasoning_effort === true;
const completionParams = {
@@ -353,8 +348,7 @@ export class NeuralwattProvider implements IChatProvider {
...chunk.usage,
...(typeof chunk.cost?.request_cost_usd === 'number'
? {
request_cost_usd:
chunk.cost.request_cost_usd,
request_cost_usd: chunk.cost.request_cost_usd,
}
: {}),
...(chunk.energy
@@ -24,10 +24,10 @@ export const NEURALWATT_ID_PREFIX = 'neuralwatt:';
export const NEURALWATT_DEFAULT_MODEL = 'neuralwatt:deepseek-v4-flash';
/**
* Shape of one entry in Neuralwatt's `GET /v1/models` catalog.
* Pricing is USD per million tokens under `metadata.pricing` and is used
* only for Puter preflight estimates / token-cost fallback. Live billing
* uses each completion's `cost.request_cost_usd`.
* Shape of one entry in Neuralwatt's `GET /v1/models` catalog. Pricing is USD
* per million tokens under `metadata.pricing` and is used only for Puter
* preflight estimates / token-cost fallback. Live billing uses each
* completion's `cost.request_cost_usd`.
*/
export type NeuralwattApiModel = {
@@ -94,9 +94,9 @@ export const stripNeuralwattPrefix = (modelId: string): string =>
: modelId;
/**
* Map a Neuralwatt catalog entry to Puter's `IChatModel`. Returns `null`
* when pricing is TBD (placeholders) so the model is not offered for
* preflight credit checks until Neuralwatt publishes real rates.
* Map a Neuralwatt catalog entry to Puter's `IChatModel`. Returns `null` when
* pricing is TBD (placeholders) so the model is not offered for preflight
* credit checks until Neuralwatt publishes real rates.
*/
export const mapNeuralwattApiModel = (
model: NeuralwattApiModel,
@@ -166,8 +166,8 @@ export const modelSupportsVision = (model: IChatModel): boolean =>
model.modalities.input.includes('image');
/**
* Detect image / puter_path parts so we can prefer a vision-capable model
* from the Neuralwatt catalog (or reject a text-only pick).
* Detect image / puter_path parts so we can prefer a vision-capable model from
* the Neuralwatt catalog (or reject a text-only pick).
*/
export const messagesHaveImageContent = (
messages: Array<{ content?: unknown }>,
@@ -180,7 +180,7 @@ export class ZAIProvider implements IChatProvider {
completion,
});
this.#normalizeReasoningContent(result);
OpenAIUtil.normalizeReasoningContent(result);
return result;
}
@@ -189,32 +189,4 @@ export class ZAIProvider implements IChatProvider {
): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
}
#normalizeReasoningContent(
result: Awaited<ReturnType<IChatProvider['complete']>>,
) {
if (!('message' in result) || !result.message) return;
const message = result.message as Record<string, unknown>;
if (
message.reasoning === undefined &&
message.reasoning_content !== undefined
) {
message.reasoning = message.reasoning_content;
}
delete message.reasoning_content;
if (!Array.isArray(message.content)) return;
for (const contentPart of message.content) {
const part = asRecord(contentPart);
if (
part.reasoning === undefined &&
part.reasoning_content !== undefined
) {
part.reasoning = part.reasoning_content;
}
delete part.reasoning_content;
}
}
}
@@ -265,6 +265,36 @@ export const extractMeteredUsage = (usage) => {
};
};
// Renames one object's DeepSeek-wire `reasoning_content` to the `reasoning`
// key Puter exposes, without clobbering an existing `reasoning`.
const renameReasoningContent = (obj) => {
if (obj.reasoning === undefined && obj.reasoning_content !== undefined) {
obj.reasoning = obj.reasoning_content;
}
delete obj.reasoning_content;
};
/**
* Normalize a non-streaming completion result whose provider follows the
* DeepSeek wire convention (`reasoning_content` on the message and content
* parts) to Puter's `reasoning` key. The streaming path already does this in
* create_chat_stream_handler.
*/
export const normalizeReasoningContent = (result) => {
if (!result || typeof result !== 'object') return;
if (!('message' in result) || !result.message) return;
const message = result.message;
renameReasoningContent(message);
if (!Array.isArray(message.content)) return;
for (const part of message.content) {
if (part && typeof part === 'object' && !Array.isArray(part)) {
renameReasoningContent(part);
}
}
};
export const create_chat_stream_handler =
({ deviations, completion, usage_calculator }) =>
async ({ chatStream }) => {
@@ -27,6 +27,7 @@ import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { secureFetch } from '../../util/secureHttp.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';
@@ -60,6 +61,7 @@ export class ImageGenerationDriver extends PuterDriver {
'cloudflare-image-generation',
'xai-image-generation',
'replicate-image-generation',
'byteplus-image-generation',
];
readonly isDefault = true;
@@ -291,8 +293,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) ??
@@ -307,8 +308,7 @@ export class ImageGenerationDriver extends PuterDriver {
apiToken: cfToken,
accountId: cfAccount,
apiBaseUrl: cloudflare?.apiBaseUrl as
| string
| undefined,
string | undefined,
},
m,
);
@@ -333,6 +333,29 @@ 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. Each field
// falls through independently so a partial image-specific block can't
// pair its missing apiBaseUrl with the shared block's key (or vice
// versa) and point a region-scoped key at the wrong endpoint.
const byteplusImageCfg = providers['byteplus-image-generation'] as
Record<string, unknown> | undefined;
const byteplusSharedCfg = providers['byteplus'] as
Record<string, unknown> | undefined;
const byteplusKey = readKey(byteplusImageCfg, byteplusSharedCfg);
if (byteplusKey) {
this.#providers['byteplus-image-generation'] =
new BytePlusImageProvider(
{
apiKey: byteplusKey,
apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
},
m,
);
}
}
async #buildModelMap() {
+19 -9
View File
@@ -20,8 +20,8 @@
/**
* Shared helpers for `input_images` (image-to-image) handling across image
* providers. `input_images` is the canonical, cross-provider field; an entry
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
* API needs base64 use these helpers to normalize URLs server-side (via the
* may be a public URL, a data-URI, or raw base64. Providers whose upstream API
* needs base64 use these helpers to normalize URLs server-side (via the
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
* xAI) pass them through untouched.
*/
@@ -35,9 +35,20 @@ export function isHttpUrl(s: string): boolean {
}
/**
* Resolve the single input image for providers that only support one.
* Throws 400 if `input_images` carries more than one entry. Returns the
* chosen image string (URL / data-URI / raw base64) or undefined.
* Normalize an input-image string for providers that accept URLs natively:
* http(s) URLs and data-URIs pass through untouched, raw base64 is wrapped with
* `mimeHint` (default image/png).
*/
export function toUrlOrDataUri(img: string, mimeHint?: string): string {
return isHttpUrl(img) || img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
/**
* Resolve the single input image for providers that only support one. Throws
* 400 if `input_images` carries more than one entry. Returns the chosen image
* string (URL / data-URI / raw base64) or undefined.
*/
export function resolveSingleInputImage(
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
@@ -84,10 +95,9 @@ export async function fetchImageAsBase64(
}
/**
* Normalize any input-image string to a base64 data-URI:
* http(s) URL fetched via secureFetch
* data-URI returned as-is
* raw base64 wrapped with `mimeHint` (default image/png)
* Normalize any input-image string to a base64 data-URI: http(s) URL
* fetched via secureFetch data-URI returned as-is raw base64 wrapped
* with `mimeHint` (default image/png)
*/
export async function toBase64DataUri(
img: string,
@@ -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,550 @@
/*
* 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({
model: 'seedream-4-0',
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({
model: 'seedream-4-0',
prompt: 'hi',
quality: '1k',
ratio: { w: 16, h: 9 },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
});
// seedream-4-5 and the 5.0 series reject anything under 3,686,400 pixels,
// which rules out every 1K/1.5K size — both the tier keyword and the
// aspect-ratio mapping have to land on 2K.
it('snaps a sub-minimum tier up to the smallest the model accepts', async () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-5',
prompt: 'hi',
quality: '1k',
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('2K');
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
quality: '1.5k',
ratio: { w: 16, h: 9 },
}),
);
expect(generateMock.mock.calls[1]![0].size).toBe('2816x1584');
});
it('passes explicit pixel dimensions straight through', async () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-0',
prompt: 'hi',
ratio: { w: 2048, h: 1024 },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('2048x1024');
});
it('rejects explicit pixel dimensions below a 2K-only model minimum', async () => {
// The default model (seedream-5-0-lite) only accepts >= 3,686,400 px,
// so a size that passes the generic floor still fails pre-flight
// instead of round-tripping to Ark for a 400.
await expect(
withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
ratio: { w: 2048, h: 1024 },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateMock).not.toHaveBeenCalled();
});
it('reduces w:h to lowest terms when mapping an aspect ratio', async () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-0',
prompt: 'hi',
quality: '1k',
ratio: { w: 32, h: 18 },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
});
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('sends a URL input image untouched', async () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-0',
prompt: 'add a hat',
input_image: 'https://example.com/cat.png',
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.model).toBe('seedream-4-0-250828');
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: 'dola-seedream-5-0-pro',
prompt: 'hi',
input_images: Array(11).fill(PNG),
}),
),
).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,345 @@
/*
* 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 { toUrlOrDataUri } 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;
// Models restricted to the 2K tier (see SEEDREAM_2K_ONLY in models.ts)
// enforce this higher minimum on explicit sizes too.
const MIN_TOTAL_PIXELS_2K_ONLY = 3_686_400;
// 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;
// Ark's `size` tiers, smallest first.
const TIERS = ['1k', '1.5k', '2k'] as const;
type Tier = (typeof TIERS)[number];
const isTier = (v: string): v is Tier =>
(TIERS as readonly string[]).includes(v);
// 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).
*
* 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 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 = 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' },
);
}
const inputImageCount = input_images?.length ?? 0;
const tier = this.#normalizeTier(quality, selectedModel);
const size = this.#resolveSize(tier, selectedModel, 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');
if (!actor) {
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
}
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) =>
toUrlOrDataUri(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;
}
/**
* Pick the tier to request. Models with a minimum output-pixel count reject
* the smaller tiers outright (and the aspect-ratio table maps them to
* sub-minimum sizes), so snap up to the nearest tier the model allows
* rather than letting Ark 400 the request.
*/
#normalizeTier(quality: string | undefined, model: IImageModel): Tier {
const q = (quality ?? '').toLowerCase();
// OpenAI-style quality names other Puter providers accept map onto
// the nearest Ark tier so e.g. 'low' isn't silently billed at the
// 2K rate; anything else falls back to Ark's own default of 2K.
const synonym: Record<string, Tier> = {
low: '1k',
medium: '1.5k',
high: '2k',
hd: '2k',
};
const requested: Tier = isTier(q) ? q : (synonym[q] ?? '2k');
const allowed = TIERS.filter(
(t) => model.allowedQualityLevels?.includes(t) ?? true,
);
if (allowed.length === 0 || allowed.includes(requested)) {
return requested;
}
return (
allowed.find((t) => TIERS.indexOf(t) > TIERS.indexOf(requested)) ??
allowed[allowed.length - 1]
);
}
/**
* 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: Tier,
model: IImageModel,
ratio?: { w: number; h: number },
): string {
if (ratio?.w && ratio?.h) {
const pixels = ratio.w * ratio.h;
if (pixels >= MIN_TOTAL_PIXELS) {
// 2K-only models enforce a higher minimum on explicit sizes
// too — fail fast with the real constraint instead of letting
// Ark 400 the request after the round-trip.
const minPixels = model.allowedQualityLevels?.includes('1k')
? MIN_TOTAL_PIXELS
: MIN_TOTAL_PIXELS_2K_ONLY;
const aspect = ratio.w / ratio.h;
if (
pixels < minPixels ||
pixels > MAX_TOTAL_PIXELS ||
aspect < 1 / 16 ||
aspect > 16
) {
throw new HttpError(
400,
`Requested size ${ratio.w}x${ratio.h} is outside BytePlus limits ` +
`for ${model.id} (total pixels within [${minPixels}, ${MAX_TOTAL_PIXELS}], ` +
'aspect ratio within [1/16, 16])',
{ legacyCode: 'bad_request' },
);
}
return `${ratio.w}x${ratio.h}`;
}
// Reduce w:h to lowest terms so any spelling of a supported
// aspect (8:6, 32:18, ...) finds its documented tier size.
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b);
const d = gcd(Math.round(ratio.w), Math.round(ratio.h)) || 1;
const key = `${Math.round(ratio.w) / d}:${Math.round(ratio.h) / d}`;
const mapped = SEEDREAM_RESOLUTION_MAP[key]?.[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,164 @@
/*
* 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'];
// seedream-4-5 and the 5.0 series enforce a minimum output of 3,686,400
// pixels, which every 1K and 1.5K entry in the table above falls under —
// they only accept the 2K tier.
const SEEDREAM_2K_ONLY = ['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_2K_ONLY,
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_2K_ONLY,
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,
},
];
@@ -26,6 +26,7 @@ import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { secureFetch } from '../../util/secureHttp.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';
@@ -57,6 +58,7 @@ export class VideoGenerationDriver extends PuterDriver {
'openai-video-generation',
'together-video-generation',
'gemini-video-generation',
'byteplus-video-generation',
];
readonly isDefault = true;
@@ -220,11 +222,16 @@ export class VideoGenerationDriver extends PuterDriver {
? args.resolution
: undefined;
// Case-insensitive so '4K' matches a catalog entry spelled '4k';
// the matched catalog spelling (not the caller's) is forwarded.
const normalizedResolution =
requestedResolution &&
model.dimensions.includes(requestedResolution)
? requestedResolution
: model.dimensions[0];
(requestedResolution &&
model.dimensions.find(
(d) =>
d.toLowerCase() ===
requestedResolution.toLowerCase(),
)) ||
model.dimensions[0];
args.size = normalizedResolution;
args.resolution = normalizedResolution;
}
@@ -291,6 +298,29 @@ 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. Each field
// falls through independently so a partial video-specific block can't
// pair its missing apiBaseUrl with the shared block's key (or vice
// versa) and point a region-scoped key at the wrong endpoint.
const byteplusVideoCfg = providers['byteplus-video-generation'] as
Record<string, unknown> | undefined;
const byteplusSharedCfg = providers['byteplus'] as
Record<string, unknown> | undefined;
const byteplusKey = readKey(byteplusVideoCfg, byteplusSharedCfg);
if (byteplusKey) {
this.#providers['byteplus-video-generation'] =
new BytePlusVideoProvider(
{
apiKey: byteplusKey,
apiBaseUrl: (byteplusVideoCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
},
m,
);
}
}
// -- Model map -----------------------------------------------------------
@@ -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,464 @@
/*
* 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);
});
// A clip shorter than the model's minimum is a duration to round up, not
// an affordability problem — it used to surface as "insufficient funds".
it('rounds a sub-minimum duration up to the shortest supported clip', async () => {
mockTaskFlow(succeededTask({ resolution: '480p' }));
await withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-0-mini',
prompt: 'hi',
resolution: '480p',
seconds: 2,
}),
);
expect(sentBody().duration).toBe(4);
});
// `dims` is shared across a model family, so it can't be the gate for
// what an individual model accepts.
it('falls back to the default resolution when the model does not list it', async () => {
mockTaskFlow(succeededTask({ resolution: '720p' }));
await withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-0-mini',
prompt: 'hi',
resolution: '1080p',
}),
);
expect(sentBody().resolution).toBe('720p');
});
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,476 @@
/*
* 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,
);
// `durationSeconds` enumerates the model's whole-second range, so a
// sub-minimum request rounds up to the shortest supported clip rather
// than being rejected as unaffordable.
const cappedSeconds = await capSecondsToRemainingCredits({
metering: this.#meteringService,
actor,
perSecondMicroCents,
requestedSeconds,
allowedSeconds: model.durationSeconds,
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' },
);
}
if (referenceImages!.length > MAX_REFERENCE_IMAGES) {
throw new HttpError(
400,
`${modelId} accepts at most ${MAX_REFERENCE_IMAGES} reference image(s)`,
{ legacyCode: 'bad_request' },
);
}
for (const img of referenceImages!) {
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();
// The shared `dims` table covers a whole model family, so gate on
// what this model actually advertises — otherwise e.g. 1080p on a
// 480p/720p-only model reaches Ark just to be rejected there.
const supported = model.dimensions!.some(
(d) => d.toLowerCase() === normalized,
);
if (supported && 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,256 @@
/*
* 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),
dimensions: ['720p', '480p', '1080p', '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,
},
};
+1
View File
@@ -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;
+3 -6
View File
@@ -446,8 +446,7 @@ export function resolveDriverMeta(
// drivers declare a raw object on the instance, which we validate here
// so a malformed `rateLimit` field still fails loud at registration.
const protoRateLimit = proto[DRIVER_RATE_LIMIT_KEY] as
| DriverRateLimitConfig
| undefined;
DriverRateLimitConfig | undefined;
let rateLimit: DriverRateLimitConfig | undefined;
if (protoRateLimit) {
rateLimit = protoRateLimit;
@@ -459,8 +458,7 @@ export function resolveDriverMeta(
}
const protoConcurrent = proto[DRIVER_CONCURRENT_KEY] as
| DriverConcurrentConfig
| undefined;
DriverConcurrentConfig | undefined;
let concurrent: DriverConcurrentConfig | undefined;
if (protoConcurrent) {
concurrent = protoConcurrent;
@@ -472,8 +470,7 @@ export function resolveDriverMeta(
}
const protoRequireSubscription = proto[DRIVER_REQUIRE_SUBSCRIPTION_KEY] as
| DriverRequireSubscriptionConfig
| undefined;
DriverRequireSubscriptionConfig | undefined;
let requireSubscription: DriverRequireSubscriptionConfig | undefined;
if (protoRequireSubscription) {
requireSubscription = protoRequireSubscription;
+2 -1
View File
@@ -139,7 +139,8 @@ export const PuterDriver = class PuterDriver implements WithCostsReporting {
public onServerShutdown() {
return;
}
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
public getReportedCosts():
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[] // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]> {
return [];
@@ -453,6 +453,30 @@ describe('MeteringService', () => {
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
});
});
it('charges the allowance first and records the split on the month record', async () => {
const overActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(overActor);
await target.updateAddonCredit(overActor.user.uuid!, 5_000_000);
// One increment that straddles the boundary: the allowance part
// lands in `allowanceUsed`, only the rest draws down credit.
await target.incrementUsage(
overActor,
'kv:read',
1,
sub.monthUsageAllowance + 1_000_000,
);
await waitFor(async () => {
const { usage } =
await target.getActorCurrentMonthUsageDetails(overActor);
expect(usage.allowanceUsed).toBe(sub.monthUsageAllowance);
expect(usage.total).toBe(sub.monthUsageAllowance + 1_000_000);
const addons = await target.getActorAddons(overActor);
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
});
});
});
// ── overuse alarm ────────────────────────────────────────────────
@@ -806,7 +830,9 @@ describe('MeteringService', () => {
}
const incrSpy = vi.spyOn(server.stores.meteringBuffer, 'incr');
await target.flushBufferedUsages();
expect(incrSpy).toHaveBeenCalledOnce();
// One usage write for all ten buffered events, plus the settle
// write that records the allowance/credit split.
expect(incrSpy).toHaveBeenCalledTimes(2);
incrSpy.mockRestore();
const { usage } =
@@ -894,7 +920,8 @@ describe('MeteringService', () => {
]);
}
await target.flushBufferedUsages();
expect(spy).toHaveBeenCalledTimes(concurrency * 3);
// Per bucket: the usage write plus the allowance settle.
expect(spy).toHaveBeenCalledTimes(concurrency * 3 * 2);
expect(peak).toBeLessThanOrEqual(concurrency);
} finally {
spy.mockRestore();
@@ -924,7 +951,8 @@ describe('MeteringService', () => {
target.flushBufferedUsages(),
target.flushBufferedUsages(),
]);
expect(started).toBe(1);
// One cycle ran (usage write + allowance settle), not three.
expect(started).toBe(2);
} finally {
spy.mockRestore();
}
@@ -1191,6 +1219,32 @@ describe('MeteringService', () => {
expect(result.total).toBe(100);
});
it('re-anchors allowanceUsed so the adjusted total is what the allowance is billed', async () => {
const sub = await target.getActorSubscription(actor);
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
// Overspend so the month holds allowance + credit-charged spend.
await target.incrementUsage(
actor,
'kv:read',
1,
sub.monthUsageAllowance + 5_000_000,
);
expect(await target.getRemainingUsage(actor)).toBe(0);
// Support sets the month back down: the new total is billed to
// the allowance in full and the rest of it reopens.
const result = await target.setActorCurrentMonthUsageTotal(
actor,
1_000,
);
expect(result.total).toBe(1_000);
expect(result.allowanceUsed).toBe(1_000);
expect(await target.getRemainingUsage(actor)).toBe(
sub.monthUsageAllowance - 1_000,
);
});
it('rejects a negative total', async () => {
await expect(
target.setActorCurrentMonthUsageTotal(actor, -1),
@@ -1308,6 +1362,76 @@ describe('MeteringService', () => {
expect(allowed.remaining).toBe(4_000_000);
});
it('keeps the allowance and credit pools separate across a mid-month upgrade', async () => {
const freeSub = await target.getActorSubscription(actor);
const freeAllowance = freeSub.monthUsageAllowance;
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
// Exhaust the free allowance, then draw 2_000_000 from credit.
await target.incrementUsage(actor, 'kv:read', 1, freeAllowance);
await target.incrementUsage(actor, 'kv:read', 1, 2_000_000);
await waitFor(async () => {
const addons = await target.getActorAddons(actor);
expect(addons.consumedPurchaseCredits).toBe(2_000_000);
});
// Upgrade mid-month to ten times the allowance. The allowance
// pool reopens (freeAllowance of 10x used); the credit pool is
// exactly where it was (2 of 5 consumed).
const paid = {
id: 'upgrade-paid',
monthUsageAllowance: freeAllowance * 10,
monthlyStorageAllowance: 1024 * 1024 * 1024,
};
target.registerPolicy(paid);
target.registerSubscriptionResolver(async () => 'upgrade-paid');
target.invalidateActorSubscription(actor.user.uuid!);
const allowed = await target.getAllowedUsage(actor);
expect(allowed.remaining).toBe(freeAllowance * 9 + 3_000_000);
expect(allowed.addons.consumedPurchaseCredits).toBe(2_000_000);
// Further spend consumes the reopened allowance, not credit.
await target.incrementUsage(actor, 'kv:read', 1, freeAllowance * 9);
const addons = await target.getActorAddons(actor);
expect(addons.consumedPurchaseCredits).toBe(2_000_000);
expect((await target.getAllowedUsage(actor)).remaining).toBe(
3_000_000,
);
// Only once the new allowance is full does credit drain again.
await target.incrementUsage(actor, 'kv:read', 1, 1_000_000);
await waitFor(async () => {
const after = await target.getActorAddons(actor);
expect(after.consumedPurchaseCredits).toBe(3_000_000);
});
});
it('falls back to the pre-split reading for month records without allowanceUsed', async () => {
const sub = await target.getActorSubscription(actor);
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
await server.stores.kv.incr({
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`,
pathAndAmountMap: { consumedPurchaseCredits: 5_000_000 },
});
// A legacy month record: total spans allowance + credit overage,
// no allowanceUsed split recorded.
const month = `${new Date().getUTCFullYear()}-${String(new Date().getUTCMonth() + 1).padStart(2, '0')}`;
await server.stores.meteringBuffer.incr({
key: `${METRICS_PREFIX}:actor:${actor.user.uuid}:${month}`,
pathAndAmountMap: {
total: sub.monthUsageAllowance + 5_000_000,
},
});
// Pre-split behavior: the whole total counts against the
// allowance (capped at it), so nothing changes at the deploy
// that introduced the field.
const allowed = await target.getAllowedUsage(actor);
expect(allowed.remaining).toBe(0);
});
it('counts consumed credits from prior months against the credit pool only', async () => {
// Simulate a prior-month overage: consumed credits exist but the
// current month has no usage (monthly usage keys roll over).
@@ -1894,8 +2018,9 @@ describe('MeteringService', () => {
const incr = vi.spyOn(server.stores.meteringBuffer, 'incr');
const usage = await target.getActorCurrentMonthUsageDetails(actor);
// Four charges across two listeners, settling as a single write.
expect(incr).toHaveBeenCalledTimes(1);
// Four charges across two listeners fold into a single usage
// write; the second call is the allowance settle.
expect(incr).toHaveBeenCalledTimes(2);
expect(incr.mock.calls[0]![0].pathAndAmountMap).toEqual({
total: 600,
'workers:monthly.units': 5,
+136 -38
View File
@@ -497,9 +497,10 @@ export class MeteringService extends PuterService {
actorSubscription.monthUsageAllowance,
);
await this.maybeConsumeAddonCredits(
const settledAllowanceUsed = await this.settleIncrementCharges(
userId,
actorUsages.total,
actorUsageKey,
actorUsages,
actorSubscription.monthUsageAllowance,
actorAddons,
totalCost,
@@ -519,7 +520,7 @@ export class MeteringService extends PuterService {
this.rememberRemainingCredits(
userId,
actorUsages.total,
settledAllowanceUsed,
actorSubscription.monthUsageAllowance,
actorAddons,
);
@@ -684,9 +685,10 @@ export class MeteringService extends PuterService {
actorSubscription.monthUsageAllowance,
);
await this.maybeConsumeAddonCredits(
const settledAllowanceUsed = await this.settleIncrementCharges(
userId,
actorUsages.total,
actorUsageKey,
actorUsages,
actorSubscription.monthUsageAllowance,
actorAddons,
totalBatchCost,
@@ -704,7 +706,7 @@ export class MeteringService extends PuterService {
this.rememberRemainingCredits(
userId,
actorUsages.total,
settledAllowanceUsed,
actorSubscription.monthUsageAllowance,
actorAddons,
);
@@ -928,7 +930,21 @@ export class MeteringService extends PuterService {
const currentTotal = (current as UsageByType | null)?.total ?? 0;
const delta = normalizedTotal - currentTotal;
if (delta === 0) {
// The adjusted total is taken to be allowance-charged in full — the
// knob's job is "this is what the month has cost the plan", and it
// doubles as the repair for records whose split predates
// `allowanceUsed`. Overshoot past the allowance is harmless: readers
// clamp. The credit pool is deliberately untouched; admin moves it
// through `updateAddonCredit`.
const subscription = await this.getActorSubscription(actor);
const allowanceUsedDelta =
normalizedTotal -
MeteringService.allowanceUsedFrom(
current as UsageByType | null,
subscription.monthUsageAllowance,
);
if (delta === 0 && allowanceUsedDelta === 0) {
return (current as UsageByType) || ({ total: 0 } as UsageByType);
}
@@ -942,7 +958,12 @@ export class MeteringService extends PuterService {
const updated = (
await this.stores.meteringBuffer.incr({
key: actorUsageKey,
pathAndAmountMap,
// `allowanceUsed` belongs to the actor record alone — the aux
// aggregates below reuse `pathAndAmountMap` without it.
pathAndAmountMap: {
...pathAndAmountMap,
allowanceUsed: allowanceUsedDelta,
},
})
).res as unknown as UsageByType;
@@ -1105,7 +1126,10 @@ export class MeteringService extends PuterService {
return {
remaining: MeteringService.remainingFrom(
currentMonthUsage.usage.total || 0,
MeteringService.allowanceUsedFrom(
currentMonthUsage.usage,
userSubscription.monthUsageAllowance,
),
userSubscription.monthUsageAllowance,
addons,
),
@@ -1115,21 +1139,42 @@ export class MeteringService extends PuterService {
}
/**
* What's left of an actor's budget, from the three numbers it's made of.
* How much of this month's spend was charged to the subscription allowance.
* Records from before `allowanceUsed` was tracked fall back to the
* pre-split reading everything counted against the allowance, capped at
* it which is also what keeps balances unchanged across the deploy that
* introduced the field.
*/
private static allowanceUsedFrom(
usage: UsageByType | null | undefined,
monthUsageAllowance: number,
): number {
if (!usage) return 0;
return (
usage.allowanceUsed ??
Math.min(usage.total || 0, Math.max(0, monthUsageAllowance || 0))
);
}
/**
* What's left of an actor's budget: what remains of the monthly allowance
* plus what remains of the lifetime credit pool.
*
* Overage past the allowance is already charged to purchased credits via
* `consumedPurchaseCredits`, so the allowance and the credit pool are
* netted separately subtracting month usage AND consumed credits from one
* combined pool would charge the overage twice.
* The pools are independent and each spend lands in exactly one of them
* (allowance first see `settleIncrementCharges`), so this is a plain sum.
* `allowanceUsed` rather than the month total is what the allowance is
* netted against: the total also contains credit-charged overage, which
* must not bill the allowance too visibly so when a plan change raises
* the allowance mid-month.
*/
private static remainingFrom(
monthUsageTotal: number,
allowanceUsed: number,
monthUsageAllowance: number,
addons: UsageAddons | null | undefined,
): number {
const remainingAllowance = Math.max(
0,
(monthUsageAllowance || 0) - (monthUsageTotal || 0),
(monthUsageAllowance || 0) - (allowanceUsed || 0),
);
const remainingPurchasedCredits = Math.max(
0,
@@ -1235,7 +1280,10 @@ export class MeteringService extends PuterService {
]);
this.rememberRemainingCredits(
uuid,
currentMonthUsage.usage.total || 0,
MeteringService.allowanceUsedFrom(
currentMonthUsage.usage,
subscription.monthUsageAllowance,
),
subscription.monthUsageAllowance,
addons,
);
@@ -1261,7 +1309,7 @@ export class MeteringService extends PuterService {
*/
private rememberRemainingCredits(
userId: string,
monthUsageTotal: number,
allowanceUsed: number,
monthUsageAllowance: number,
addons: UsageAddons | null | undefined,
): void {
@@ -1272,7 +1320,7 @@ export class MeteringService extends PuterService {
this.rememberHasCredits(
userId,
MeteringService.remainingFrom(
monthUsageTotal,
allowanceUsed,
monthUsageAllowance,
addons,
) > 0,
@@ -1688,33 +1736,83 @@ export class MeteringService extends PuterService {
return this.settledMonth === month && this.settledActors.has(claimId);
}
private async maybeConsumeAddonCredits(
/**
* Split a settled increment between the two budget pools, allowance first:
* whatever fits under the monthly allowance bumps the month record's
* `allowanceUsed`, and only the part that doesn't fit draws down the
* lifetime credit pool. Each spend lands in exactly one pool, which is what
* lets `remainingFrom` sum them independently.
*
* A month record without `allowanceUsed` predates the split; its first
* settled increment folds the fallback baseline into the write, so the
* record answers directly from then on and no balance moves on the deploy
* that introduced the field.
*
* Returns the month's allowance-charged spend as of after this settle
* `usageRecord` itself predates the write, so callers deciding on the
* balance must use this rather than re-read the record they hold.
*/
private async settleIncrementCharges(
userId: string,
totalUsage: number,
actorUsageKey: string,
usageRecord: UsageByType,
monthUsageAllowance: number,
addons: UsageAddons,
incrementCost: number,
): Promise<void> {
if (totalUsage <= monthUsageAllowance) return;
if (!addons.purchasedCredits) return;
if (addons.purchasedCredits <= (addons.consumedPurchaseCredits || 0))
return;
): Promise<number> {
if (incrementCost <= 0) {
return MeteringService.allowanceUsedFrom(
usageRecord,
monthUsageAllowance,
);
}
const withinBoundsUsage = Math.max(
const totalBefore = Math.max(
0,
monthUsageAllowance - totalUsage + incrementCost,
(usageRecord.total || 0) - incrementCost,
);
const overageUsage = incrementCost - withinBoundsUsage;
if (overageUsage <= 0) return;
// Same fallback as `allowanceUsedFrom`, measured before this
// increment's own cost.
const usedBefore =
usageRecord.allowanceUsed ??
Math.min(totalBefore, Math.max(0, monthUsageAllowance || 0));
const baseline =
usageRecord.allowanceUsed === undefined ? usedBefore : 0;
const toConsume = Math.min(
overageUsage,
addons.purchasedCredits - (addons.consumedPurchaseCredits || 0),
);
await this.stores.kv.incr({
key: `${POLICY_PREFIX}:actor:${userId}:addons`,
pathAndAmountMap: { consumedPurchaseCredits: toConsume },
});
const headroom = Math.max(0, monthUsageAllowance - usedBefore);
const allowanceCharge = Math.min(incrementCost, headroom);
const overage = incrementCost - allowanceCharge;
const writes: Promise<unknown>[] = [];
if (baseline + allowanceCharge > 0) {
writes.push(
this.stores.meteringBuffer.incr({
key: actorUsageKey,
pathAndAmountMap: {
allowanceUsed: baseline + allowanceCharge,
},
}),
);
}
const remainingCredits =
(addons.purchasedCredits || 0) -
(addons.consumedPurchaseCredits || 0);
if (overage > 0 && remainingCredits > 0) {
writes.push(
this.stores.kv.incr({
key: `${POLICY_PREFIX}:actor:${userId}:addons`,
pathAndAmountMap: {
consumedPurchaseCredits: Math.min(
overage,
remainingCredits,
),
},
}),
);
}
await Promise.all(writes);
return usedBefore + allowanceCharge;
}
private maybeAlertOveruse(ctx: {
+10
View File
@@ -41,6 +41,16 @@ export interface UsageInput {
export type UsageByType = {
total: number;
/**
* The part of `total` that was charged to the monthly subscription
* allowance. The allowance is consumed first; spend past it draws down the
* lifetime credit pool (`consumedPurchaseCredits`) instead, so the two
* pools never bill the same spend. Living on the month record, it resets
* with the month the way the allowance itself does. Absent on records from
* before the split was tracked readers fall back to counting `total`
* against the allowance, capped at the allowance.
*/
allowanceUsed?: number;
/**
* Claim counter for the month's recurring charges see
* `MONTHLY_CHARGE_CLAIM`. Absent until the first read or write of the
+3 -7
View File
@@ -239,12 +239,7 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
}
/**
@@ -1071,7 +1066,8 @@ export interface WithLifecycle extends Object {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];