mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(platform): support live system proxy changes * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Honor lowercase proxy env vars within a single source before merging proxy-aware envs.\n\nGenerated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Refresh provider request proxy env on each dispatcher creation and cover it with a focused regression test. Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): enable node env proxy for user proxy vars * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
901 lines
31 KiB
TypeScript
901 lines
31 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
BYOK_SENSEAUDIO_TOOLS,
|
||
executeGenerateImage,
|
||
executeGenerateSpeech,
|
||
executeGenerateVideo,
|
||
} from '../src/byok-tools.js';
|
||
|
||
describe('BYOK_SENSEAUDIO_TOOLS', () => {
|
||
it('exports an OpenAI-shaped generate_image tool definition', () => {
|
||
const tool = BYOK_SENSEAUDIO_TOOLS.find(
|
||
(t) => t.function.name === 'generate_image',
|
||
);
|
||
expect(tool).toBeDefined();
|
||
expect(tool!.type).toBe('function');
|
||
expect(tool!.function.parameters.required).toEqual(['prompt']);
|
||
const properties = tool!.function.parameters.properties as Record<string, any>;
|
||
expect(properties.aspect_ratio.enum).toEqual([
|
||
'1:1',
|
||
'16:9',
|
||
'9:16',
|
||
'4:3',
|
||
'3:4',
|
||
]);
|
||
});
|
||
|
||
it('exposes image, speech, and video tools', () => {
|
||
const names = BYOK_SENSEAUDIO_TOOLS.map((t) => t.function.name).sort();
|
||
expect(names).toEqual(['generate_image', 'generate_speech', 'generate_video']);
|
||
});
|
||
});
|
||
|
||
describe('executeGenerateImage', () => {
|
||
let root: string;
|
||
let projectsRoot: string;
|
||
const PROJECT_ID = 'test-project';
|
||
const realFetch = globalThis.fetch;
|
||
|
||
beforeEach(async () => {
|
||
root = await mkdtemp(path.join(tmpdir(), 'od-byok-tools-'));
|
||
projectsRoot = path.join(root, 'projects');
|
||
});
|
||
|
||
afterEach(async () => {
|
||
globalThis.fetch = realFetch;
|
||
vi.unstubAllGlobals();
|
||
await rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
const baseCtx = () => ({
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
});
|
||
|
||
it('calls /v1/image/sync, downloads the URL, persists bytes, and returns a daemon URL', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
expect(init?.dispatcher).toBe(dispatcher);
|
||
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
|
||
expect(init?.method).toBe('POST');
|
||
expect(init?.headers).toMatchObject({
|
||
authorization: 'Bearer sa-byok-key',
|
||
'content-type': 'application/json',
|
||
});
|
||
expect(JSON.parse(String(init?.body))).toEqual({
|
||
model: 'senseaudio-image-2.0-260319',
|
||
prompt: 'a tabby cat playing with yarn',
|
||
size: '1024x1024',
|
||
});
|
||
return new Response(
|
||
JSON.stringify({
|
||
url: 'https://cdn.example.test/generated/cat.png',
|
||
base_resp: { status_code: 0, status_msg: 'success' },
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (url === 'https://cdn.example.test/generated/cat.png') {
|
||
return new Response(pngBytes, {
|
||
status: 200,
|
||
headers: { 'content-type': 'image/png' },
|
||
});
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'a tabby cat playing with yarn' },
|
||
{ ...baseCtx(), requestInit: { dispatcher } },
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
// Returns a relative URL through the project file route so the
|
||
// chat UI loads same-origin via Next.js's /api/:path* rewrite,
|
||
// satisfying the strict CSP `img-src 'self'`. Path component is
|
||
// url-encoded so unusual (but isSafeId-passing) project ids don't
|
||
// break the URL.
|
||
expect(result.url).toMatch(
|
||
new RegExp(`^/api/projects/${PROJECT_ID}/files/byok-[a-z0-9-]+\\.png$`),
|
||
);
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
|
||
// Persisted file lives inside the project folder where listFiles /
|
||
// readProjectFile / archive plumbing will all discover it.
|
||
const filename = result.url!.split('/').pop()!;
|
||
const onDisk = await readFile(path.join(projectsRoot, PROJECT_ID, filename));
|
||
expect(onDisk.equals(pngBytes)).toBe(true);
|
||
});
|
||
|
||
it('honours args.model when the LLM picks a SenseAudio image model', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
expect(JSON.parse(String(init?.body)).model).toBe('doubao-seedream-5-0-260128');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/hi.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'wallpaper', model: 'doubao-seedream-5-0-260128' },
|
||
baseCtx(),
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('falls back to ctx.defaultImageModel when args.model is missing', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
expect(JSON.parse(String(init?.body)).model).toBe('senseaudio-image-1.0-260319');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/std.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'standard' },
|
||
{ ...baseCtx(), defaultImageModel: 'senseaudio-image-1.0-260319' },
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('ignores args.model when it is not in the SenseAudio allowlist', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
// Falls through to ctx.defaultImageModel (registry-valid).
|
||
expect(JSON.parse(String(init?.body)).model).toBe('senseaudio-image-1.0-260319');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/x.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'spoofed', model: 'evil-model-id' },
|
||
{ ...baseCtx(), defaultImageModel: 'senseaudio-image-1.0-260319' },
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('falls back to registry default when both args.model and ctx.defaultImageModel are missing/invalid', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
// Registry default is the first SenseAudio entry — 2.0 today.
|
||
expect(JSON.parse(String(init?.body)).model).toBe('senseaudio-image-2.0-260319');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/d.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'no model anywhere' },
|
||
{ ...baseCtx(), defaultImageModel: 'also-bogus' },
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('rejects unsafe projectId before any upstream call', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'x' },
|
||
{ ...baseCtx(), projectId: '../escape' },
|
||
);
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/invalid projectId/);
|
||
// ensureProject runs up front so the unsafe id is caught BEFORE
|
||
// any senseaudio upstream call goes out — no token spent, no
|
||
// attempt to write outside the project tree.
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('maps aspect_ratio to the SenseAudio size string', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
expect(JSON.parse(String(init?.body)).size).toBe('1280x720');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/wide.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'widescreen banner', aspect_ratio: '16:9' },
|
||
baseCtx(),
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('falls back to 1:1 for unknown aspect_ratio values', async () => {
|
||
const pngBytes = Buffer.from([0x89, 0x50]);
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
expect(JSON.parse(String(init?.body)).size).toBe('1024x1024');
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/square.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(pngBytes, { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage(
|
||
{ prompt: 'square thing', aspect_ratio: 'something-else' },
|
||
baseCtx(),
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('returns { ok: false } on missing prompt', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({}, baseCtx());
|
||
|
||
expect(result).toEqual({ ok: false, error: 'prompt is required' });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('returns { ok: false } when no API key is available', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ctx = { ...baseCtx(), upstreamApiKey: '' };
|
||
const result = await executeGenerateImage({ prompt: 'whatever' }, ctx);
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/no SenseAudio API key/);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('surfaces HTTP failures with status code and truncated body', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response('unauthorized', {
|
||
status: 401,
|
||
headers: { 'content-type': 'text/plain' },
|
||
}),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/senseaudio image 401/);
|
||
});
|
||
|
||
it('surfaces error_message envelope verbatim', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response(
|
||
JSON.stringify({ error_message: 'sensitive_content_blocked' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/sensitive_content_blocked/);
|
||
});
|
||
|
||
it('surfaces base_resp non-zero status_code', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
base_resp: { status_code: 1004, status_msg: 'quota exhausted' },
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/api error 1004/);
|
||
expect(result.error).toMatch(/quota exhausted/);
|
||
});
|
||
|
||
it('returns { ok: false } when upstream returns no url', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response(
|
||
JSON.stringify({ base_resp: { status_code: 0, status_msg: 'ok' } }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/missing url/);
|
||
});
|
||
|
||
it('returns { ok: false } when the image download fails', async () => {
|
||
const fetchMock = vi.fn(async (input: unknown) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/image/sync')) {
|
||
return new Response(
|
||
JSON.stringify({ url: 'https://cdn.example.test/will-404.png' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response('not found', { status: 404 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateImage({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/image download 404/);
|
||
});
|
||
});
|
||
|
||
describe('BYOK_SENSEAUDIO_TOOLS — video', () => {
|
||
it('exposes a generate_video tool definition with the documented param surface', () => {
|
||
const video = BYOK_SENSEAUDIO_TOOLS.find(
|
||
(t) => t.function.name === 'generate_video',
|
||
);
|
||
expect(video).toBeDefined();
|
||
const props = video!.function.parameters.properties as Record<string, any>;
|
||
expect(video!.function.parameters.required).toEqual(['prompt']);
|
||
expect(props.aspect_ratio.enum).toEqual(['16:9', '9:16', '4:3', '3:4', '1:1']);
|
||
expect(props.resolution.enum).toEqual(['480p', '720p', '1080p']);
|
||
expect(props.duration).toMatchObject({ type: 'integer', minimum: 4, maximum: 15 });
|
||
expect(props.generate_audio.type).toBe('boolean');
|
||
});
|
||
});
|
||
|
||
describe('executeGenerateSpeech', () => {
|
||
let root: string;
|
||
let projectsRoot: string;
|
||
const PROJECT_ID = 'test-project';
|
||
const realFetch = globalThis.fetch;
|
||
|
||
beforeEach(async () => {
|
||
root = await mkdtemp(path.join(tmpdir(), 'od-byok-speech-'));
|
||
projectsRoot = path.join(root, 'projects');
|
||
});
|
||
|
||
afterEach(async () => {
|
||
globalThis.fetch = realFetch;
|
||
vi.unstubAllGlobals();
|
||
await rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
it('calls /v1/t2a_v2, persists mp3 bytes, and returns a daemon URL', async () => {
|
||
const audioBytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
|
||
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
expect(String(input)).toBe('https://api.senseaudio.cn/v1/t2a_v2');
|
||
expect(init?.method).toBe('POST');
|
||
expect(init?.dispatcher).toBe(dispatcher);
|
||
expect(init?.redirect).toBe('error');
|
||
expect(init?.headers).toMatchObject({
|
||
authorization: 'Bearer sa-byok-key',
|
||
'content-type': 'application/json',
|
||
});
|
||
expect(JSON.parse(String(init?.body))).toEqual({
|
||
model: 'senseaudio-tts-1.5-260319',
|
||
text: 'Meet saddle2 — the way work was supposed to feel.',
|
||
stream: false,
|
||
voice_setting: {
|
||
voice_id: 'female_0033_b',
|
||
speed: 1,
|
||
vol: 1,
|
||
pitch: 0,
|
||
},
|
||
audio_setting: {
|
||
format: 'mp3',
|
||
sample_rate: 32000,
|
||
bitrate: 128000,
|
||
channel: 2,
|
||
},
|
||
});
|
||
return new Response(
|
||
JSON.stringify({
|
||
data: { audio: audioBytes.toString('hex') },
|
||
base_resp: { status_code: 0, status_msg: 'success' },
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'Meet saddle2 — the way work was supposed to feel.' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
requestInit: { dispatcher },
|
||
},
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
expect(result.url).toMatch(
|
||
new RegExp(`^/api/projects/${PROJECT_ID}/files/byok-speech-[a-z0-9-]+\\.mp3$`),
|
||
);
|
||
|
||
const filename = result.url!.split('/').pop()!;
|
||
const onDisk = await readFile(path.join(projectsRoot, PROJECT_ID, filename));
|
||
expect(onDisk.equals(audioBytes)).toBe(true);
|
||
});
|
||
|
||
it('does not duplicate /v1 when the BYOK gateway base URL is already versioned', async () => {
|
||
const audioBytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
|
||
const fetchMock = vi.fn(async (input: unknown) => {
|
||
expect(String(input)).toBe('https://gateway.example.com/api/v1/openai/t2a_v2');
|
||
return new Response(
|
||
JSON.stringify({
|
||
data: { audio: audioBytes.toString('hex') },
|
||
base_resp: { status_code: 0, status_msg: 'success' },
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'hello' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://gateway.example.com/api/v1/openai',
|
||
},
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('returns { ok: false } when SenseAudio returns malformed JSON', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () =>
|
||
new Response('not json', {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/plain' },
|
||
}),
|
||
),
|
||
);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'hello' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
},
|
||
);
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/senseaudio speech non-JSON/);
|
||
});
|
||
|
||
it('returns { ok: false } when the SenseAudio request fails', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => {
|
||
throw new Error('network down');
|
||
}),
|
||
);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'hello' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
},
|
||
);
|
||
|
||
expect(result).toEqual({ ok: false, error: 'network down' });
|
||
});
|
||
|
||
it('asks fetch to reject redirected SenseAudio TTS upstreams', async () => {
|
||
const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => {
|
||
expect(init?.redirect).toBe('error');
|
||
throw new TypeError('redirect mode is set to error');
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'hello' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
},
|
||
);
|
||
|
||
expect(result).toEqual({ ok: false, error: 'redirect mode is set to error' });
|
||
});
|
||
|
||
it.each(['aaZZ', 'abc'])(
|
||
'returns { ok: false } when SenseAudio returns malformed hex audio: %s',
|
||
async (audio) => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
data: { audio },
|
||
base_resp: { status_code: 0, status_msg: 'success' },
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
),
|
||
),
|
||
);
|
||
|
||
const result = await executeGenerateSpeech(
|
||
{ text: 'hello' },
|
||
{
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
},
|
||
);
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/invalid hex audio/);
|
||
},
|
||
);
|
||
});
|
||
|
||
describe('executeGenerateVideo', () => {
|
||
let root: string;
|
||
let projectsRoot: string;
|
||
const PROJECT_ID = 'test-project';
|
||
const realFetch = globalThis.fetch;
|
||
|
||
beforeEach(async () => {
|
||
root = await mkdtemp(path.join(tmpdir(), 'od-byok-video-'));
|
||
projectsRoot = path.join(root, 'projects');
|
||
});
|
||
|
||
afterEach(async () => {
|
||
globalThis.fetch = realFetch;
|
||
vi.unstubAllGlobals();
|
||
await rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
const baseCtx = () => ({
|
||
projectRoot: root,
|
||
projectsRoot,
|
||
projectId: PROJECT_ID,
|
||
upstreamApiKey: 'sa-byok-key',
|
||
upstreamBaseUrl: 'https://api.senseaudio.cn',
|
||
// Keep tests fast — 1 ms between polls instead of the production 5 s.
|
||
videoPollIntervalMs: 1,
|
||
});
|
||
|
||
it('creates, polls until completed, downloads, and writes the mp4 into the project folder', async () => {
|
||
const mp4Bytes = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]);
|
||
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
|
||
let pollCount = 0;
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
expect(init?.dispatcher).toBe(dispatcher);
|
||
|
||
if (url === 'https://api.senseaudio.cn/v1/video/create') {
|
||
expect(init?.method).toBe('POST');
|
||
expect(init?.headers).toMatchObject({
|
||
authorization: 'Bearer sa-byok-key',
|
||
'content-type': 'application/json',
|
||
});
|
||
const body = JSON.parse(String(init?.body));
|
||
expect(body).toEqual({
|
||
model: 'doubao-seedance-2-0-260128',
|
||
content: [{ type: 'text', text: 'a sunset over the ocean' }],
|
||
duration: 8,
|
||
resolution: '1080p',
|
||
ratio: '16:9',
|
||
provider_specific: { generate_audio: true },
|
||
});
|
||
return new Response(
|
||
JSON.stringify({ task_id: 'task-abc' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
|
||
if (url.startsWith('https://api.senseaudio.cn/v1/video/status?id=task-abc')) {
|
||
pollCount++;
|
||
if (pollCount === 1) {
|
||
return new Response(
|
||
JSON.stringify({ status: 'pending', progress: 0 }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (pollCount === 2) {
|
||
return new Response(
|
||
JSON.stringify({ status: 'processing', progress: 50 }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(
|
||
JSON.stringify({
|
||
status: 'completed',
|
||
progress: 100,
|
||
video_url: 'https://cdn.example.test/video/done.mp4',
|
||
duration: 8,
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
|
||
if (url === 'https://cdn.example.test/video/done.mp4') {
|
||
return new Response(mp4Bytes, {
|
||
status: 200,
|
||
headers: { 'content-type': 'video/mp4' },
|
||
});
|
||
}
|
||
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo(
|
||
{
|
||
prompt: 'a sunset over the ocean',
|
||
aspect_ratio: '16:9',
|
||
duration: 8,
|
||
resolution: '1080p',
|
||
generate_audio: true,
|
||
},
|
||
{ ...baseCtx(), requestInit: { dispatcher } },
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
expect(result.url).toMatch(
|
||
new RegExp(`^/api/projects/${PROJECT_ID}/files/byok-video-[a-z0-9-]+\\.mp4$`),
|
||
);
|
||
|
||
// 1× create + 3× poll + 1× download = 5 fetches total.
|
||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||
expect(pollCount).toBe(3);
|
||
|
||
const filename = result.url!.split('/').pop()!;
|
||
const onDisk = await readFile(path.join(projectsRoot, PROJECT_ID, filename));
|
||
expect(onDisk.equals(mp4Bytes)).toBe(true);
|
||
});
|
||
|
||
it('defaults duration / resolution / aspect when caller omits them', async () => {
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/video/create')) {
|
||
const body = JSON.parse(String(init?.body));
|
||
expect(body).toMatchObject({
|
||
duration: 5,
|
||
resolution: '720p',
|
||
ratio: '16:9',
|
||
provider_specific: { generate_audio: false },
|
||
});
|
||
return new Response(
|
||
JSON.stringify({ task_id: 'task-defaults' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (url.startsWith('https://api.senseaudio.cn/v1/video/status')) {
|
||
return new Response(
|
||
JSON.stringify({
|
||
status: 'completed',
|
||
video_url: 'https://cdn.example.test/video/d.mp4',
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(Buffer.from([0x01]), { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo({ prompt: 'minimal' }, baseCtx());
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('clamps duration outside the 4–15 range and rejects non-enum aspect_ratio / resolution', async () => {
|
||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/video/create')) {
|
||
const body = JSON.parse(String(init?.body));
|
||
// 99 → clamped to 15; 'octagonal' → falls back to '16:9';
|
||
// '8k' → falls back to '720p'.
|
||
expect(body).toMatchObject({
|
||
duration: 15,
|
||
resolution: '720p',
|
||
ratio: '16:9',
|
||
});
|
||
return new Response(
|
||
JSON.stringify({ task_id: 'task-clamp' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (url.startsWith('https://api.senseaudio.cn/v1/video/status')) {
|
||
return new Response(
|
||
JSON.stringify({
|
||
status: 'completed',
|
||
video_url: 'https://cdn.example.test/clamp.mp4',
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
return new Response(Buffer.from([0x02]), { status: 200 });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo(
|
||
{
|
||
prompt: 'overflow',
|
||
duration: 99,
|
||
aspect_ratio: 'octagonal',
|
||
resolution: '8k',
|
||
},
|
||
baseCtx(),
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('surfaces a failed status as a tool error so the model can apologize', async () => {
|
||
const fetchMock = vi.fn(async (input: unknown) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/video/create')) {
|
||
return new Response(
|
||
JSON.stringify({ task_id: 'task-fail' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (url.startsWith('https://api.senseaudio.cn/v1/video/status')) {
|
||
return new Response(
|
||
JSON.stringify({
|
||
status: 'failed',
|
||
error_message: 'sensitive_content_blocked',
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo(
|
||
{ prompt: 'blocked content' },
|
||
baseCtx(),
|
||
);
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/senseaudio video failed/);
|
||
expect(result.error).toMatch(/sensitive_content_blocked/);
|
||
});
|
||
|
||
it('times out after SENSEAUDIO_VIDEO_MAX_POLLS polls when the job stays pending', async () => {
|
||
const fetchMock = vi.fn(async (input: unknown) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/v1/video/create')) {
|
||
return new Response(
|
||
JSON.stringify({ task_id: 'task-stuck' }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
if (url.startsWith('https://api.senseaudio.cn/v1/video/status')) {
|
||
return new Response(
|
||
JSON.stringify({ status: 'pending', progress: 0 }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo(
|
||
{ prompt: 'stuck job' },
|
||
baseCtx(),
|
||
);
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/timed out/);
|
||
// 1× create + 120× poll = 121 fetches (10-min ceiling at 5 s
|
||
// intervals — kept generous because doubao-seedance frequently
|
||
// spends 3–8 min on the gateway for 1080p+audio jobs).
|
||
expect(fetchMock).toHaveBeenCalledTimes(121);
|
||
}, 30_000);
|
||
|
||
it('returns a tool error when create response is missing task_id', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response('{"oops": true}', {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/missing task_id/);
|
||
});
|
||
|
||
it('returns a tool error when create call returns non-2xx', async () => {
|
||
const fetchMock = vi.fn(async () =>
|
||
new Response('unauthorized', {
|
||
status: 401,
|
||
headers: { 'content-type': 'text/plain' },
|
||
}),
|
||
);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo({ prompt: 'x' }, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/senseaudio video create 401/);
|
||
});
|
||
|
||
it('rejects an unsafe projectId before any upstream call', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo(
|
||
{ prompt: 'x' },
|
||
{ ...baseCtx(), projectId: '../escape' },
|
||
);
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/invalid projectId/);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects empty prompt before any upstream call', async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const result = await executeGenerateVideo({}, baseCtx());
|
||
expect(result.ok).toBe(false);
|
||
expect(result.error).toMatch(/prompt is required/);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|