Fix Seedance runtime model handling

This commit is contained in:
2026-07-07 09:38:08 +08:00
parent 3b6781d695
commit 13f44a4bbc
4 changed files with 153 additions and 19 deletions

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
resolveSeedanceActualModel,
seedanceVideoAdapter,
} from '../model-adapters/seedance-adapter';
import type { AdapterContext } from '../model-adapters/types';
describe('seedance video adapter', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps runtime Doubao Seedance model ids unchanged', () => {
expect(
resolveSeedanceActualModel('doubao-seedance-2-0-fast-260128', '720p')
).toBe('doubao-seedance-2-0-fast-260128');
});
it('maps built-in Seedance aliases to concrete API model ids', () => {
expect(resolveSeedanceActualModel('seedance-1.5-pro', '720p')).toBe(
'doubao-seedance-1-5-pro_720p'
);
});
it('submits runtime Doubao Seedance model ids without adding a second prefix', async () => {
const requests: Array<{ url: string; init: RequestInit }> = [];
const fetcher = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: String(input), init: init || {} });
if ((init?.method || 'GET') === 'POST') {
return new Response(
JSON.stringify({
id: 'task-1',
status: 'queued',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}
return new Response(
JSON.stringify({
id: 'task-1',
status: 'succeeded',
progress: 100,
url: 'https://cdn.example.com/seedance.mp4',
seconds: '5',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}
);
const context: AdapterContext = {
baseUrl: 'https://truemodel.benchu.cloud/v1',
apiKey: 'sk-test',
authType: 'bearer',
operation: 'video',
fetcher,
};
const resultPromise = seedanceVideoAdapter.generateVideo(context, {
model: 'doubao-seedance-2-0-fast-260128',
prompt: 'tiny city at night',
size: '720p@16:9',
duration: 5,
});
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
const body = requests[0]?.init.body as FormData;
expect(result.url).toBe('https://cdn.example.com/seedance.mp4');
expect(requests[0]?.url).toBe('/api/truemodel/v1/videos');
expect(body.get('model')).toBe('doubao-seedance-2-0-fast-260128');
expect(body.get('model')).not.toBe(
'doubao-doubao-seedance-2-0-fast-260128_720p'
);
expect(body.get('size')).toBe('16:9');
expect(requests[1]?.url).toBe('/api/truemodel/v1/videos/task-1');
});
});

View File

@@ -15,14 +15,28 @@ const SEEDANCE_MODELS = [
type SeedanceSubmitResponse = {
id: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
status:
| 'queued'
| 'processing'
| 'in_progress'
| 'completed'
| 'succeeded'
| 'failed'
| 'error';
created_at?: number;
error?: string | { code: string; message: string };
};
type SeedanceQueryResponse = {
id: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
status:
| 'queued'
| 'processing'
| 'in_progress'
| 'completed'
| 'succeeded'
| 'failed'
| 'error';
progress?: number;
video_url?: string;
url?: string;
@@ -36,10 +50,20 @@ const DEFAULT_POLL_MAX_ATTEMPTS = 1080; // ~90 min
/**
* 将逻辑模型 ID + 分辨率拼接为 API 实际模型名
* seedance-1.5-pro + 720p → doubao-seedance-1-5-pro_720p
* 运行时发现的 doubao-seedance-* 已经是真实供应商模型名,必须原样发送。
*/
const resolveActualModel = (logicalId: string, resolution: string): string => {
export const resolveSeedanceActualModel = (
logicalId: string,
resolution: string
): string => {
const normalizedLogicalId = logicalId.trim();
const lowerId = normalizedLogicalId.toLowerCase();
if (lowerId.startsWith('doubao-seedance-')) {
return normalizedLogicalId;
}
// 将 "." 替换为 "-"seedance-1.5-pro → seedance-1-5-pro
const normalized = logicalId.replace(/\./g, '-');
const normalized = normalizedLogicalId.replace(/\./g, '-');
return `doubao-${normalized}_${resolution}`;
};
@@ -208,12 +232,14 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
async generateVideo(context, request: VideoGenerationRequest) {
const logicalModel = request.model || 'seedance-1.5-pro';
const resolution = extractResolution(request.size);
const actualModel = resolveActualModel(logicalModel, resolution);
const actualModel = resolveSeedanceActualModel(logicalModel, resolution);
const parsedSize = parseSeedanceSize(request.size);
// 宽高比优先取显式参数,其次回退到 size 中的组合值
const aspectRatio =
normalizeAspectRatio(request.params?.aspect_ratio as string | undefined) ||
normalizeAspectRatio(
request.params?.aspect_ratio as string | undefined
) ||
normalizeAspectRatio(request.params?.aspectRatio as string | undefined) ||
parsedSize.aspectRatio ||
'16:9';
@@ -253,7 +279,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
onSubmitted?.(taskId);
// 提交时已失败
if (submitResult.status === 'failed') {
if (submitResult.status === 'failed' || submitResult.status === 'error') {
throw new Error(extractErrorMessage(submitResult.error));
}
@@ -281,11 +307,11 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
(status.status === 'failed'
? 100
: status.status === 'completed'
? 100
: 0);
? 100
: 0);
onProgress?.(progress, status.status);
if (status.status === 'completed') {
if (status.status === 'completed' || status.status === 'succeeded') {
const url = status.video_url || status.url;
if (!url) {
throw new Error('Seedance 结果缺少视频 URL');
@@ -298,7 +324,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
};
}
if (status.status === 'failed') {
if (status.status === 'failed' || status.status === 'error') {
isBusinessFailure = true;
throw new Error(extractErrorMessage(status.error));
}