From b9d4c2de064f7412a9395068e61efbee62c7bd59 Mon Sep 17 00:00:00 2001 From: Anil Matcha Date: Sun, 16 Aug 2026 15:26:44 +0530 Subject: [PATCH 1/2] feat(video): add MuAPI provider --- README.md | 10 ++- packages/cli/src/index.ts | 8 +- packages/core/src/config/config.ts | 15 +++- packages/mcp/src/index.ts | 2 +- packages/tools/src/video/video.ts | 89 ++++++++++++++++++--- packages/tools/test/gen.test.ts | 119 ++++++++++++++++++++++++++++- 6 files changed, 223 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8b83f00..5762cab 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ opt-in and uses **your** keys — no managed backend, no account binding. Config | Capability | Providers | Env | |---|---|---| | Image (`ovs image`) | OpenAI-compatible · Gemini · Doubao Seedream | `OVS_IMAGE_PROVIDER` · `OVS_IMAGE_BASE_URL` · `OVS_IMAGE_API_KEY` · `OVS_IMAGE_MODEL` | -| Video (`ovs video`) | Doubao Seedance (image-to-video) | `OVS_VIDEO_PROVIDER` · `OVS_VIDEO_BASE_URL` · `OVS_VIDEO_API_KEY` · `OVS_VIDEO_MODEL` | +| Video (`ovs video`) | Doubao Seedance · Atlas Cloud · MuAPI | `OVS_VIDEO_PROVIDER` · `OVS_VIDEO_BASE_URL` · `OVS_VIDEO_API_KEY` · `OVS_VIDEO_MODEL` | | TTS (`ovs speak`) | OpenAI-compatible (incl. ElevenLabs-style) | `OVS_TTS_BASE_URL` · `OVS_TTS_API_KEY` · `OVS_TTS_MODEL` · `OVS_TTS_VOICE` · `OVS_TTS_FORMAT` | Use `ovs speech-capabilities` to resolve the exact configured narration profile without @@ -171,6 +171,14 @@ printing credentials, then `ovs narration fit` before and after synthesis to kee inside its plan window. Video generation accepts explicit reference images, ratio, duration, resolution, and audio generation flags so the provider call matches the approved plan. +For [MuAPI](https://muapi.ai), set `video.provider` to `"muapi"` and provide `MUAPI_API_KEY` (or +use `OVS_VIDEO_API_KEY`). `video.model` / `OVS_VIDEO_MODEL` accepts a MuAPI endpoint slug; +text-to-video defaults to `kling-v2.1-master-t2v`, while a first-frame `image_url` defaults to +`kling-v2.1-standard-i2v`. See the [MuAPI API reference](https://muapi.ai/docs/api-reference) +for the submit-and-poll contract and model-specific parameters. This adapter exposes the +common prompt, aspect-ratio, duration, and first-frame inputs; resolution, audio, edit, and +additional-reference controls remain provider-specific. + --- ## How it compares diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 603afe4..ad3c2c0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -639,8 +639,8 @@ const videoCmd = defineCommand({ quality: { type: 'string', description: 'economy | balanced | quality (provider-neutral intent)' }, ratio: { type: 'string', default: '16:9', description: '16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9' }, duration: { type: 'string', default: '5', description: '4-15 seconds' }, - resolution: { type: 'string', default: '720p', description: '480p | 720p | 1080p' }, - 'generate-audio': { type: 'boolean', default: true }, + resolution: { type: 'string', description: '480p | 720p | 1080p' }, + 'generate-audio': { type: 'boolean', description: 'request provider-generated audio when supported' }, }, async run({ args }) { const referenceImageUrls = args['image-urls'] @@ -660,8 +660,8 @@ const videoCmd = defineCommand({ quality: args.quality ? String(args.quality) as 'economy' | 'balanced' | 'quality' : undefined, ratio: String(args.ratio) as '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9', duration: num(args.duration, 'duration'), - resolution: String(args.resolution) as '480p' | '720p' | '1080p', - generate_audio: args['generate-audio'] !== false, + resolution: args.resolution ? String(args.resolution) as '480p' | '720p' | '1080p' : undefined, + generate_audio: args['generate-audio'] === undefined ? undefined : args['generate-audio'] !== false, })); }, }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0e39d7f..cc1d450 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -21,7 +21,7 @@ export interface ImageProviderConfig { model?: string; } export interface VideoProviderConfig { - provider?: 'doubao' | 'atlas'; + provider?: 'doubao' | 'atlas' | 'muapi'; base_url?: string; api_key?: string; model?: string; @@ -71,11 +71,20 @@ export function loadConfig(): OvsConfig { ...(process.env.OVS_IMAGE_API_KEY ? { api_key: process.env.OVS_IMAGE_API_KEY } : {}), ...(process.env.OVS_IMAGE_MODEL ? { model: process.env.OVS_IMAGE_MODEL } : {}), }; + const configuredVideoProvider = process.env.OVS_VIDEO_PROVIDER as VideoProviderConfig['provider'] | undefined; + const fileVideoProvider = fromFile.video?.provider; + const useMuapiEnvKey = Boolean(process.env.MUAPI_API_KEY) && + (configuredVideoProvider === 'muapi' || fileVideoProvider === 'muapi' || (!configuredVideoProvider && !fileVideoProvider)); + const muapiEnvProvider = !configuredVideoProvider && !fileVideoProvider && useMuapiEnvKey ? 'muapi' as const : undefined; const video: VideoProviderConfig = { ...fromFile.video, - ...(process.env.OVS_VIDEO_PROVIDER ? { provider: process.env.OVS_VIDEO_PROVIDER as VideoProviderConfig['provider'] } : {}), + ...(configuredVideoProvider ? { provider: configuredVideoProvider } : muapiEnvProvider ? { provider: muapiEnvProvider } : {}), ...(process.env.OVS_VIDEO_BASE_URL ? { base_url: process.env.OVS_VIDEO_BASE_URL } : {}), - ...(process.env.OVS_VIDEO_API_KEY ? { api_key: process.env.OVS_VIDEO_API_KEY } : {}), + ...(process.env.OVS_VIDEO_API_KEY + ? { api_key: process.env.OVS_VIDEO_API_KEY } + : useMuapiEnvKey + ? { api_key: process.env.MUAPI_API_KEY } + : {}), ...(process.env.OVS_VIDEO_MODEL ? { model: process.env.OVS_VIDEO_MODEL } : {}), }; const out: OvsConfig = { ...fromFile }; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7ef305a..3848e8c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -254,7 +254,7 @@ server.tool( ); server.tool( 'video', - 'Generate a video clip via the configured BYO provider (Doubao Seedance), with exact Gate C settings.', + 'Generate a video clip via the configured BYO provider (Doubao Seedance, Atlas Cloud, or MuAPI), with exact Gate C settings.', { prompt: z.string(), output: z.string(), diff --git a/packages/tools/src/video/video.ts b/packages/tools/src/video/video.ts index 6b53385..a71011f 100644 --- a/packages/tools/src/video/video.ts +++ b/packages/tools/src/video/video.ts @@ -11,6 +11,9 @@ const ATLAS_DEFAULT_MODEL = 'bytedance/seedance-2.0/text-to-video'; // model's schema has no `image` field — a first frame sent to it is ignored // or rejected, never used. const ATLAS_DEFAULT_I2V_MODEL = 'bytedance/seedance-2.0/image-to-video'; +const MUAPI_DEFAULT_BASE = 'https://api.muapi.ai/api/v1'; +const MUAPI_DEFAULT_T2V_MODEL = 'kling-v2.1-master-t2v'; +const MUAPI_DEFAULT_I2V_MODEL = 'kling-v2.1-standard-i2v'; const POLL_INTERVAL_MS = 10_000; const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout — one slow poll must not fail the task const TASK_TIMEOUT_MS = 60 * 60 * 1000; @@ -48,6 +51,10 @@ function atlasBase(cfg: VideoProviderConfig): string { return (cfg.base_url ?? ATLAS_DEFAULT_BASE).replace(/\/+$/, ''); } +function muapiBase(cfg: VideoProviderConfig): string { + return (cfg.base_url ?? MUAPI_DEFAULT_BASE).replace(/\/+$/, ''); +} + /** Build an Atlas Cloud media task request (`POST {base}/model/generateVideo`). */ export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest { if (!cfg.api_key) throw new Error('video: no api_key configured'); @@ -86,6 +93,48 @@ export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams }; } +/** Build a MuAPI submit request (`POST {base}/{model-endpoint}`). */ +export function buildMuapiCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest { + if (!cfg.api_key) throw new Error('video: no api_key configured'); + if (!p.prompt.trim()) throw new Error('video: prompt is required'); + if (p.operation !== undefined && p.operation !== 'generate') { + throw new Error('video: MuAPI currently supports the generate operation'); + } + if (p.reference_image_urls?.length || p.reference_video_urls?.length) { + throw new Error('video: MuAPI currently accepts one first-frame image_url; additional references are not supported'); + } + if (p.resolution !== undefined || p.generate_audio !== undefined) { + throw new Error('video: MuAPI resolution and audio controls are model-specific and are not supported by this adapter'); + } + const duration = p.duration ?? 5; + if (!Number.isInteger(duration) || duration <= 0) { + throw new Error('video: duration must be a positive integer'); + } + const ratio = p.ratio ?? '16:9'; + if (!['16:9', '9:16', '1:1'].includes(ratio)) { + throw new Error('video: MuAPI supports 16:9, 9:16, and 1:1 aspect ratios'); + } + const model = p.model ?? cfg.model ?? (p.image_url ? MUAPI_DEFAULT_I2V_MODEL : MUAPI_DEFAULT_T2V_MODEL); + const looksLikeI2v = /(?:image-to-video|i2v)/i.test(model); + const looksLikeT2v = /(?:text-to-video|t2v)/i.test(model); + if (p.image_url && looksLikeT2v) { + throw new Error(`video: model "${model}" is text-to-video; use an image-to-video model for image_url`); + } + if (!p.image_url && looksLikeI2v) { + throw new Error(`video: model "${model}" requires a first-frame image_url`); + } + return { + url: `${muapiBase(cfg)}/${model}`, + headers: { 'x-api-key': cfg.api_key, 'content-type': 'application/json' }, + body: { + prompt: p.prompt, + aspect_ratio: ratio, + duration, + ...(p.image_url ? { image_url: p.image_url } : {}), + }, + }; +} + /** Build the Doubao Seedance task-create request (`POST {base}/contents/generations/tasks`). */ export function buildSeedanceCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest { if (!cfg.api_key) throw new Error('video: no api_key configured'); @@ -146,6 +195,14 @@ interface AtlasResp { error?: string; }; } +interface MuapiCreateResp { + request_id?: string; +} +interface MuapiPollResp { + status?: string; + outputs?: string[]; + error?: string | { message?: string }; +} const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -200,30 +257,39 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS; const provider = cfg.provider ?? 'doubao'; - const req = provider === 'atlas' ? buildAtlasCreateRequest(cfg, params) : buildSeedanceCreateRequest(cfg, params); - const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp & AtlasResp; - const id = provider === 'atlas' ? created.data?.id : created.id; + const req = provider === 'atlas' + ? buildAtlasCreateRequest(cfg, params) + : provider === 'muapi' + ? buildMuapiCreateRequest(cfg, params) + : buildSeedanceCreateRequest(cfg, params); + const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp & AtlasResp & MuapiCreateResp; + const id = provider === 'atlas' ? created.data?.id : provider === 'muapi' ? created.request_id : created.id; if (!id) throw new Error('video: task create returned no id'); - const base = provider === 'atlas' ? atlasBase(cfg) : arkBase(cfg); - const authHeaders = { authorization: `Bearer ${cfg.api_key}` }; + const base = provider === 'atlas' ? atlasBase(cfg) : provider === 'muapi' ? muapiBase(cfg) : arkBase(cfg); + const authHeaders: Record = provider === 'muapi' + ? { 'x-api-key': cfg.api_key } + : { authorization: `Bearer ${cfg.api_key}` }; const start = now(); for (;;) { if (now() - start > TASK_TIMEOUT_MS) throw new Error(`video: task ${id} timed out after ${TASK_TIMEOUT_MS}ms`); const pollUrl = provider === 'atlas' ? `${base}/model/prediction/${id}` - : `${base}/contents/generations/tasks/${id}`; - const response = (await getJson(pollUrl, authHeaders, POLL_TIMEOUT_MS)) as PollResp & AtlasResp; + : provider === 'muapi' + ? `${base}/predictions/${id}/result` + : `${base}/contents/generations/tasks/${id}`; + const response = (await getJson(pollUrl, authHeaders, POLL_TIMEOUT_MS)) as PollResp & AtlasResp & MuapiPollResp; const atlasPoll = provider === 'atlas' ? response.data : undefined; + const muapiPoll = provider === 'muapi' ? response : undefined; const doubaoPoll = provider === 'atlas' ? undefined : response; - const status = atlasPoll?.status ?? doubaoPoll?.status; + const status = atlasPoll?.status ?? muapiPoll?.status ?? doubaoPoll?.status; const succeeded = status === 'succeeded' || status === 'completed'; if (succeeded) { const atlasOutput = provider === 'atlas' ? (Array.isArray(atlasPoll?.output) ? atlasPoll.output[0] : atlasPoll?.output) ?? atlasPoll?.outputs?.[0] : undefined; - const url = provider === 'atlas' ? atlasOutput : doubaoPoll?.content?.video_url; + const url = provider === 'atlas' ? atlasOutput : provider === 'muapi' ? muapiPoll?.outputs?.[0] : doubaoPoll?.content?.video_url; if (!url) throw new Error(`video: task ${id} succeeded but returned no video_url`); const dl = await fetchWithTimeout(url, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); if (!dl.ok) throw new Error(`video download failed with HTTP ${dl.status}`); @@ -241,7 +307,10 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa return { output: resolve(params.output), bytes: buf.byteLength, task_id: id }; } if (status === 'failed' || status === 'canceled') { - throw new Error(`video: task ${id} ${status}`); + const detail = provider === 'muapi' + ? typeof muapiPoll?.error === 'string' ? muapiPoll.error : muapiPoll?.error?.message + : undefined; + throw new Error(`video: task ${id} ${status}${detail ? `: ${detail}` : ''}`); } await sleep(interval); } diff --git a/packages/tools/test/gen.test.ts b/packages/tools/test/gen.test.ts index 70caa75..ab2bec4 100644 --- a/packages/tools/test/gen.test.ts +++ b/packages/tools/test/gen.test.ts @@ -13,7 +13,7 @@ import { compileImagePromptContract, normalizeImageReferenceBindings, } from '../src/image/image'; -import { generateVideo, buildAtlasCreateRequest, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video'; +import { generateVideo, buildAtlasCreateRequest, buildMuapiCreateRequest, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video'; const VALID_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', @@ -118,6 +118,29 @@ describe('request builders', () => { ]); }); + it('builds MuAPI text-to-video and image-to-video requests with endpoint overrides', () => { + const t2v = buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key' }, + { prompt: 'a dog running', output: 'out.mp4' }, + ); + expect(t2v.url).toBe('https://api.muapi.ai/api/v1/kling-v2.1-master-t2v'); + expect(t2v.headers['x-api-key']).toBe('mu-key'); + expect(t2v.body).toMatchObject({ prompt: 'a dog running', aspect_ratio: '16:9', duration: 5 }); + expect(t2v.body).not.toHaveProperty('image_url'); + + const i2v = buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key', base_url: 'https://example.test/api/v1', model: 'custom-i2v' }, + { prompt: 'gentle camera movement', output: 'out.mp4', image_url: 'https://example.test/frame.png', ratio: '9:16', duration: 8 }, + ); + expect(i2v.url).toBe('https://example.test/api/v1/custom-i2v'); + expect(i2v.body).toMatchObject({ prompt: 'gentle camera movement', aspect_ratio: '9:16', duration: 8, image_url: 'https://example.test/frame.png' }); + + expect(() => buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key', model: 'custom-t2v' }, + { prompt: 'animate this', output: 'out.mp4', image_url: 'https://example.test/frame.png' }, + )).toThrow(/text-to-video/); + }); + it('builds video edit requests with bounded source-video references', () => { const request = buildSeedanceCreateRequest( { api_key: 'k' }, @@ -394,6 +417,74 @@ describe('generateVideo (Atlas Cloud task + poll)', () => { }); }); +describe('generateVideo (MuAPI task + poll)', () => { + it('creates a task, polls until completed, and downloads the result', async () => { + let polls = 0; + const srv = await startServer((req, res) => { + const url = req.url ?? ''; + if (req.method === 'POST' && url === '/custom-t2v') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ request_id: 'mu-1', status: 'processing' })); + } else if (req.method === 'GET' && url === '/predictions/mu-1/result') { + polls += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(polls < 2 ? { status: 'processing' } : { status: 'completed', outputs: [`${srv.baseUrl}/mu.mp4`] })); + } else if (req.method === 'GET' && url === '/mu.mp4') { + res.writeHead(200, { 'content-type': 'video/mp4' }); + res.end(VALID_MP4); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + const out = join(dir, 'muapi.mp4'); + const result = await generateVideo( + { prompt: 'a dog running', output: out, ratio: '9:16', duration: 8 }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'custom-t2v' } }, + { pollIntervalMs: 1 }, + ); + expect(result.task_id).toBe('mu-1'); + expect(polls).toBe(2); + expect(readFileSync(result.output)).toEqual(VALID_MP4); + const create = srv.requests.find((x) => x.method === 'POST')!; + expect(create.headers['x-api-key']).toBe('mu-key'); + expect(create.headers.authorization).toBeUndefined(); + expect(JSON.parse(create.body)).toMatchObject({ prompt: 'a dog running', aspect_ratio: '9:16', duration: 8 }); + } finally { + await srv.close(); + } + }); + + it('surfaces a MuAPI task failure without writing an output', async () => { + const srv = await startServer((req, res) => { + if (req.method === 'POST') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ request_id: 'mu-2' })); + } else if (req.url === '/predictions/mu-2/result') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'failed', error: 'content policy' })); + } else { + res.writeHead(404); + res.end(); + } + }); + const out = join(dir, 'muapi-failed.mp4'); + try { + await expect( + generateVideo( + { prompt: 'unsafe', output: out }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'custom-t2v' } }, + { pollIntervalMs: 1 }, + ), + ).rejects.toThrow(/task mu-2 failed: content policy/); + expect(existsSync(out)).toBe(false); + } finally { + await srv.close(); + } + }); +}); + // --- config env overlay ---------------------------------------------------- describe('config env overlay', () => { @@ -415,4 +506,30 @@ describe('config env overlay', () => { } } }); + + it('selects MuAPI from MUAPI_API_KEY when no video provider is otherwise configured', () => { + const prev = { ...process.env }; + process.env.OVS_CONFIG_DIR = dir; + delete process.env.OVS_VIDEO_PROVIDER; + delete process.env.OVS_VIDEO_API_KEY; + process.env.MUAPI_API_KEY = 'mu-key'; + try { + const c = loadConfig(); + expect(c.video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); + + const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); + try { + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ video: { provider: 'muapi' } })); + process.env.OVS_CONFIG_DIR = configDir; + expect(loadConfig().video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + } finally { + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'MUAPI_API_KEY']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } + }); }); From ddf12cdb8e30ff94f5ec1babea8833bd57062c57 Mon Sep 17 00:00:00 2001 From: Anil-matcha Date: Sat, 29 Aug 2026 10:52:03 +0530 Subject: [PATCH 2/2] fix(video): address MuAPI provider review findings --- README.md | 22 +++- packages/core/src/config/config.ts | 39 ++++-- packages/core/src/runtime/fetch.ts | 32 ++++- packages/tools/src/video/video.ts | 199 +++++++++++++++++++++-------- packages/tools/test/gen.test.ts | 196 +++++++++++++++++++++++++--- 5 files changed, 391 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 5762cab..c25509c 100644 --- a/README.md +++ b/README.md @@ -171,13 +171,21 @@ printing credentials, then `ovs narration fit` before and after synthesis to kee inside its plan window. Video generation accepts explicit reference images, ratio, duration, resolution, and audio generation flags so the provider call matches the approved plan. -For [MuAPI](https://muapi.ai), set `video.provider` to `"muapi"` and provide `MUAPI_API_KEY` (or -use `OVS_VIDEO_API_KEY`). `video.model` / `OVS_VIDEO_MODEL` accepts a MuAPI endpoint slug; -text-to-video defaults to `kling-v2.1-master-t2v`, while a first-frame `image_url` defaults to -`kling-v2.1-standard-i2v`. See the [MuAPI API reference](https://muapi.ai/docs/api-reference) -for the submit-and-poll contract and model-specific parameters. This adapter exposes the -common prompt, aspect-ratio, duration, and first-frame inputs; resolution, audio, edit, and -additional-reference controls remain provider-specific. +For [MuAPI](https://muapi.ai), explicitly set `video.provider` to `"muapi"` and provide +`MUAPI_API_KEY` (or use `OVS_VIDEO_API_KEY`). `MUAPI_API_KEY` takes precedence over the generic +key when MuAPI is selected, and never selects MuAPI by itself. `video.model` / +`OVS_VIDEO_MODEL` currently supports these validated Kling v2.1 endpoint slugs: +`kling-v2.1-master-t2v`, `kling-v2.1-master-i2v`, `kling-v2.1-standard-i2v`, and +`kling-v2.1-pro-i2v`; unsupported slugs are rejected rather than sent with a mismatched body. +Text-to-video defaults to `kling-v2.1-master-t2v`, while a first-frame `image_url` defaults to +`kling-v2.1-master-i2v`. The default base URL includes `/api/v1`; custom MuAPI base URLs must +include that path. See the [MuAPI API reference](https://muapi.ai/docs/api-reference) for the +submit-and-poll contract and model-specific parameters. This adapter accepts the common +prompt, aspect-ratio, duration, and first-frame inputs; for the supported Kling endpoints, +duration is 5 or 10 seconds and ratio is `16:9`, `9:16`, or `1:1`. Provider-neutral `resolution`, +`generate_audio`, and `quality` inputs remain compatible with signed plans; resolution and audio +are ignored by Kling, while quality is validated but not sent. Edit and additional-reference +controls are rejected until a matching MuAPI schema is supported. --- diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cc1d450..eeeee6b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -32,6 +32,22 @@ export interface OvsConfig { video?: VideoProviderConfig; } +const VIDEO_PROVIDERS = ['doubao', 'atlas', 'muapi'] as const; +type VideoProvider = (typeof VIDEO_PROVIDERS)[number]; + +function normalizeVideoProvider(value: unknown): VideoProvider | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') { + throw new Error('video.provider must be doubao, atlas, or muapi'); + } + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + if (!(VIDEO_PROVIDERS as readonly string[]).includes(normalized)) { + throw new Error(`Unsupported video provider "${value}". Expected doubao, atlas, or muapi.`); + } + return normalized as VideoProvider; +} + /** Config file location: $OVS_CONFIG_DIR/config.json, else ~/.config/orkas-video-studio/config.json */ export function configPath(): string { const dir = @@ -71,20 +87,21 @@ export function loadConfig(): OvsConfig { ...(process.env.OVS_IMAGE_API_KEY ? { api_key: process.env.OVS_IMAGE_API_KEY } : {}), ...(process.env.OVS_IMAGE_MODEL ? { model: process.env.OVS_IMAGE_MODEL } : {}), }; - const configuredVideoProvider = process.env.OVS_VIDEO_PROVIDER as VideoProviderConfig['provider'] | undefined; - const fileVideoProvider = fromFile.video?.provider; - const useMuapiEnvKey = Boolean(process.env.MUAPI_API_KEY) && - (configuredVideoProvider === 'muapi' || fileVideoProvider === 'muapi' || (!configuredVideoProvider && !fileVideoProvider)); - const muapiEnvProvider = !configuredVideoProvider && !fileVideoProvider && useMuapiEnvKey ? 'muapi' as const : undefined; + const configuredVideoProvider = normalizeVideoProvider(process.env.OVS_VIDEO_PROVIDER); + const fileVideoProvider = normalizeVideoProvider(fromFile.video?.provider); + const effectiveVideoProvider = configuredVideoProvider ?? fileVideoProvider; + // MUAPI_API_KEY is intentionally opt-in: an unrelated key in the shell must + // never change a provider-less config into a billable MuAPI request. + // When MuAPI is explicitly selected, its vendor-specific key wins over the + // generic key so a stale OVS_VIDEO_API_KEY cannot silently cause a 401. + const videoApiKey = effectiveVideoProvider === 'muapi' + ? process.env.MUAPI_API_KEY || process.env.OVS_VIDEO_API_KEY + : process.env.OVS_VIDEO_API_KEY; const video: VideoProviderConfig = { ...fromFile.video, - ...(configuredVideoProvider ? { provider: configuredVideoProvider } : muapiEnvProvider ? { provider: muapiEnvProvider } : {}), + ...(effectiveVideoProvider ? { provider: effectiveVideoProvider } : {}), ...(process.env.OVS_VIDEO_BASE_URL ? { base_url: process.env.OVS_VIDEO_BASE_URL } : {}), - ...(process.env.OVS_VIDEO_API_KEY - ? { api_key: process.env.OVS_VIDEO_API_KEY } - : useMuapiEnvKey - ? { api_key: process.env.MUAPI_API_KEY } - : {}), + ...(videoApiKey ? { api_key: videoApiKey } : {}), ...(process.env.OVS_VIDEO_MODEL ? { model: process.env.OVS_VIDEO_MODEL } : {}), }; const out: OvsConfig = { ...fromFile }; diff --git a/packages/core/src/runtime/fetch.ts b/packages/core/src/runtime/fetch.ts index 16eb9a1..1fca152 100644 --- a/packages/core/src/runtime/fetch.ts +++ b/packages/core/src/runtime/fetch.ts @@ -22,6 +22,28 @@ export async function fetchWithTimeout(url: string, init: RequestInit & { timeou } } +function errorMessage(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + for (const key of ['message', 'error', 'detail', 'details', 'code']) { + const message = errorMessage(record[key]); + if (message) return message; + } + return undefined; +} + +/** Keep provider failures actionable without echoing headers, URLs, or secrets. */ +function providerErrorDetail(body: string): string | undefined { + let detail: string | undefined; + try { + detail = errorMessage(JSON.parse(body)); + } catch { + detail = body.replace(/\s+/g, ' ').trim() || undefined; + } + return detail?.slice(0, 500); +} + /** POST JSON and parse a JSON response, throwing a legible error on non-2xx. */ export async function postJson(url: string, body: unknown, headers: Record, timeoutMs = 60_000): Promise { const res = await fetchWithTimeout(url, { @@ -31,7 +53,10 @@ export async function postJson(url: string, body: unknown, headers: Record, timeoutMs = 30_000): Promise { const res = await fetchWithTimeout(url, { method: 'GET', headers, timeoutMs }); const text = await res.text(); - if (!res.ok) throw new Error(`provider request failed with HTTP ${res.status}`); + if (!res.ok) { + const detail = providerErrorDetail(text); + throw new Error(`provider request failed with HTTP ${res.status}${detail ? `: ${detail}` : ''}`); + } try { return JSON.parse(text); } catch { diff --git a/packages/tools/src/video/video.ts b/packages/tools/src/video/video.ts index a71011f..d984920 100644 --- a/packages/tools/src/video/video.ts +++ b/packages/tools/src/video/video.ts @@ -13,7 +13,16 @@ const ATLAS_DEFAULT_MODEL = 'bytedance/seedance-2.0/text-to-video'; const ATLAS_DEFAULT_I2V_MODEL = 'bytedance/seedance-2.0/image-to-video'; const MUAPI_DEFAULT_BASE = 'https://api.muapi.ai/api/v1'; const MUAPI_DEFAULT_T2V_MODEL = 'kling-v2.1-master-t2v'; -const MUAPI_DEFAULT_I2V_MODEL = 'kling-v2.1-standard-i2v'; +const MUAPI_DEFAULT_I2V_MODEL = 'kling-v2.1-master-i2v'; +const MUAPI_MODEL_KINDS = { + 'kling-v2.1-master-t2v': 't2v', + 'kling-v2.1-master-i2v': 'i2v', + 'kling-v2.1-standard-i2v': 'i2v', + 'kling-v2.1-pro-i2v': 'i2v', +} as const; +const MUAPI_SUPPORTED_MODELS = Object.keys(MUAPI_MODEL_KINDS).join(', '); +const MUAPI_SUPPORTED_RATIOS = ['16:9', '9:16', '1:1'] as const; +const MUAPI_SUPPORTED_DURATIONS = [5, 10] as const; const POLL_INTERVAL_MS = 10_000; const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout — one slow poll must not fail the task const TASK_TIMEOUT_MS = 60 * 60 * 1000; @@ -103,25 +112,33 @@ export function buildMuapiCreateRequest(cfg: VideoProviderConfig, p: VideoParams if (p.reference_image_urls?.length || p.reference_video_urls?.length) { throw new Error('video: MuAPI currently accepts one first-frame image_url; additional references are not supported'); } - if (p.resolution !== undefined || p.generate_audio !== undefined) { - throw new Error('video: MuAPI resolution and audio controls are model-specific and are not supported by this adapter'); + if (p.quality !== undefined && !['economy', 'balanced', 'quality'].includes(p.quality)) { + throw new Error('video: quality must be economy, balanced, or quality'); } + // Keep accepting provider-neutral plan fields so the sanctioned Gate-C flow + // can pass them through. Kling v2.1 does not expose either control, so they + // are deliberately omitted from the request rather than misrepresented. const duration = p.duration ?? 5; - if (!Number.isInteger(duration) || duration <= 0) { - throw new Error('video: duration must be a positive integer'); + const model = p.model ?? cfg.model ?? (p.image_url ? MUAPI_DEFAULT_I2V_MODEL : MUAPI_DEFAULT_T2V_MODEL); + if (!/^[A-Za-z0-9._-]+$/.test(model)) { + throw new Error('video: MuAPI model must be a simple endpoint slug (letters, numbers, dots, underscores, and hyphens)'); } - const ratio = p.ratio ?? '16:9'; - if (!['16:9', '9:16', '1:1'].includes(ratio)) { - throw new Error('video: MuAPI supports 16:9, 9:16, and 1:1 aspect ratios'); + const modelKind = MUAPI_MODEL_KINDS[model as keyof typeof MUAPI_MODEL_KINDS]; + if (!modelKind) { + throw new Error(`video: unsupported MuAPI model "${model}"; supported endpoint slugs: ${MUAPI_SUPPORTED_MODELS}`); } - const model = p.model ?? cfg.model ?? (p.image_url ? MUAPI_DEFAULT_I2V_MODEL : MUAPI_DEFAULT_T2V_MODEL); - const looksLikeI2v = /(?:image-to-video|i2v)/i.test(model); - const looksLikeT2v = /(?:text-to-video|t2v)/i.test(model); - if (p.image_url && looksLikeT2v) { + if (modelKind === 'i2v' && !p.image_url) { + throw new Error(`video: model "${model}" requires a first-frame image_url`); + } + if (modelKind === 't2v' && p.image_url) { throw new Error(`video: model "${model}" is text-to-video; use an image-to-video model for image_url`); } - if (!p.image_url && looksLikeI2v) { - throw new Error(`video: model "${model}" requires a first-frame image_url`); + if (!Number.isInteger(duration) || !(MUAPI_SUPPORTED_DURATIONS as readonly number[]).includes(duration)) { + throw new Error(`video: MuAPI model "${model}" supports durations 5 or 10 seconds`); + } + const ratio = p.ratio ?? '16:9'; + if (!(MUAPI_SUPPORTED_RATIOS as readonly string[]).includes(ratio)) { + throw new Error(`video: MuAPI model "${model}" supports 16:9, 9:16, and 1:1 aspect ratios`); } return { url: `${muapiBase(cfg)}/${model}`, @@ -182,7 +199,7 @@ interface CreateResp { } interface PollResp { status?: string; - content?: { video_url?: string }; + content?: { video_url?: unknown }; error?: { message?: string }; } interface AtlasResp { @@ -190,9 +207,9 @@ interface AtlasResp { data?: { id?: string; status?: string; - outputs?: string[]; - output?: string | string[]; - error?: string; + outputs?: unknown; + output?: unknown; + error?: unknown; }; } interface MuapiCreateResp { @@ -200,8 +217,96 @@ interface MuapiCreateResp { } interface MuapiPollResp { status?: string; - outputs?: string[]; - error?: string | { message?: string }; + outputs?: unknown; + error?: unknown; +} + +interface ProviderPollState { + status?: string; + outputUrl?: string; + error?: string; +} + +type VideoProvider = 'doubao' | 'atlas' | 'muapi'; + +interface VideoProviderAdapter { + buildRequest: (cfg: VideoProviderConfig, params: VideoParams) => ProviderRequest; + taskId: (response: unknown) => string | undefined; + base: (cfg: VideoProviderConfig) => string; + pollUrl: (base: string, id: string) => string; + authHeaders: (cfg: VideoProviderConfig) => Record; + parsePoll: (response: unknown) => ProviderPollState; +} + +function firstString(value: unknown): string | undefined { + if (typeof value === 'string' && value.length > 0) return value; + if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0); + return undefined; +} + +function errorMessage(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + return typeof record.message === 'string' ? record.message : undefined; +} + +function bearerHeaders(cfg: VideoProviderConfig): Record { + return { authorization: `Bearer ${cfg.api_key}` }; +} + +const VIDEO_PROVIDER_ADAPTERS: Record = { + doubao: { + buildRequest: buildSeedanceCreateRequest, + taskId: (response) => (response as CreateResp).id, + base: arkBase, + pollUrl: (base, id) => `${base}/contents/generations/tasks/${id}`, + authHeaders: bearerHeaders, + parsePoll: (response) => { + const poll = response as PollResp; + return { + status: poll.status, + outputUrl: firstString(poll.content?.video_url), + error: errorMessage(poll.error), + }; + }, + }, + atlas: { + buildRequest: buildAtlasCreateRequest, + taskId: (response) => (response as AtlasResp).data?.id, + base: atlasBase, + pollUrl: (base, id) => `${base}/model/prediction/${id}`, + authHeaders: bearerHeaders, + parsePoll: (response) => { + const data = (response as AtlasResp).data; + return { + status: data?.status, + outputUrl: firstString(data?.output) ?? firstString(data?.outputs), + error: errorMessage(data?.error), + }; + }, + }, + muapi: { + buildRequest: buildMuapiCreateRequest, + taskId: (response) => (response as MuapiCreateResp).request_id, + base: muapiBase, + pollUrl: (base, id) => `${base}/predictions/${id}/result`, + authHeaders: (cfg) => ({ 'x-api-key': cfg.api_key! }), + parsePoll: (response) => { + const poll = response as MuapiPollResp; + return { + status: poll.status, + outputUrl: firstString(poll.outputs), + error: errorMessage(poll.error), + }; + }, + }, +}; + +function resolveVideoProvider(provider: VideoProviderConfig['provider']): VideoProvider { + if (provider === undefined) return 'doubao'; + if (provider === 'doubao' || provider === 'atlas' || provider === 'muapi') return provider; + throw new Error(`video: unsupported provider "${String(provider)}"; expected doubao, atlas, or muapi`); } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -244,53 +349,38 @@ export function validateDownloadedVideo(buffer: Buffer): void { } /** - * Generate a video with the configured BYO provider (Doubao or Atlas Cloud): create an - * async task, poll until it succeeds, then download the result. Text-to-video by - * default; pass a PUBLIC `image_url` for image-to-video. + * Generate a video with the configured BYO provider: create an async task, poll until it + * succeeds, then download the result. Text-to-video by default; pass a PUBLIC `image_url` + * for image-to-video. */ export async function generateVideo(params: VideoParams, config: OvsConfig = loadConfig(), opts: GenerateVideoOpts = {}): Promise { const cfg = config.video; if (!cfg?.api_key) { - throw new Error('No video provider configured. Set video.api_key (provider=doubao) in config, or OVS_VIDEO_* env vars.'); + throw new Error('No video provider configured. Set video.provider and video.api_key (doubao, atlas, or muapi), or use OVS_VIDEO_API_KEY; MuAPI also supports MUAPI_API_KEY.'); } const now = opts.now ?? Date.now; const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS; - const provider = cfg.provider ?? 'doubao'; - const req = provider === 'atlas' - ? buildAtlasCreateRequest(cfg, params) - : provider === 'muapi' - ? buildMuapiCreateRequest(cfg, params) - : buildSeedanceCreateRequest(cfg, params); - const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp & AtlasResp & MuapiCreateResp; - const id = provider === 'atlas' ? created.data?.id : provider === 'muapi' ? created.request_id : created.id; + const provider = resolveVideoProvider(cfg.provider); + const adapter = VIDEO_PROVIDER_ADAPTERS[provider]; + const req = adapter.buildRequest(cfg, params); + const created = await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS); + const id = adapter.taskId(created); if (!id) throw new Error('video: task create returned no id'); - const base = provider === 'atlas' ? atlasBase(cfg) : provider === 'muapi' ? muapiBase(cfg) : arkBase(cfg); - const authHeaders: Record = provider === 'muapi' - ? { 'x-api-key': cfg.api_key } - : { authorization: `Bearer ${cfg.api_key}` }; + const base = adapter.base(cfg); + const authHeaders = adapter.authHeaders(cfg); const start = now(); for (;;) { if (now() - start > TASK_TIMEOUT_MS) throw new Error(`video: task ${id} timed out after ${TASK_TIMEOUT_MS}ms`); - const pollUrl = provider === 'atlas' - ? `${base}/model/prediction/${id}` - : provider === 'muapi' - ? `${base}/predictions/${id}/result` - : `${base}/contents/generations/tasks/${id}`; - const response = (await getJson(pollUrl, authHeaders, POLL_TIMEOUT_MS)) as PollResp & AtlasResp & MuapiPollResp; - const atlasPoll = provider === 'atlas' ? response.data : undefined; - const muapiPoll = provider === 'muapi' ? response : undefined; - const doubaoPoll = provider === 'atlas' ? undefined : response; - const status = atlasPoll?.status ?? muapiPoll?.status ?? doubaoPoll?.status; + const response = await getJson(adapter.pollUrl(base, id), authHeaders, POLL_TIMEOUT_MS); + const poll = adapter.parsePoll(response); + const status = poll.status; const succeeded = status === 'succeeded' || status === 'completed'; if (succeeded) { - const atlasOutput = provider === 'atlas' - ? (Array.isArray(atlasPoll?.output) ? atlasPoll.output[0] : atlasPoll?.output) ?? atlasPoll?.outputs?.[0] - : undefined; - const url = provider === 'atlas' ? atlasOutput : provider === 'muapi' ? muapiPoll?.outputs?.[0] : doubaoPoll?.content?.video_url; - if (!url) throw new Error(`video: task ${id} succeeded but returned no video_url`); + if (!poll.outputUrl) throw new Error(`video: task ${id} succeeded but returned no usable video_url`); + const url = poll.outputUrl; const dl = await fetchWithTimeout(url, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); if (!dl.ok) throw new Error(`video download failed with HTTP ${dl.status}`); const buf = Buffer.from(await dl.arrayBuffer()); @@ -306,11 +396,8 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa } return { output: resolve(params.output), bytes: buf.byteLength, task_id: id }; } - if (status === 'failed' || status === 'canceled') { - const detail = provider === 'muapi' - ? typeof muapiPoll?.error === 'string' ? muapiPoll.error : muapiPoll?.error?.message - : undefined; - throw new Error(`video: task ${id} ${status}${detail ? `: ${detail}` : ''}`); + if (status === 'failed' || status === 'canceled' || status === 'cancelled') { + throw new Error(`video: task ${id} ${status}${poll.error ? `: ${poll.error}` : ''}`); } await sleep(interval); } diff --git a/packages/tools/test/gen.test.ts b/packages/tools/test/gen.test.ts index ab2bec4..fb0c05b 100644 --- a/packages/tools/test/gen.test.ts +++ b/packages/tools/test/gen.test.ts @@ -129,16 +129,40 @@ describe('request builders', () => { expect(t2v.body).not.toHaveProperty('image_url'); const i2v = buildMuapiCreateRequest( - { provider: 'muapi', api_key: 'mu-key', base_url: 'https://example.test/api/v1', model: 'custom-i2v' }, - { prompt: 'gentle camera movement', output: 'out.mp4', image_url: 'https://example.test/frame.png', ratio: '9:16', duration: 8 }, + { provider: 'muapi', api_key: 'mu-key', base_url: 'https://example.test/api/v1', model: 'kling-v2.1-master-i2v' }, + { + prompt: 'gentle camera movement', + output: 'out.mp4', + image_url: 'https://example.test/frame.png', + ratio: '9:16', + duration: 10, + resolution: '1080p', + generate_audio: false, + quality: 'balanced', + }, ); - expect(i2v.url).toBe('https://example.test/api/v1/custom-i2v'); - expect(i2v.body).toMatchObject({ prompt: 'gentle camera movement', aspect_ratio: '9:16', duration: 8, image_url: 'https://example.test/frame.png' }); + expect(i2v.url).toBe('https://example.test/api/v1/kling-v2.1-master-i2v'); + expect(i2v.body).toMatchObject({ prompt: 'gentle camera movement', aspect_ratio: '9:16', duration: 10, image_url: 'https://example.test/frame.png' }); + expect(i2v.body).not.toHaveProperty('resolution'); + expect(i2v.body).not.toHaveProperty('generate_audio'); + expect(i2v.body).not.toHaveProperty('quality'); expect(() => buildMuapiCreateRequest( - { provider: 'muapi', api_key: 'mu-key', model: 'custom-t2v' }, + { provider: 'muapi', api_key: 'mu-key', model: 'kling-v2.1-master-t2v' }, { prompt: 'animate this', output: 'out.mp4', image_url: 'https://example.test/frame.png' }, )).toThrow(/text-to-video/); + expect(() => buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key', model: 'https://api.muapi.ai/api/v1/kling-v2.1-master-t2v' }, + { prompt: 'bad model', output: 'out.mp4' }, + )).toThrow(/simple endpoint slug/); + expect(() => buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key', model: 'seedance-2-image-to-video' }, + { prompt: 'unsupported shape', output: 'out.mp4', image_url: 'https://example.test/frame.png' }, + )).toThrow(/unsupported MuAPI model/); + expect(() => buildMuapiCreateRequest( + { provider: 'muapi', api_key: 'mu-key' }, + { prompt: 'bad duration', output: 'out.mp4', duration: 8 }, + )).toThrow(/supports durations 5 or 10/); }); it('builds video edit requests with bounded source-video references', () => { @@ -422,7 +446,7 @@ describe('generateVideo (MuAPI task + poll)', () => { let polls = 0; const srv = await startServer((req, res) => { const url = req.url ?? ''; - if (req.method === 'POST' && url === '/custom-t2v') { + if (req.method === 'POST' && url === '/kling-v2.1-master-t2v') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ request_id: 'mu-1', status: 'processing' })); } else if (req.method === 'GET' && url === '/predictions/mu-1/result') { @@ -440,8 +464,8 @@ describe('generateVideo (MuAPI task + poll)', () => { try { const out = join(dir, 'muapi.mp4'); const result = await generateVideo( - { prompt: 'a dog running', output: out, ratio: '9:16', duration: 8 }, - { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'custom-t2v' } }, + { prompt: 'a dog running', output: out, ratio: '9:16', duration: 10 }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, { pollIntervalMs: 1 }, ); expect(result.task_id).toBe('mu-1'); @@ -450,7 +474,7 @@ describe('generateVideo (MuAPI task + poll)', () => { const create = srv.requests.find((x) => x.method === 'POST')!; expect(create.headers['x-api-key']).toBe('mu-key'); expect(create.headers.authorization).toBeUndefined(); - expect(JSON.parse(create.body)).toMatchObject({ prompt: 'a dog running', aspect_ratio: '9:16', duration: 8 }); + expect(JSON.parse(create.body)).toMatchObject({ prompt: 'a dog running', aspect_ratio: '9:16', duration: 10 }); } finally { await srv.close(); } @@ -474,7 +498,7 @@ describe('generateVideo (MuAPI task + poll)', () => { await expect( generateVideo( { prompt: 'unsafe', output: out }, - { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'custom-t2v' } }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, { pollIntervalMs: 1 }, ), ).rejects.toThrow(/task mu-2 failed: content policy/); @@ -483,6 +507,84 @@ describe('generateVideo (MuAPI task + poll)', () => { await srv.close(); } }); + + it('treats MuAPI cancellation as terminal and preserves its reason', async () => { + let polls = 0; + const srv = await startServer((req, res) => { + if (req.method === 'POST') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ request_id: 'mu-cancel' })); + } else if (req.url === '/predictions/mu-cancel/result') { + polls += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'cancelled', error: { message: 'user stopped the job' } })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect( + generateVideo( + { prompt: 'stop', output: join(dir, 'muapi-cancelled.mp4') }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, + { pollIntervalMs: 1 }, + ), + ).rejects.toThrow(/task mu-cancel cancelled: user stopped the job/); + expect(polls).toBe(1); + } finally { + await srv.close(); + } + }); + + it('rejects a non-string MuAPI output without attempting a download', async () => { + const srv = await startServer((req, res) => { + if (req.method === 'POST') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ request_id: 'mu-object-output' })); + } else if (req.url === '/predictions/mu-object-output/result') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'completed', outputs: [{ url: `${srv.baseUrl}/mu.mp4` }] })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect( + generateVideo( + { prompt: 'bad output', output: join(dir, 'muapi-object-output.mp4') }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, + { pollIntervalMs: 1 }, + ), + ).rejects.toThrow(/succeeded but returned no usable video_url/); + } finally { + await srv.close(); + } + }); + + it('includes a safe provider error detail for a rejected MuAPI request', async () => { + const srv = await startServer((req, res) => { + if (req.method === 'POST') { + res.writeHead(422, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'duration must be 5 or 10' }, api_key: 'must not be shown' })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect( + generateVideo( + { prompt: 'rejected', output: join(dir, 'muapi-rejected.mp4') }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, + { pollIntervalMs: 1 }, + ), + ).rejects.toThrow(/HTTP 422: duration must be 5 or 10/); + } finally { + await srv.close(); + } + }); }); // --- config env overlay ---------------------------------------------------- @@ -507,26 +609,78 @@ describe('config env overlay', () => { } }); - it('selects MuAPI from MUAPI_API_KEY when no video provider is otherwise configured', () => { + it('does not select MuAPI from MUAPI_API_KEY without an explicit provider', () => { const prev = { ...process.env }; process.env.OVS_CONFIG_DIR = dir; delete process.env.OVS_VIDEO_PROVIDER; delete process.env.OVS_VIDEO_API_KEY; + delete process.env.OVS_VIDEO_BASE_URL; + delete process.env.OVS_VIDEO_MODEL; process.env.MUAPI_API_KEY = 'mu-key'; try { const c = loadConfig(); - expect(c.video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); - - const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); - try { - writeFileSync(join(configDir, 'config.json'), JSON.stringify({ video: { provider: 'muapi' } })); - process.env.OVS_CONFIG_DIR = configDir; - expect(loadConfig().video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); - } finally { - rmSync(configDir, { recursive: true, force: true }); + expect(c.video?.provider).toBeUndefined(); + expect(c.video?.api_key).toBeUndefined(); + } finally { + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'OVS_VIDEO_BASE_URL', 'OVS_VIDEO_MODEL', 'MUAPI_API_KEY']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; } + } + }); + + it('normalizes the provider and gives the explicit MuAPI key precedence', () => { + const prev = { ...process.env }; + const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); + process.env.OVS_CONFIG_DIR = configDir; + process.env.OVS_VIDEO_PROVIDER = 'MuAPI'; + process.env.OVS_VIDEO_API_KEY = 'generic-key'; + process.env.MUAPI_API_KEY = 'mu-key'; + try { + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ video: { provider: 'muapi', api_key: 'file-key' } })); + expect(loadConfig().video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'OVS_VIDEO_BASE_URL', 'OVS_VIDEO_MODEL', 'MUAPI_API_KEY']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } + }); + + it('does not apply MUAPI_API_KEY when the environment selects another provider', () => { + const prev = { ...process.env }; + const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); + process.env.OVS_CONFIG_DIR = configDir; + process.env.OVS_VIDEO_PROVIDER = 'atlas'; + delete process.env.OVS_VIDEO_API_KEY; + process.env.MUAPI_API_KEY = 'mu-key'; + try { + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ video: { provider: 'muapi', api_key: 'file-key' } })); + expect(loadConfig().video).toMatchObject({ provider: 'atlas', api_key: 'file-key' }); + expect(loadConfig().video?.api_key).not.toBe('mu-key'); + } finally { + rmSync(configDir, { recursive: true, force: true }); + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'OVS_VIDEO_BASE_URL', 'OVS_VIDEO_MODEL', 'MUAPI_API_KEY']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } + }); + + it('loads an explicitly selected MuAPI key from a config file', () => { + const prev = { ...process.env }; + const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); + process.env.OVS_CONFIG_DIR = configDir; + delete process.env.OVS_VIDEO_PROVIDER; + delete process.env.OVS_VIDEO_API_KEY; + process.env.MUAPI_API_KEY = 'mu-key'; + try { + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ video: { provider: 'muapi' } })); + expect(loadConfig().video).toMatchObject({ provider: 'muapi', api_key: 'mu-key' }); } finally { - for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'MUAPI_API_KEY']) { + rmSync(configDir, { recursive: true, force: true }); + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'OVS_VIDEO_BASE_URL', 'OVS_VIDEO_MODEL', 'MUAPI_API_KEY']) { if (prev[k] === undefined) delete process.env[k]; else process.env[k] = prev[k]; }