mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user