From c2cb171d1316c241bd303fac3f4faec589b8ff54 Mon Sep 17 00:00:00 2001 From: celeste1900 <253458269+celeste1900@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:22:29 +0800 Subject: [PATCH 1/2] feat(providers): add ofox.ai as LLM preset and video generation provider Add OFox (https://ofox.ai), a multi-model gateway, as: - an OpenAI-compatible LLM preset (mirrors the openrouter entry; keystore whitelist, vendor icon monogram, vision label, verify assertions) - a video generation provider (async task id + polling, same shape as grok-imagine-video, implemented in its own server/plugins module): text-to-video, first-frame / first-and-last-frame image-to-video, and up to 9 image references (frames and references are mutually exclusive at the API level, enforced before submission); generateAudio and seed pass through. Local project media rides as base64 data URLs, which the OFox gateway re-hosts on its own object storage (verified live). Validation, resumer registration, capabilities, tool schema, settings page with model discovery via the /models probe, agent skill reference, i18n (en/it/ru), .env.example, and verify coverage included. refVideos/refAudios are supported by the OFox API but not wired yet; per-model duration/resolution limits are enforced by the API with a clear 400 before any task is created, so no local per-model whitelist is kept. Co-Authored-By: Claude Fable 5 --- .env.example | 5 + README.md | 2 +- README_ZH.md | 2 +- assets/agent/openchatcut-tool-schemas.json | 13 +- server/key-probes.ts | 7 + server/key-probes.verify.ts | 2 +- server/keystore.ts | 5 + server/keystore.verify.ts | 2 +- server/plugins/index.ts | 9 ++ server/plugins/ofox-video-provider.ts | 125 ++++++++++++++++++ server/plugins/video-validation.ts | 46 ++++++- server/plugins/video.ts | 16 ++- server/plugins/video.verify.ts | 50 +++++++ shared/llm-providers.ts | 7 + src/agent/ai-sdk.verify.ts | 4 + src/agent/capabilities.ts | 1 + .../references/providers-and-keys.md | 2 +- src/agent/skills/video-gen/SKILL.md | 9 +- src/agent/skills/video-gen/references/ofox.md | 46 +++++++ src/agent/tools/generate-schemas.ts | 10 +- src/components/settings/VisionModelPane.tsx | 1 + .../settings/settingsMediaProviders.ts | 1 + src/components/settings/settingsSchema.ts | 7 + src/components/settings/vendorIcons.tsx | 3 +- src/generate/video.ts | 2 +- src/i18n/dict/en/settings.ts | 4 + src/i18n/dict/it/settings.ts | 4 + src/i18n/dict/ru/index.ts | 4 + 28 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 server/plugins/ofox-video-provider.ts create mode 100644 src/agent/skills/video-gen/references/ofox.md diff --git a/.env.example b/.env.example index 4aace4a4..d85fb2a7 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,11 @@ SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 SEEDANCE_API_KEY= SEEDANCE_VIDEO_MODEL=doubao-seedance-2-0-260128 +# OFox multi-model gateway (LLM preset + video generation share one key) +LLM_OFOX_BASE_URL=https://api.ofox.ai/v1 +LLM_OFOX_API_KEY= +OFOX_VIDEO_MODEL=bytedance/seedance-2.0-fast + KLING_BASE_URL=https://api-singapore.klingai.com KLING_API_KEY= KLING_VIDEO_MODEL=kling-v3-omni diff --git a/README.md b/README.md index b45c2b4e..c95709a8 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ Clients must send `Authorization: Bearer `. The current bridge is designe |---|---| | Frontend | React 19, TypeScript 6, Vite 8 | | Editing core | Immutable timeline state, command layer, and proposal-based application | -| Agent | Vercel AI SDK 7 (Anthropic, OpenAI, Gemini, Kimi, Qwen, GLM, DeepSeek, MiniMax, Xiaomi MiMo, Mistral, xAI Grok by API key or SuperGrok/X Premium+ subscription sign-in, and compatible APIs), Agent Skills, MCP SDK | +| Agent | Vercel AI SDK 7 (Anthropic, OpenAI, Gemini, Kimi, Qwen, GLM, DeepSeek, MiniMax, Xiaomi MiMo, Mistral, xAI Grok by API key or SuperGrok/X Premium+ subscription sign-in, OFox, and compatible APIs), Agent Skills, MCP SDK | | Preview and visuals | Remotion Player, WebGL / GLSL | | Server | Dual-host Vite / Electron plugins and a server-side keystore | | Persistence | Shared local project store under `~/.openchatcut`, IndexedDB cache, configurable local media directory, optional Cloudflare R2 | diff --git a/README_ZH.md b/README_ZH.md index 1f37ba0c..88fc7b22 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -397,7 +397,7 @@ OPENCHATCUT_EDITOR_URL=https://your-editor.example.com |---|---| | 前端 | React 19、TypeScript 6、Vite 8 | | 编辑核心 | 不可变时间线状态、命令层、提案式应用 | -| Agent | Vercel AI SDK 7(Anthropic、OpenAI、Gemini、Kimi、Qwen、GLM、DeepSeek、MiniMax、小米 MiMo、Mistral、xAI Grok(API Key 或 SuperGrok/X Premium+ 订阅登录)与兼容接口)、Agent Skills、MCP SDK | +| Agent | Vercel AI SDK 7(Anthropic、OpenAI、Gemini、Kimi、Qwen、GLM、DeepSeek、MiniMax、小米 MiMo、Mistral、xAI Grok(API Key 或 SuperGrok/X Premium+ 订阅登录)、OFox 与兼容接口)、Agent Skills、MCP SDK | | 预览与视觉 | Remotion Player、WebGL / GLSL | | 服务端 | Vite / Electron 双宿主插件、服务端密钥仓 | | 持久化 | `~/.openchatcut` 下的本机共享工程库、IndexedDB 缓存、可配置本地素材目录、可选 Cloudflare R2 | diff --git a/assets/agent/openchatcut-tool-schemas.json b/assets/agent/openchatcut-tool-schemas.json index 81c639bd..781a6090 100644 --- a/assets/agent/openchatcut-tool-schemas.json +++ b/assets/agent/openchatcut-tool-schemas.json @@ -1922,9 +1922,10 @@ "kling", "hailuo", "byteplus", - "grok-imagine-video" + "grok-imagine-video", + "ofox" ], - "description": "hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames." + "description": "hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames. ofox is the OFox multi-model gateway (Seedance/Wan and more behind one key): 2–30s with per-model limits enforced by the API; supports firstFrame (and optional lastFrame), or up to 9 refImages (frames and refImages are mutually exclusive); no refVideos/refAudios yet." }, "prompt": { "type": "string", @@ -1942,11 +1943,11 @@ "type": "string" } ], - "description": "Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video." + "description": "Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video, 2–30 for ofox (per-model limits enforced by the API)." }, "ratio": { "type": "string", - "description": "Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. Do not send for hailuo." + "description": "Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. ofox: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9, 9:21. Do not send for hailuo." }, "resolution": { "type": "string", @@ -1957,7 +1958,7 @@ "1080p", "4k" ], - "description": "Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p." + "description": "Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p. ofox: 480p/720p(default)/1080p, per-model support enforced by the API." }, "mode": { "type": "string", @@ -1973,7 +1974,7 @@ }, "lastFrame": { "type": "string", - "description": "Project image asset reference; requires firstFrame. Supported on seedance2, kling, and hailuo (not with multi-ref on seedance2)." + "description": "Project image asset reference; requires firstFrame. Supported on seedance2, kling, hailuo, and ofox (not with multi-ref on seedance2/ofox)." }, "refImages": { "type": "array", diff --git a/server/key-probes.ts b/server/key-probes.ts index ef848551..3e1eaf66 100644 --- a/server/key-probes.ts +++ b/server/key-probes.ts @@ -383,6 +383,13 @@ export const PROBES: Record = { 'video/hailuo': minimaxProbe, 'video/byteplus': byteplusProbe, 'video/xai': xaiMediaProbe, + 'video/ofox': { + needs: [['LLM_OFOX_API_KEY']], + run: (get) => fetch(`${base(get, 'LLM_OFOX_BASE_URL', 'https://api.ofox.ai/v1')}/models`, { + signal: t(), headers: bearer(get('LLM_OFOX_API_KEY')), + }), + models: parseModelCatalog, + }, 'music/mureka': { needs: [['MUREKA_API_KEY']], run: (get) => fetch(`${base(get, 'MUREKA_BASE_URL', 'https://api.mureka.ai')}/v1/account/billing`, { diff --git a/server/key-probes.verify.ts b/server/key-probes.verify.ts index 99bbfaf8..5c634735 100644 --- a/server/key-probes.verify.ts +++ b/server/key-probes.verify.ts @@ -28,7 +28,7 @@ const EXPECTED_PAGES = [ 'image/openai', 'image/gemini', 'image/minimax', 'image/wavespeed', 'image/byteplus', 'image/xai', 'voice/elevenlabs', 'voice/openai', 'voice/gemini', 'voice/mistral', 'voice/cartesia', 'voice/doubao', 'voice/minimax', 'voice/inworld', 'voice/fishaudio', 'voice/speechify', - 'video/seedance', 'video/kling', 'video/hailuo', 'video/byteplus', 'video/xai', + 'video/seedance', 'video/kling', 'video/hailuo', 'video/byteplus', 'video/xai', 'video/ofox', 'music/mureka', 'music/minimax', 'music/atlas', 'music/sonilo', 'stock/pexels', 'stock/pixabay', 'stock/unsplash', 'stock/freesound', 'transcription/assemblyai', 'transcription/openai', 'transcription/mistral', diff --git a/server/keystore.ts b/server/keystore.ts index 158dd563..146b7ddc 100644 --- a/server/keystore.ts +++ b/server/keystore.ts @@ -82,6 +82,10 @@ export const KEY_NAMES = [ "LLM_OPENROUTER_API_KEY", "LLM_OPENROUTER_BASE_URL", "LLM_OPENROUTER_MODEL", + "LLM_OFOX_API_KEY", + "LLM_OFOX_BASE_URL", + "LLM_OFOX_MODEL", + "OFOX_VIDEO_MODEL", "LLM_OLLAMA_API_KEY", "LLM_OLLAMA_BASE_URL", "LLM_OLLAMA_MODEL", @@ -237,6 +241,7 @@ export const NON_SECRET_NAMES: ReadonlySet = new Set([ "BYTEPLUS_VIDEO_MODEL", "XAI_IMAGE_MODEL", "XAI_VIDEO_MODEL", + "OFOX_VIDEO_MODEL", "INWORLD_TTS_MODEL", "FISHAUDIO_TTS_MODEL", "SPEECHIFY_TTS_MODEL", diff --git a/server/keystore.verify.ts b/server/keystore.verify.ts index b8fa40eb..bb91df8e 100644 --- a/server/keystore.verify.ts +++ b/server/keystore.verify.ts @@ -130,7 +130,7 @@ const MODEL_ROUTING_NAMES = [ 'MINIMAX_TTS_MODEL', 'MINIMAX_VIDEO_MODEL', 'MINIMAX_MUSIC_MODEL', 'MINIMAX_IMAGE_MODEL', 'ATLASCLOUD_API_BASE', 'ATLASCLOUD_MUSIC_MODEL', 'WAVESPEED_IMAGE_MODEL', 'BYTEPLUS_IMAGE_MODEL', 'BYTEPLUS_VIDEO_MODEL', - 'XAI_IMAGE_MODEL', 'XAI_VIDEO_MODEL', + 'XAI_IMAGE_MODEL', 'XAI_VIDEO_MODEL', 'OFOX_VIDEO_MODEL', 'INWORLD_TTS_MODEL', 'FISHAUDIO_TTS_MODEL', 'SPEECHIFY_TTS_MODEL', 'PREFERRED_IMAGE_VENDOR', 'PREFERRED_VOICE_VENDOR', 'PREFERRED_VIDEO_VENDOR', 'PREFERRED_MUSIC_VENDOR', 'PREFERRED_TRANSCRIPTION_PROVIDER', 'TRANSCRIPTION_LANGUAGE', 'TRANSCRIPTION_DIARIZATION', 'AUTO_TRANSCRIBE_INGEST', 'UI_SCALE', diff --git a/server/plugins/index.ts b/server/plugins/index.ts index dd3ba096..91be6a2f 100644 --- a/server/plugins/index.ts +++ b/server/plugins/index.ts @@ -310,6 +310,15 @@ export function serverPlugins(options: { projectStoreHttp?: boolean } = {}): Plu get xaiVideoModel() { return getKey("XAI_VIDEO_MODEL") || "grok-imagine-video-1.5"; }, + get ofoxBaseUrl() { + return getKey("LLM_OFOX_BASE_URL") || "https://api.ofox.ai/v1"; + }, + get ofoxApiKey() { + return getKey("LLM_OFOX_API_KEY"); + }, + get ofoxVideoModel() { + return getKey("OFOX_VIDEO_MODEL") || "bytedance/seedance-2.0-fast"; + }, get byteplusModel() { return getKey("BYTEPLUS_VIDEO_MODEL") || "seedance-1-5-pro-251215"; }, diff --git a/server/plugins/ofox-video-provider.ts b/server/plugins/ofox-video-provider.ts new file mode 100644 index 00000000..38f22d93 --- /dev/null +++ b/server/plugins/ofox-video-provider.ts @@ -0,0 +1,125 @@ +import { proxyDispatcher } from '../outbound-proxy.ts'; +import type { RegisterGenerationProviderTask } from './generation-jobs.ts'; +import { mediaDataUrl } from './video-media.ts'; +import type { ValidVideoRequest } from './video-validation.ts'; + +type FetchInit = Parameters[1] & { dispatcher?: unknown }; +const fetchWithProxy = (url: RequestInfo | URL, init?: FetchInit): Promise => + fetch(url, { ...init, dispatcher: proxyDispatcher() } as RequestInit); +const FAILURES = new Set(['failed', 'error', 'cancelled', 'canceled', 'expired']); +const SUCCESSES = new Set(['completed', 'succeeded']); +const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +interface OfoxVideoOptions { + ofoxBaseUrl: string; + ofoxApiKey: string; + ofoxVideoModel: string; +} + +async function providerError(response: Response): Promise { + const text = await response.text(); + try { + const data = JSON.parse(text) as { message?: string; error?: { message?: string } }; + return data.error?.message ?? data.message ?? `video provider failed (${response.status})`; + } catch { + return text.slice(0, 300) || `video provider failed (${response.status})`; + } +} + +interface OfoxMediaRef { + type: 'image_url'; + image_url: { url: string }; + frame_type?: 'first_frame' | 'last_frame'; +} + +/** Frame anchors and image references both ride as data URLs; the OFox + * gateway re-hosts data URIs on its own object storage before handing the + * request to the upstream model (verified live), so local project media + * needs no public hosting. frame_images and input_references are mutually + * exclusive at the API level; validation enforces that before submission. */ +async function ofoxMediaInputs(input: ValidVideoRequest): Promise<{ + frame_images?: OfoxMediaRef[]; + input_references?: OfoxMediaRef[]; +}> { + if (input.firstFramePath) { + const frames: OfoxMediaRef[] = [{ + type: 'image_url', + image_url: { url: await mediaDataUrl(input.firstFramePath) }, + frame_type: 'first_frame', + }]; + if (input.lastFramePath) { + frames.push({ + type: 'image_url', + image_url: { url: await mediaDataUrl(input.lastFramePath) }, + frame_type: 'last_frame', + }); + } + return { frame_images: frames }; + } + if (input.refImagePaths.length) { + return { + input_references: await Promise.all(input.refImagePaths.map(async (path) => ({ + type: 'image_url' as const, + image_url: { url: await mediaDataUrl(path) }, + }))), + }; + } + return {}; +} + +/** OFox multi-model video gateway: asynchronous task id plus polling. + * POST /videos -> { id } ; GET /videos/{id} -> { status, unsigned_urls }. + * Invalid duration/resolution/model values are rejected by the API with a + * clear 400 before any task is created, so no per-model limits are kept here. */ +export async function generateOfoxVideo( + input: ValidVideoRequest, + options: OfoxVideoOptions, + registerProviderTask: RegisterGenerationProviderTask, + existingTaskId?: string, +): Promise { + const baseUrl = options.ofoxBaseUrl.replace(/\/$/, ''); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${options.ofoxApiKey}` }; + let taskId = existingTaskId; + if (!taskId) { + const startedResponse = await fetchWithProxy(`${baseUrl}/videos`, { + method: 'POST', headers, + body: JSON.stringify({ + model: options.ofoxVideoModel, prompt: input.prompt, + duration: input.durationSeconds, aspect_ratio: input.ratio, + resolution: input.resolution ?? '720p', + ...(input.generateAudio !== undefined ? { generate_audio: input.generateAudio } : {}), + ...(input.seed !== undefined ? { seed: input.seed } : {}), + ...(await ofoxMediaInputs(input)), + }), + // Data-URL payloads carry whole reference images; give the submit + // request a proportionally larger timeout than the text-only case. + signal: AbortSignal.timeout(120_000), + }); + if (!startedResponse.ok) throw new Error(await providerError(startedResponse)); + const started = await startedResponse.json() as { id?: unknown }; + taskId = String(started.id ?? ''); + if (!taskId) throw new Error('ofox did not return a task id'); + await registerProviderTask('ofox', taskId); + } + const deadline = Date.now() + 15 * 60_000; + while (Date.now() < deadline) { + const poll = await fetchWithProxy(`${baseUrl}/videos/${encodeURIComponent(taskId)}`, { + headers, signal: AbortSignal.timeout(20_000), + }); + if (!poll.ok) throw new Error(await providerError(poll)); + const current = await poll.json() as { status?: unknown; unsigned_urls?: unknown; error?: { message?: unknown } }; + const status = String(current.status ?? ''); + if (SUCCESSES.has(status)) { + const urls = Array.isArray(current.unsigned_urls) ? current.unsigned_urls : []; + const url = urls.find((item): item is string => typeof item === 'string' && /^https?:\/\//.test(item)); + if (!url) throw new Error('ofox generation succeeded without a video URL'); + return url; + } + if (FAILURES.has(status)) { + const detail = typeof current.error?.message === 'string' ? `: ${current.error.message}` : ''; + throw new Error(`ofox generation ${status}${detail}`); + } + await wait(3_000); + } + throw new Error('ofox generation timed out'); +} diff --git a/server/plugins/video-validation.ts b/server/plugins/video-validation.ts index 233217ca..e797150d 100644 --- a/server/plugins/video-validation.ts +++ b/server/plugins/video-validation.ts @@ -4,7 +4,7 @@ export type KlingVideoReferType = 'feature' | 'base'; export interface VideoRequest { operationId?: string; - model?: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video'; + model?: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video' | 'ofox'; prompt?: string; name?: string; durationSeconds?: number | string; @@ -34,7 +34,7 @@ export interface VideoRequest { } export interface ValidVideoRequest extends Omit { - model: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video'; + model: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video' | 'ofox'; prompt: string; durationSeconds: number; durationSpecified: boolean; @@ -192,14 +192,52 @@ function validateGrok(input: ValidVideoRequest): ValidVideoRequest { return input; } +const OFOX_UNSUPPORTED_ARK_KEYS = ['cameraFixed', 'watermark', 'returnLastFrame', 'executionExpiresAfter', 'priority'] as const; + +/** OFox multi-model video gateway: the API validates duration, resolution and + * vendor per model with a clear 400 before any task is created, so only + * cross-provider limits and unsupported options are enforced here. Text, + * first/last-frame and image-reference modes are wired; frame anchors and + * references are mutually exclusive at the API level (400 references_conflict), + * enforced locally before any paid submission. */ +function validateOfox(input: ValidVideoRequest): ValidVideoRequest { + if (!input.prompt || input.prompt.length > 4000) throw new Error('ofox prompt is required and must be at most 4000 characters'); + if (input.durationSeconds < 2 || input.durationSeconds > 30) throw new Error('ofox durationSeconds must be between 2 and 30 (per-model limits are enforced by the API)'); + if (!['16:9', '9:16', '1:1', '4:3', '3:4', '3:2', '2:3', '21:9', '9:21'].includes(input.ratio)) { + throw new Error(`ofox does not support ratio ${input.ratio}`); + } + if (input.resolution && !['480p', '720p', '1080p'].includes(input.resolution)) throw new Error('ofox resolution must be 480p, 720p, or 1080p (per-model support is enforced by the API)'); + if (input.lastFramePath && !input.firstFramePath) throw new Error('lastFrame requires firstFrame'); + if ((input.firstFramePath || input.lastFramePath) && input.refImagePaths.length) { + throw new Error('ofox frame anchors (firstFrame/lastFrame) cannot be combined with refImages'); + } + if (input.refImagePaths.length > 9) throw new Error('ofox accepts at most 9 refImages'); + if (input.refVideoPaths.length || input.refAudioPaths.length) { + throw new Error('ofox refVideos/refAudios are not wired in this integration yet; use refImages or firstFrame/lastFrame'); + } + if (input.mode || input.shotType || input.multiPrompts?.length || input.refVideoMode) { + throw new Error('multi-shot and editing options are not supported by ofox'); + } + for (const key of OFOX_UNSUPPORTED_ARK_KEYS) { + if (input[key] !== undefined) throw new Error(`${key} is supported by seedance2/byteplus only`); + } + if (input.generateAudio !== undefined && typeof input.generateAudio !== 'boolean') throw new Error('generateAudio must be a boolean'); + if (input.seed !== undefined && !Number.isSafeInteger(input.seed)) throw new Error('seed must be a safe integer'); + if (input.promptOptimizer !== undefined || input.fastPretreatment !== undefined) { + throw new Error('promptOptimizer/fastPretreatment are supported by hailuo only'); + } + return input; +} + export function validateVideoRequest(input: VideoRequest): ValidVideoRequest { - if (input.model !== 'seedance2' && input.model !== 'kling' && input.model !== 'hailuo' && input.model !== 'byteplus' && input.model !== 'grok-imagine-video') { - throw new Error('model must be seedance2, kling, hailuo, byteplus, or grok-imagine-video'); + if (input.model !== 'seedance2' && input.model !== 'kling' && input.model !== 'hailuo' && input.model !== 'byteplus' && input.model !== 'grok-imagine-video' && input.model !== 'ofox') { + throw new Error('model must be seedance2, kling, hailuo, byteplus, grok-imagine-video, or ofox'); } if (input.model === 'hailuo' && input.ratio !== undefined) throw new Error('hailuo does not accept ratio; framing follows the first frame when present'); const normalized = common(input, input.model); if (normalized.model === 'hailuo') return validateHailuo(normalized); if (normalized.model === 'kling') return validateKling(normalized); if (normalized.model === 'grok-imagine-video') return validateGrok(normalized); + if (normalized.model === 'ofox') return validateOfox(normalized); return validateSeedance(normalized); } diff --git a/server/plugins/video.ts b/server/plugins/video.ts index 44997b30..bbcc3eb9 100644 --- a/server/plugins/video.ts +++ b/server/plugins/video.ts @@ -19,6 +19,7 @@ import { ServerReferencePreflightError, } from './video-media.ts'; import { generateGrokVideo } from './grok-video-provider.ts'; +import { generateOfoxVideo } from './ofox-video-provider.ts'; import { saveVideoResults } from './video-result-save.ts'; import { hailuoApiResolution, seedanceApiResolution, validateVideoRequest, videoSeconds, @@ -49,6 +50,9 @@ interface VideoOptions { xaiBaseUrl: string; xaiApiKey: string; xaiVideoModel: string; + ofoxBaseUrl: string; + ofoxApiKey: string; + ofoxVideoModel: string; } async function readJson(req: IncomingMessage): Promise { @@ -407,14 +411,16 @@ async function runVideoOperation( } else { const url = input.model === 'grok-imagine-video' ? await generateGrokVideo(input, options, registerProviderTask, providerTaskId) - : input.model === 'kling' - ? await generateKling(input, options, registerProviderTask, providerTaskId) - : await generateHailuo(input, options, registerProviderTask, providerTaskId); + : input.model === 'ofox' + ? await generateOfoxVideo(input, options, registerProviderTask, providerTaskId) + : input.model === 'kling' + ? await generateKling(input, options, registerProviderTask, providerTaskId) + : await generateHailuo(input, options, registerProviderTask, providerTaskId); urls = requireGenerationResultUrls([url], expectedResultCount); } } urls = requireGenerationResultUrls(urls, expectedResultCount); - const resultFetch = input.model === 'grok-imagine-video' ? fetchWithProxy : undefined; + const resultFetch = input.model === 'grok-imagine-video' || input.model === 'ofox' ? fetchWithProxy : undefined; const download = () => saveVideoResults( operationId, name, @@ -426,7 +432,7 @@ async function runVideoOperation( return download(); } export function videoGenerationPlugin(options: VideoOptions): Plugin { - for (const provider of ['seedance2', 'kling', 'hailuo', 'byteplus', 'grok-imagine-video'] as const) { + for (const provider of ['seedance2', 'kling', 'hailuo', 'byteplus', 'grok-imagine-video', 'ofox'] as const) { registerGenerationJobResumer('submit_video', provider, async ( snapshot: GenerationJobSnapshot, _update, diff --git a/server/plugins/video.verify.ts b/server/plugins/video.verify.ts index 64ad48a2..51e870c7 100644 --- a/server/plugins/video.verify.ts +++ b/server/plugins/video.verify.ts @@ -329,4 +329,54 @@ assert.throws( /supported by seedance2\/byteplus only/, ); +const ofox = validateVideoRequest({ model: 'ofox', prompt: 'a paper airplane gliding through a sunlit room', durationSeconds: 4, ratio: '9:16', resolution: '720p' }); +assert.equal(ofox.model, 'ofox'); +assert.equal(ofox.durationSeconds, 4); +assert.equal(ofox.ratio, '9:16'); +assert.equal(ofox.resolution, '720p'); +assert.equal(validateVideoRequest({ model: 'ofox', prompt: 'x', ratio: '21:9' }).ratio, '21:9'); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', durationSeconds: 40 }), + /durationSeconds must be between 2 and 30/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', resolution: '4k' }), + /resolution must be 480p, 720p, or 1080p/, +); +const ofoxI2v = validateVideoRequest({ model: 'ofox', prompt: 'x', firstFramePath: '/media/uploads/a.jpg', lastFramePath: '/media/uploads/b.jpg', generateAudio: false, seed: 7 }); +assert.equal(ofoxI2v.firstFramePath, '/media/uploads/a.jpg'); +assert.equal(ofoxI2v.lastFramePath, '/media/uploads/b.jpg'); +assert.equal(ofoxI2v.generateAudio, false); +assert.equal(ofoxI2v.seed, 7); +const ofoxRefs = validateVideoRequest({ model: 'ofox', prompt: 'x', refImagePaths: Array.from({ length: 9 }, (_, i) => `/media/uploads/r${i}.jpg`) }); +assert.equal(ofoxRefs.refImagePaths.length, 9); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', lastFramePath: '/media/uploads/b.jpg' }), + /lastFrame requires firstFrame/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', firstFramePath: '/media/uploads/a.jpg', refImagePaths: ['/media/uploads/r.jpg'] }), + /cannot be combined with refImages/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', refImagePaths: Array.from({ length: 10 }, (_, i) => `/media/uploads/r${i}.jpg`) }), + /at most 9 refImages/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', refVideoPaths: ['/media/uploads/v.mp4'] }), + /refVideos\/refAudios are not wired/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', watermark: true }), + /supported by seedance2\/byteplus only/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', promptOptimizer: true }), + /supported by hailuo only/, +); +assert.throws( + () => validateVideoRequest({ model: 'ofox', prompt: 'x', shotType: 'customize', multiPrompts: [{ prompt: 'a', duration: 2, index: 1 }, { prompt: 'b', duration: 2, index: 2 }] }), + /multi-shot and editing options are not supported by ofox/, +); + console.log('video.check: ok (seedance 480p + kling base/feature + hailuo)'); diff --git a/shared/llm-providers.ts b/shared/llm-providers.ts index ea6c9475..d184ed76 100644 --- a/shared/llm-providers.ts +++ b/shared/llm-providers.ts @@ -121,6 +121,13 @@ export const LLM_PROVIDER_PRESETS = [ baseUrl: 'https://openrouter.ai/api/v1', defaultModel: 'openrouter/auto', }, + { + id: 'ofox', + label: 'OFox', + protocol: 'openai-compatible', + baseUrl: 'https://api.ofox.ai/v1', + defaultModel: 'deepseek/deepseek-v3.2', + }, { id: 'ollama', label: 'Ollama (Local)', diff --git a/src/agent/ai-sdk.verify.ts b/src/agent/ai-sdk.verify.ts index d2189065..c9c4b3ea 100644 --- a/src/agent/ai-sdk.verify.ts +++ b/src/agent/ai-sdk.verify.ts @@ -46,6 +46,7 @@ assert.equal(normalizeLlmProvider('KIMI'), 'kimi'); assert.equal(normalizeLlmProvider('qwen'), 'qwen'); assert.equal(normalizeLlmProvider('glm'), 'glm'); assert.equal(normalizeLlmProvider('OpenRouter'), 'openrouter'); +assert.equal(normalizeLlmProvider('OFox'), 'ofox'); assert.equal(normalizeLlmProvider('unexpected'), 'anthropic'); assert.equal(defaultModelForProvider('anthropic'), 'claude-fable-5'); assert.equal(defaultModelForProvider('openai'), 'gpt-5'); @@ -53,12 +54,14 @@ assert.equal(defaultModelForProvider('kimi'), 'kimi-k3'); assert.equal(defaultModelForProvider('qwen'), 'qwen-plus'); assert.equal(defaultModelForProvider('glm'), 'glm-5.2'); assert.equal(defaultModelForProvider('openrouter'), 'openrouter/auto'); +assert.equal(defaultModelForProvider('ofox'), 'deepseek/deepseek-v3.2'); assert.equal(providerApiPath('anthropic'), '/messages'); assert.equal(providerApiPath('openai'), '/responses'); assert.equal(providerApiPath('openai', 'chat'), '/chat/completions'); assert.equal(providerApiPath('kimi'), '/chat/completions'); assert.equal(providerApiPath('gemini'), '/models'); assert.equal(providerApiPath('openrouter'), '/chat/completions'); +assert.equal(providerApiPath('ofox'), '/chat/completions'); const strippedVisualMessages = withoutModelImages([{ role: 'user', @@ -83,6 +86,7 @@ assert.equal((await getLanguageModel('openai', 'test-model', 'chat')).provider, assert.equal((await getLanguageModel('kimi', 'test-model')).provider, 'moonshotai.chat'); assert.equal((await getLanguageModel('gemini', 'test-model')).provider, 'google.generative-ai'); assert.equal((await getLanguageModel('openrouter', 'openrouter/auto')).provider, 'openrouter.chat'); +assert.equal((await getLanguageModel('ofox', 'deepseek/deepseek-v3.2')).provider, 'ofox.chat'); assert.deepEqual(getLanguageModelProviderOptions('openai'), { openai: { store: false } }); assert.equal(getLanguageModelProviderOptions('openai', 'chat'), undefined); assert.deepEqual(getLanguageModelProviderOptions('minimax'), { diff --git a/src/agent/capabilities.ts b/src/agent/capabilities.ts index c91b0fa7..f89c3fbb 100644 --- a/src/agent/capabilities.ts +++ b/src/agent/capabilities.ts @@ -63,6 +63,7 @@ const CAP_PROVIDERS: Partial> = { { label: 'MiniMax', arg: 'image-01', argKey: 'model', need: [['MINIMAX_API_KEY']] }, { label: 'WaveSpeed', arg: 'wavespeed', argKey: 'model', need: [['WAVESPEED_API_KEY']] }, { label: 'BytePlus', arg: 'byteplus', argKey: 'model', need: [['BYTEPLUS_API_KEY']] }, + { label: 'OFox', arg: 'ofox', argKey: 'model', need: [['LLM_OFOX_API_KEY']] }, { label: 'xAI Grok', arg: 'grok-imagine', argKey: 'model', need: [['LLM_XAI_OAUTH_API_KEY'], ['LLM_XAI_API_KEY']] }, ], voice: [ diff --git a/src/agent/skills/product-help/references/providers-and-keys.md b/src/agent/skills/product-help/references/providers-and-keys.md index 6e46a964..bcd44169 100644 --- a/src/agent/skills/product-help/references/providers-and-keys.md +++ b/src/agent/skills/product-help/references/providers-and-keys.md @@ -12,7 +12,7 @@ If a capability is off, say so and offer alternatives (upload, library, another | Capability | Tools (examples) | Keys (any configured vendor is enough) | | --- | --- | --- | | Image gen | `submit_image` | `IMAGE_API_KEY` / OpenAI, `GEMINI_API_KEY`, `MINIMAX_API_KEY` | -| Video gen | `submit_video` | `SEEDANCE_API_KEY`, `KLING_API_KEY`, `MINIMAX_API_KEY` (Hailuo) | +| Video gen | `submit_video` | `SEEDANCE_API_KEY`, `KLING_API_KEY`, `MINIMAX_API_KEY` (Hailuo), `LLM_OFOX_API_KEY` (OFox) | | TTS / voice | `submit_voice` | Provider-specific server key(s): Doubao, ElevenLabs, MiniMax, Inworld, Fish Audio, Speechify, OpenAI, Gemini, Mistral, or Cartesia | | Music | `submit_music` | `MUREKA_API_KEY`, `MINIMAX_API_KEY`, `ATLASCLOUD_API_KEY` | | Sound FX gen | `submit_sound` | `ELEVENLABS_API_KEY` | diff --git a/src/agent/skills/video-gen/SKILL.md b/src/agent/skills/video-gen/SKILL.md index 3b9516a2..17faaad1 100644 --- a/src/agent/skills/video-gen/SKILL.md +++ b/src/agent/skills/video-gen/SKILL.md @@ -21,6 +21,7 @@ Any time the user wants to generate a video clip — text-to-video, image-to-vid | `kling` | [references/kling.md](references/kling.md) | Technical camera/performance; Omni multi-shot; images ≤7 (≤4 with one feature `refVideos`); std/pro; 3–15s. | | `hailuo` | [references/hailuo.md](references/hailuo.md) | MiniMax 海螺. T2V / I2V / first+last; **6s or 10s**; 512P (Hailuo-02), 720p→768P, 1080P (6s); no multi-ref / multi-shot. | | `grok-imagine-video` | [references/grok-imagine-video.md](references/grok-imagine-video.md) | xAI Grok Imagine. Text-to-video only; 1–15s; 480p/720p/1080p; audio track included. | +| `ofox` | [references/ofox.md](references/ofox.md) | OFox multi-model gateway (Seedance/Wan and more behind one key). Text/image-to-video, first+last frame, up to 9 image refs; 2–30s with per-model API limits; 480p/720p/1080p per model. | **IMPORTANT:** Before generating, READ the chosen model's reference for capabilities, input channels, modes, prompt structure, and model-specific behavior. Never invent params the reference forbids. @@ -30,7 +31,7 @@ Respect **configured vendors** from the capabilities prompt (only call a model w 1. **User named a vendor** ("用海螺", "MiniMax", "Kling", "Seedance") → that `model`, if configured. 2. Else **default `seedance2`** when Seedance is configured. -3. Else if only Kling is on → `kling`. Else if only MiniMax is on → `hailuo`. Else if only xAI is on → `grok-imagine-video`. +3. Else if only Kling is on → `kling`. Else if only MiniMax is on → `hailuo`. Else if only xAI is on → `grok-imagine-video`. Else if only OFox is on → `ofox`. 4. Switch away from default when: - Need **multi-shot customize / intelligence** → `kling` (confirm if not user-named). - Need **rich multi-modal refs** (video/audio refs, edit/extend) → `seedance2`. @@ -44,9 +45,9 @@ Briefly tell the user what you will generate before submitting. | Param | Values | Default | | --- | --- | --- | -| `model` | `seedance2`, `kling`, `hailuo`, `grok-imagine-video` | seedance2 when available | -| `durationSeconds` | model-specific | seedance/kling ~5; **hailuo 6 or 10** (1080p → 6 only); **grok 1–15** | -| `ratio` | see model docs | 16:9 (seedance/kling/grok); **ignored on hailuo** | +| `model` | `seedance2`, `kling`, `hailuo`, `grok-imagine-video`, `ofox` | seedance2 when available | +| `durationSeconds` | model-specific | seedance/kling ~5; **hailuo 6 or 10** (1080p → 6 only); **grok 1–15**; **ofox 2–30 (per-model API limits)** | +| `ratio` | see model docs | 16:9 (seedance/kling/grok/ofox); **ignored on hailuo** | | `resolution` | `480p`, `512p`, `720p`, `1080p`, `4k` | provider-specific; hailuo adds 512p for Hailuo-02; grok: 480p/720p/1080p | | `refVideoMode` | `feature`, `base` | kling only, with `refVideos` | | `promptOptimizer` / `fastPretreatment` | boolean | hailuo only | diff --git a/src/agent/skills/video-gen/references/ofox.md b/src/agent/skills/video-gen/references/ofox.md new file mode 100644 index 00000000..f0fb3e2c --- /dev/null +++ b/src/agent/skills/video-gen/references/ofox.md @@ -0,0 +1,46 @@ +# OFox Multi-Model Video (`ofox`) + +Text-to-video via the OFox multi-model gateway (one API key in front of the +Seedance series, Wan, and other text-to-video models). Auth: the +`LLM_OFOX_API_KEY` key (shared with the OFox LLM preset). Configured model id +comes from Settings (`OFOX_VIDEO_MODEL`), default +`bytedance/seedance-2.0-fast`; any text-to-video model from the OFox catalog +(`GET /v1/models`) works. + +## Wired capabilities + +- **Text-to-video, image-to-video, first-and-last-frame, and image + references.** `firstFrame` (optionally with `lastFrame`) anchors exact + frames; up to 9 `refImages` guide subject/style instead. The two modes are + **mutually exclusive** (the API rejects the combination). `refVideos` / + `refAudios` are supported by the OFox API but not wired here yet; + `refVideoMode` / `mode` / `shotType` / `multiPrompts` are rejected. +- **`generateAudio`** (default true for capable models) and **`seed`** pass + through; seed determinism is not guaranteed by every upstream vendor. +- **Duration** (`durationSeconds`): 2–30 seconds locally; the API enforces the + configured model's own range with a clear 400 before any task is created + (for example the default model accepts 4–15 seconds). +- **Aspect ratio** (`ratio`): `16:9` (default), `9:16`, `1:1`, `4:3`, `3:4`, + `3:2`, `2:3`, `21:9`, `9:21`. +- **Resolution** (`resolution`): `480p`, `720p` (default), `1080p` — per-model + support is enforced by the API (the default model accepts 480p/720p). +- Project media rides as base64 data URLs; the OFox gateway re-hosts them on + its own object storage before dispatching to the upstream model, so no + public hosting is needed. Reference images should be reasonably sized + (very small images can fail the upstream image validation). +- The job is asynchronous: `submit_video` returns a `jobId`; poll / wait with + `track_progress` exactly like the other video vendors. + +## Prompt guidance + +One coherent action per clip. Include subject, motion, environment, lighting, +and camera intent. Different catalog models have different strengths; keep +prompts model-agnostic unless the user pinned a specific model. + +## Errors + +- `duration N out of range [a, b]` / `resolution "…" not supported`: the + configured model's server-side limits — adjust the args or switch + `OFOX_VIDEO_MODEL`, do not retry unchanged. +- 401 `upstream_auth_failed`: the OFox account key is invalid or out of + balance — ask the user to check the key / top up. diff --git a/src/agent/tools/generate-schemas.ts b/src/agent/tools/generate-schemas.ts index f1a5f5d8..b1e7886e 100644 --- a/src/agent/tools/generate-schemas.ts +++ b/src/agent/tools/generate-schemas.ts @@ -150,15 +150,15 @@ export const GENERATE_TOOL_SCHEMAS: AgentToolSchema[] = [ input_schema: { type: 'object', properties: { - model: { type: 'string', enum: ['seedance2', 'kling', 'hailuo', 'byteplus', 'grok-imagine-video'], description: 'hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames.' }, // minimax: hailuo enum + model: { type: 'string', enum: ['seedance2', 'kling', 'hailuo', 'byteplus', 'grok-imagine-video', 'ofox'], description: 'hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames. ofox is the OFox multi-model gateway (Seedance/Wan and more behind one key): 2–30s with per-model limits enforced by the API; supports firstFrame (and optional lastFrame), or up to 9 refImages (frames and refImages are mutually exclusive); no refVideos/refAudios yet.' }, // minimax: hailuo enum prompt: { type: 'string', description: 'Required for normal generation and Kling intelligence; omit for Kling customize.' }, name: { type: 'string' }, - durationSeconds: { anyOf: [{ type: 'number' }, { type: 'string' }], description: 'Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video.' }, // minimax: hailuo durations - ratio: { type: 'string', description: 'Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. Do not send for hailuo.' }, - resolution: { type: 'string', enum: ['480p', '512p', '720p', '1080p', '4k'], description: 'Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p.' }, + durationSeconds: { anyOf: [{ type: 'number' }, { type: 'string' }], description: 'Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video, 2–30 for ofox (per-model limits enforced by the API).' }, // minimax: hailuo durations + ratio: { type: 'string', description: 'Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. ofox: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9, 9:21. Do not send for hailuo.' }, + resolution: { type: 'string', enum: ['480p', '512p', '720p', '1080p', '4k'], description: 'Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p. ofox: 480p/720p(default)/1080p, per-model support enforced by the API.' }, mode: { type: 'string', enum: ['std', 'pro'], description: 'Kling only; std=720p, pro=1080p.' }, firstFrame: { type: 'string', description: 'Project image asset ID, asset:// ID, short unique ID prefix, or same-project asset path.' }, - lastFrame: { type: 'string', description: 'Project image asset reference; requires firstFrame. Supported on seedance2, kling, and hailuo (not with multi-ref on seedance2).' }, + lastFrame: { type: 'string', description: 'Project image asset reference; requires firstFrame. Supported on seedance2, kling, hailuo, and ofox (not with multi-ref on seedance2/ofox).' }, refImages: { type: 'array', items: { type: 'string' } }, refVideos: { type: 'array', items: { type: 'string' } }, refAudios: { type: 'array', items: { type: 'string' } }, diff --git a/src/components/settings/VisionModelPane.tsx b/src/components/settings/VisionModelPane.tsx index 11333201..15dec7a6 100644 --- a/src/components/settings/VisionModelPane.tsx +++ b/src/components/settings/VisionModelPane.tsx @@ -43,6 +43,7 @@ const PROVIDER_LABELS: Record = { xai: 'xAI', 'xai-oauth': 'xAI (订阅登录)', openrouter: 'OpenRouter', + ofox: 'OFox', ollama: 'Ollama', lmstudio: 'LM Studio', }; diff --git a/src/components/settings/settingsMediaProviders.ts b/src/components/settings/settingsMediaProviders.ts index 23e8cb7e..d81d5fd3 100644 --- a/src/components/settings/settingsMediaProviders.ts +++ b/src/components/settings/settingsMediaProviders.ts @@ -233,6 +233,7 @@ export const ROUTE_NEEDS: Record = { 'image-01': [['MINIMAX_API_KEY']], 'grok-imagine': [['LLM_XAI_OAUTH_API_KEY'], ['LLM_XAI_API_KEY']], 'grok-imagine-video': [['LLM_XAI_OAUTH_API_KEY'], ['LLM_XAI_API_KEY']], + ofox: [['LLM_OFOX_API_KEY']], elevenlabs: [['ELEVENLABS_API_KEY']], doubao: [['DOUBAO_TTS_APP_ID', 'DOUBAO_TTS_ACCESS_KEY']], minimax: [['MINIMAX_API_KEY']], diff --git a/src/components/settings/settingsSchema.ts b/src/components/settings/settingsSchema.ts index 73301aa1..147b85d5 100644 --- a/src/components/settings/settingsSchema.ts +++ b/src/components/settings/settingsSchema.ts @@ -126,6 +126,7 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ { value: 'hailuo', label: 'MiniMax 海螺' }, { value: 'byteplus', label: 'BytePlus · Seedance' }, { value: 'grok-imagine-video', label: 'xAI Grok Imagine' }, + { value: 'ofox', label: 'OFox · 多模型' }, ]), vendors: [ { key: 'video/seedance', vendor: 'seedance', title: 'Seedance · 火山', fields: [ @@ -148,6 +149,12 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ modelText('XAI_VIDEO_MODEL', '视频模型', 'grok-imagine-video-1.5', '测试连接后可直接选择接口返回的模型,也可以手动填写模型 ID。', true), ] }, + { key: 'video/ofox', vendor: 'ofox', title: 'OFox · 多模型网关', + note: '使用 LLM_OFOX_API_KEY(在 Agent 供应商里配置 OFox)生成视频。一个 Key 覆盖 Seedance、Wan 等视频模型;支持文生视频、首帧/首尾帧图生视频与图片参考(最多 9 张);时长/分辨率按模型由 API 校验,2–30 秒。', + fields: [ + modelText('OFOX_VIDEO_MODEL', '视频模型', 'bytedance/seedance-2.0-fast', + '测试连接后可直接选择接口返回的模型,也可以手动填写模型 ID。', true), + ] }, ] }, { key: 'music', title: '生音乐', hint: 'submit_music · 文字 / 成片生成配乐,任一厂商即可。', route: routeSelect('PREFERRED_MUSIC_VENDOR', [ diff --git a/src/components/settings/vendorIcons.tsx b/src/components/settings/vendorIcons.tsx index 9811c3b6..50362323 100644 --- a/src/components/settings/vendorIcons.tsx +++ b/src/components/settings/vendorIcons.tsx @@ -43,7 +43,7 @@ export type VendorId = | 'seedance' | 'kling' | 'mureka' | 'sonilo' | 'pexels' | 'pixabay' | 'unsplash' | 'freesound' | 'assemblyai' | 'deepgram' | 'groq' | 'cartesia' | 'e2b' | 'firecrawl' | 'r2' | 'localdisk' | 'localasr' | 'stepfun' | 'byteplus' | 'inworld' | 'fishaudio' | 'speechify' | 'wavespeed' - | 'vision' | 'proxy' | 'atlas' | 'xai' | 'xai-oauth'; + | 'vision' | 'proxy' | 'atlas' | 'xai' | 'xai-oauth' | 'ofox'; interface SvgIcon { readonly svg: string; @@ -101,6 +101,7 @@ const MONOGRAMS: Partial = { '使用 SuperGrok 或 X Premium+ 订阅登录:官方 Grok CLI 管理登录与凭据(终端运行 grok login),OpenChatCut 导入会话并自动续期,不会读取或显示 OAuth 凭据。': 'Accedi con un abbonamento SuperGrok o X Premium+: la CLI ufficiale di Grok gestisce accesso e credenziali (esegui grok login nel terminale). OpenChatCut importa e rinnova automaticamente la sessione senza leggere o mostrare le credenziali OAuth.', '使用 xAI 订阅会话(SuperGrok / X Premium+,优先)或 LLM_XAI_API_KEY 生成图片。文生图:最多 4 张,1K / 2K。': 'Genera immagini con la sessione di abbonamento xAI (SuperGrok / X Premium+, preferita) o con LLM_XAI_API_KEY. Da testo a immagine: fino a 4 immagini, 1K / 2K.', '使用 xAI 订阅会话(SuperGrok / X Premium+,优先)或 LLM_XAI_API_KEY 生成视频。文生视频:1–15 秒,自带音轨,480p / 720p / 1080p。': 'Genera video con la sessione di abbonamento xAI (SuperGrok / X Premium+, preferita) o con LLM_XAI_API_KEY. Da testo a video: 1-15 secondi, traccia audio inclusa, 480p / 720p / 1080p.', + 'OFox · 多模型': 'OFox · Multi-modello', + 'OFox · 多模型网关': 'OFox · Gateway multi-modello', + '使用 LLM_OFOX_API_KEY(在 Agent 供应商里配置 OFox)生成视频。一个 Key 覆盖 Seedance、Wan 等视频模型;支持文生视频、首帧/首尾帧图生视频与图片参考(最多 9 张);时长/分辨率按模型由 API 校验,2–30 秒。': + 'Genera video con LLM_OFOX_API_KEY (configura OFox tra i provider Agent). Una sola chiave copre Seedance, Wan e altri modelli video; supporta testo-video, immagine-video con primo fotogramma / primo e ultimo fotogramma e fino a 9 immagini di riferimento; durata e risoluzione sono convalidate per modello dalla API, 2–30 secondi.', '处理中…': 'Elaborazione…', '导入登录状态': 'Importa accesso', '新工程和生成素材的默认保存位置,以及可选的 R2 云备份。': 'Posizione predefinita per i nuovi progetti e i media generati, con backup cloud R2 facoltativo.', diff --git a/src/i18n/dict/ru/index.ts b/src/i18n/dict/ru/index.ts index 26d69b13..d27f5c43 100644 --- a/src/i18n/dict/ru/index.ts +++ b/src/i18n/dict/ru/index.ts @@ -1779,6 +1779,10 @@ export const RU: Record = { 'xAI · Grok (订阅登录)': 'xAI · Grok (вход по подписке)', '使用 xAI 订阅会话(SuperGrok / X Premium+,优先)或 LLM_XAI_API_KEY 生成图片。文生图:最多 4 张,1K / 2K。': 'Генерация изображений через сессию подписки xAI (SuperGrok / X Premium+, приоритет) или LLM_XAI_API_KEY. Текст → изображение: до 4 штук, 1K / 2K.', '使用 xAI 订阅会话(SuperGrok / X Premium+,优先)或 LLM_XAI_API_KEY 生成视频。文生视频:1–15 秒,自带音轨,480p / 720p / 1080p。': 'Генерация видео через сессию подписки xAI (SuperGrok / X Premium+, приоритет) или LLM_XAI_API_KEY. Текст → видео: 1–15 с, со звуковой дорожкой, 480p / 720p / 1080p.', + 'OFox · 多模型': 'OFox · Мультимодельный', + 'OFox · 多模型网关': 'OFox · Мультимодельный шлюз', + '使用 LLM_OFOX_API_KEY(在 Agent 供应商里配置 OFox)生成视频。一个 Key 覆盖 Seedance、Wan 等视频模型;支持文生视频、首帧/首尾帧图生视频与图片参考(最多 9 张);时长/分辨率按模型由 API 校验,2–30 秒。': + 'Генерация видео через LLM_OFOX_API_KEY (настройте OFox в провайдерах Agent). Один ключ покрывает Seedance, Wan и другие видеомодели; поддерживает текст-в-видео, изображение-в-видео (первый кадр / первый и последний кадры) и до 9 референсных изображений; длительность и разрешение проверяются API для каждой модели, 2–30 секунд.', 'xAI · Grok Imagine (视频)': 'xAI · Grok Imagine (видео)', '验证地址与密钥,并读取该接口可用的模型': 'Проверить адрес и ключ, прочитать доступные модели API', '选择模型': 'Выбрать модель', From 3c4d460d73918f47c7fcc9c71433a46af79a2efb Mon Sep 17 00:00:00 2001 From: celeste1900 <253458269+celeste1900@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:24:32 +0800 Subject: [PATCH 2/2] fix(providers): prefer ofox mirror_urls over temporary unsigned_urls mirror_urls are persistent signed CDN addresses returned when the upstream has mirroring enabled; unsigned_urls are temporary upstream links that may expire within 24 hours. Prefer the former and fall back to the latter, matching the official retrieve-endpoint recommendation. Co-Authored-By: Claude Fable 5 --- server/plugins/ofox-video-provider.ts | 14 ++++++++++---- src/agent/skills/video-gen/references/ofox.md | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/plugins/ofox-video-provider.ts b/server/plugins/ofox-video-provider.ts index 38f22d93..26271590 100644 --- a/server/plugins/ofox-video-provider.ts +++ b/server/plugins/ofox-video-provider.ts @@ -110,10 +110,16 @@ export async function generateOfoxVideo( const current = await poll.json() as { status?: unknown; unsigned_urls?: unknown; error?: { message?: unknown } }; const status = String(current.status ?? ''); if (SUCCESSES.has(status)) { - const urls = Array.isArray(current.unsigned_urls) ? current.unsigned_urls : []; - const url = urls.find((item): item is string => typeof item === 'string' && /^https?:\/\//.test(item)); - if (!url) throw new Error('ofox generation succeeded without a video URL'); - return url; + // Prefer mirror_urls (persistent signed CDN addresses, present only when + // the upstream has mirroring enabled) and fall back to unsigned_urls + // (temporary upstream links that may expire within 24 hours). + const body = current as { mirror_urls?: unknown; unsigned_urls?: unknown }; + for (const field of ['mirror_urls', 'unsigned_urls'] as const) { + const urls = Array.isArray(body[field]) ? body[field] as unknown[] : []; + const url = urls.find((item): item is string => typeof item === 'string' && /^https?:\/\//.test(item)); + if (url) return url; + } + throw new Error('ofox generation succeeded without a video URL'); } if (FAILURES.has(status)) { const detail = typeof current.error?.message === 'string' ? `: ${current.error.message}` : ''; diff --git a/src/agent/skills/video-gen/references/ofox.md b/src/agent/skills/video-gen/references/ofox.md index f0fb3e2c..418f84bd 100644 --- a/src/agent/skills/video-gen/references/ofox.md +++ b/src/agent/skills/video-gen/references/ofox.md @@ -28,6 +28,9 @@ comes from Settings (`OFOX_VIDEO_MODEL`), default its own object storage before dispatching to the upstream model, so no public hosting is needed. Reference images should be reasonably sized (very small images can fail the upstream image validation). +- Results prefer `mirror_urls` (persistent signed CDN addresses, present when + the upstream has mirroring enabled) and fall back to the temporary + `unsigned_urls`. - The job is asynchronous: `submit_video` returns a `jobId`; poll / wait with `track_progress` exactly like the other video vendors.