Tighten BytePlus AI provider handling

Extract shared reasoning-content normalization for OpenAI-style chat providers, and harden BytePlus image/video behavior. This updates image tier and size validation, normalizes aspect ratios and input image refs, prevents mismatched BytePlus key/base URL fallback config, makes video resolution matching case-insensitive, and rejects excess reference images instead of silently truncating them. Tests were expanded to cover the new BytePlus request and validation paths.
This commit is contained in:
jelveh
2026-08-15 20:49:47 -07:00
parent 7571b63ba1
commit 26efd44169
12 changed files with 185 additions and 123 deletions
@@ -52,7 +52,7 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { BytePlusProvider } from './BytePlusProvider.js';
import { BYTEPLUS_MODELS } from './models.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
// -- 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
@@ -80,7 +80,7 @@ vi.mock('openai', () => {
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// ── Test harness ────────────────────────────────────────────────────
// -- Test harness ----------------------------------------------------
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
@@ -148,7 +148,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
// -- Construction ----------------------------------------------------
describe('BytePlusProvider construction', () => {
it('points the OpenAI SDK at the ModelArk base URL with the configured key', () => {
@@ -171,7 +171,7 @@ describe('BytePlusProvider construction', () => {
});
});
// ── Model catalog ───────────────────────────────────────────────────
// -- Model catalog ---------------------------------------------------
describe('BytePlusProvider model catalog', () => {
it('returns seed-2-0-lite-260428 as the default', () => {
@@ -209,7 +209,7 @@ describe('BytePlusProvider model catalog', () => {
});
});
// ── Request shape ───────────────────────────────────────────────────
// -- Request shape ---------------------------------------------------
describe('BytePlusProvider.complete request shape', () => {
const baseCompletion = {
@@ -357,7 +357,7 @@ describe('BytePlusProvider.complete request shape', () => {
});
});
// ── Model resolution ────────────────────────────────────────────────
// -- Model resolution ------------------------------------------------
describe('BytePlusProvider model resolution', () => {
const baseCompletion = {
@@ -428,7 +428,7 @@ describe('BytePlusProvider model resolution', () => {
});
});
// ── Non-stream completion + reasoning_content normalisation ─────────
// -- Non-stream completion + reasoning_content normalisation ---------
describe('BytePlusProvider.complete non-stream output', () => {
it('returns the first choice and runs the metered usage calculator', async () => {
@@ -600,7 +600,7 @@ describe('BytePlusProvider.complete non-stream output', () => {
});
});
// ── Streaming deltas ────────────────────────────────────────────────
// -- Streaming deltas ------------------------------------------------
describe('BytePlusProvider.complete streaming', () => {
it('streams text deltas through to text events and meters final usage', async () => {
@@ -781,7 +781,7 @@ describe('BytePlusProvider.complete streaming', () => {
});
});
// ── Error mapping ───────────────────────────────────────────────────
// -- Error mapping ---------------------------------------------------
describe('BytePlusProvider.complete error mapping', () => {
it('rethrows errors raised by the OpenAI client unchanged', async () => {
@@ -803,7 +803,7 @@ describe('BytePlusProvider.complete error mapping', () => {
});
});
// ── Moderation ──────────────────────────────────────────────────────
// -- Moderation ------------------------------------------------------
describe('BytePlusProvider.checkModeration', () => {
it('throws — BytePlus provider does not implement moderation', () => {
@@ -166,7 +166,9 @@ export class BytePlusProvider implements IChatProvider {
completion,
});
this.#normalizeReasoningContent(result);
// Ark's deep-reasoning models return `reasoning_content` (DeepSeek
// wire convention); expose it under `reasoning` like other providers.
OpenAIUtil.normalizeReasoningContent(result);
return result;
}
@@ -175,21 +177,4 @@ export class BytePlusProvider implements IChatProvider {
): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
}
// Ark's deep-reasoning models return `reasoning_content` (DeepSeek wire
// convention); expose it under the `reasoning` key like other providers.
#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;
}
}
@@ -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 }) => {
@@ -335,19 +335,22 @@ export class ImageGenerationDriver extends PuterDriver {
}
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
// selects the ModelArk region, same as the chat provider.
const byteplusCfg = (providers['byteplus-image-generation'] ??
providers['byteplus']) as Record<string, unknown> | undefined;
const byteplusKey = readKey(
providers['byteplus-image-generation'],
providers['byteplus'],
);
// 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: byteplusCfg?.apiBaseUrl as
apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
},
m,
+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,
@@ -47,7 +47,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { BYTEPLUS_IMAGE_GENERATION_MODELS } from './models.js';
import { BytePlusImageProvider } from './BytePlusImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
// -- OpenAI SDK mock -------------------------------------------------
const { generateMock, openAICtor } = vi.hoisted(() => ({
generateMock: vi.fn(),
@@ -68,7 +68,7 @@ vi.mock('openai', () => {
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// ── Test harness ────────────────────────────────────────────────────
// -- Test harness ----------------------------------------------------
let server: PuterServer;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
@@ -114,7 +114,7 @@ const sampleResponse = { data: [{ url: 'https://ark.example/img/1' }] };
const findModel = (id: string) =>
BYTEPLUS_IMAGE_GENERATION_MODELS.find((m) => m.id === id)!;
// ── Construction ────────────────────────────────────────────────────
// -- Construction ----------------------------------------------------
describe('BytePlusImageProvider construction', () => {
it('defaults the OpenAI SDK to the ap-southeast ModelArk base URL', () => {
@@ -146,7 +146,7 @@ describe('BytePlusImageProvider construction', () => {
});
});
// ── Model catalog ───────────────────────────────────────────────────
// -- Model catalog ---------------------------------------------------
describe('BytePlusImageProvider model catalog', () => {
it('returns seedream-5-0-lite as the default', () => {
@@ -162,7 +162,7 @@ describe('BytePlusImageProvider model catalog', () => {
});
});
// ── test_mode / validation / credit gate ────────────────────────────
// -- test_mode / validation / credit gate ----------------------------
describe('BytePlusImageProvider.generate gates', () => {
it('returns the canned sample URL in test_mode without side effects', async () => {
@@ -200,7 +200,7 @@ describe('BytePlusImageProvider.generate gates', () => {
});
});
// ── Model resolution ────────────────────────────────────────────────
// -- Model resolution ------------------------------------------------
describe('BytePlusImageProvider.generate model resolution', () => {
it('falls back to the default model for unknown ids', async () => {
@@ -237,7 +237,7 @@ describe('BytePlusImageProvider.generate model resolution', () => {
});
});
// ── Request shape ───────────────────────────────────────────────────
// -- Request shape ---------------------------------------------------
describe('BytePlusImageProvider.generate request shape', () => {
it('sends the tier keyword size (Ark default 2K), url format and no watermark', async () => {
@@ -306,6 +306,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-0',
prompt: 'hi',
ratio: { w: 2048, h: 1024 },
}),
@@ -313,6 +314,34 @@ describe('BytePlusImageProvider.generate request shape', () => {
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(() =>
@@ -340,7 +369,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
});
});
// ── Input images ────────────────────────────────────────────────────
// -- Input images ----------------------------------------------------
describe('BytePlusImageProvider.generate input images', () => {
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
@@ -390,7 +419,7 @@ describe('BytePlusImageProvider.generate input images', () => {
});
});
// ── Metering ────────────────────────────────────────────────────────
// -- Metering --------------------------------------------------------
describe('BytePlusImageProvider.generate metering', () => {
it('meters flat per-image models at their catalog rate', async () => {
@@ -488,7 +517,7 @@ describe('BytePlusImageProvider.generate metering', () => {
});
});
// ── Response handling ───────────────────────────────────────────────
// -- Response handling -----------------------------------------------
describe('BytePlusImageProvider.generate response handling', () => {
it('falls back to a data URI when the response carries b64_json', async () => {
@@ -26,7 +26,7 @@ import type {
IImageModel,
IImageProvider,
} from '../../types.js';
import { isHttpUrl } from '../../inputImage.js';
import { toUrlOrDataUri } from '../../inputImage.js';
import {
BYTEPLUS_IMAGE_GENERATION_MODELS,
SEEDREAM_RESOLUTION_MAP,
@@ -38,6 +38,9 @@ const DEFAULT_MODEL = 'seedream-5-0-lite-260128';
// [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;
@@ -135,7 +138,7 @@ export class BytePlusImageProvider implements IImageProvider {
const inputImageCount = input_images?.length ?? 0;
const tier = this.#normalizeTier(quality, selectedModel);
const size = this.#resolveSize(tier, ratio);
const size = this.#resolveSize(tier, selectedModel, ratio);
// The pro model bills by output pixel count; everything else is a
// flat per-image rate.
@@ -184,7 +187,7 @@ export class BytePlusImageProvider implements IImageProvider {
const image =
inputImageCount > 0
? input_images!.map((img) =>
this.#toImageRef(img, input_image_mime_type),
toUrlOrDataUri(img, input_image_mime_type),
)
: undefined;
@@ -239,14 +242,6 @@ export class BytePlusImageProvider implements IImageProvider {
return url;
}
// Ark accepts a public URL or a `data:image/...;base64,` URI; wrap raw
// base64 payloads so they're valid.
#toImageRef(img: string, mimeHint?: string): string {
return isHttpUrl(img) || img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
/**
* 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
@@ -255,8 +250,16 @@ export class BytePlusImageProvider implements IImageProvider {
*/
#normalizeTier(quality: string | undefined, model: IImageModel): Tier {
const q = (quality ?? '').toLowerCase();
// Ark's default size is 2K when unspecified.
const requested: Tier = isTier(q) ? q : '2k';
// 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,
@@ -278,12 +281,23 @@ export class BytePlusImageProvider implements IImageProvider {
* documented `WxH` for (aspect, tier)
* - Otherwise → the tier keyword (`1K`/`1.5K`/`2K`, method 1)
*/
#resolveSize(tier: Tier, ratio?: { w: number; h: number }): string {
#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
@@ -291,14 +305,20 @@ export class BytePlusImageProvider implements IImageProvider {
throw new HttpError(
400,
`Requested size ${ratio.w}x${ratio.h} is outside BytePlus limits ` +
`(total pixels ${MAX_TOTAL_PIXELS}, aspect ratio within [1/16, 16])`,
`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}`;
}
const mapped =
SEEDREAM_RESOLUTION_MAP[`${ratio.w}:${ratio.h}`]?.[tier];
// 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];
@@ -222,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;
}
@@ -295,19 +300,22 @@ export class VideoGenerationDriver extends PuterDriver {
}
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
// selects the ModelArk region, same as the chat provider.
const byteplusCfg = (providers['byteplus-video-generation'] ??
providers['byteplus']) as Record<string, unknown> | undefined;
const byteplusKey = readKey(
providers['byteplus-video-generation'],
providers['byteplus'],
);
// 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: byteplusCfg?.apiBaseUrl as
apiBaseUrl: (byteplusVideoCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
},
m,
@@ -46,7 +46,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { BYTEPLUS_VIDEO_GENERATION_MODELS } from './models.js';
import { BytePlusVideoProvider } from './BytePlusVideoProvider.js';
// ── Test harness ────────────────────────────────────────────────────
// -- Test harness ----------------------------------------------------
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
@@ -121,7 +121,7 @@ const sentBody = (callIndex = 0): Record<string, unknown> =>
const findModel = (id: string) =>
BYTEPLUS_VIDEO_GENERATION_MODELS.find((m) => m.id === id)!;
// ── Construction / catalog ──────────────────────────────────────────
// -- Construction / catalog ------------------------------------------
describe('BytePlusVideoProvider construction and catalog', () => {
it('throws when no apiKey is supplied', () => {
@@ -152,7 +152,7 @@ describe('BytePlusVideoProvider construction and catalog', () => {
});
});
// ── Gates ───────────────────────────────────────────────────────────
// -- Gates -----------------------------------------------------------
describe('BytePlusVideoProvider.generate gates', () => {
it('returns the canned sample URL in test_mode without network calls', async () => {
@@ -183,7 +183,7 @@ describe('BytePlusVideoProvider.generate gates', () => {
});
});
// ── Request shape ───────────────────────────────────────────────────
// -- Request shape ---------------------------------------------------
describe('BytePlusVideoProvider.generate request shape', () => {
it('POSTs a create-task request with text content and defaults', async () => {
@@ -362,7 +362,7 @@ describe('BytePlusVideoProvider.generate request shape', () => {
});
});
// ── Polling / outcomes ──────────────────────────────────────────────
// -- Polling / outcomes ----------------------------------------------
describe('BytePlusVideoProvider.generate polling and outcomes', () => {
it('keeps polling while the task is queued/running', async () => {
@@ -400,7 +400,7 @@ describe('BytePlusVideoProvider.generate polling and outcomes', () => {
});
});
// ── Metering ────────────────────────────────────────────────────────
// -- Metering --------------------------------------------------------
describe('BytePlusVideoProvider.generate metering', () => {
it('bills the tokens the task reports at the resolution rate', async () => {
@@ -279,7 +279,14 @@ export class BytePlusVideoProvider extends VideoProvider {
{ legacyCode: 'bad_request' },
);
}
for (const img of referenceImages!.slice(0, MAX_REFERENCE_IMAGES)) {
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',
@@ -90,9 +90,7 @@ export const BYTEPLUS_VIDEO_GENERATION_MODELS: IVideoModel[] = [
'default-duration-per-video': 76,
},
durationSeconds: seconds(5, 4, 15),
// '4K' repeats Ark's documented casing so the driver's strict
// `includes` match accepts either spelling.
dimensions: ['720p', '480p', '1080p', '4k', '4K'],
dimensions: ['720p', '480p', '1080p', '4k'],
fps: FPS,
defaultUsageKey:
'byteplus-video-generation:dreamina-seedance-2-0-260128:video_tokens:720p',