diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index d1bfc4c7..6e3c86d9 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -23,10 +23,19 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only) MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch") mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/ -core/files/upload.ts ← 文件上传 endpoint(cn-pinned) - UPLOAD_API ${REGIONS.cn}/api/v1/uploads +core/files/upload.ts ← 文件上传 endpoint(与 model 域凭证同源) + UploadOptions.baseUrl Client 传入有效凭证的 baseUrl,经 normalizeModelBaseUrl 取 origin + /api/v1/uploads 直接 SDK 调用未提供 baseUrl 时保持 REGIONS.cn 默认值 ``` +临时文件上传与后续模型请求使用同一 API origin,包括国际区、Workspace 和自定义代理。 +Prime 视频命令通过 `Client.uploadFile` / `resolveImageInput` 的 `defaultBaseUrl` 回调, +复用 `resolvePrimeEndpoint` 推导默认 Workspace origin;显式配置的 API origin 仍优先, +已有媒体 URL 不触发上传或求值上传默认地址。 +上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应, +不自动换区域重试。 +如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。 + ## 必查清单 ### A. TS 源码(必须 import,不准硬编码) diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 1108ca98..892a7de5 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -203,14 +203,18 @@ export default defineCommand({ ); } + const uploadOptions = flags.prime + ? { defaultBaseUrl: () => resolvePrimeEndpoint(ctx, "") } + : undefined; + // Auto-upload local image file for i2v let resolvedImageUrl: string | undefined; if (imageUrl) { - resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model); + resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model, uploadOptions); } let resolvedLastFrameUrl: string | undefined; if (lastFrameUrl) { - resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model); + resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model, uploadOptions); } // kf2v mode: both --image and --last-frame provided. @@ -225,7 +229,7 @@ export default defineCommand({ let resolvedFileUrl: string | undefined; if (fileUrl) { - resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model); + resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model, uploadOptions); } const watermark = resolveWatermark(flags.watermark, settings.watermark); diff --git a/packages/commands/tests/prime.test.ts b/packages/commands/tests/prime.test.ts index e17725b2..6670d36b 100644 --- a/packages/commands/tests/prime.test.ts +++ b/packages/commands/tests/prime.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, test } from "vite-plus/test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client, REGIONS, type ApiKeyCredential, type Settings } from "bailian-cli-core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; import { resolvePrimeEndpoint, validatePrimeFlags } from "../src/commands/shared/prime.ts"; +import videoGenerate from "../src/commands/video/generate.ts"; function createUrlResolver(baseUrl: string, usesDefaultBaseUrl: boolean) { return { @@ -48,3 +53,156 @@ describe("Prime command helpers", () => { ); }); }); + +describe("Prime video upload origins", () => { + const identity = { + binName: "test-cli", + version: "test", + npmPackage: "test-cli", + clientName: "test-client", + }; + const settings: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }; + const workspaceOrigin = "https://ws_test.cn-beijing.maas.aliyuncs.com"; + const policy = { + upload_host: "https://uploads.example.test", + upload_dir: "temporary", + oss_access_key_id: "test-access-key", + signature: "test-signature", + policy: "test-policy", + x_oss_object_acl: "private", + x_oss_forbid_overwrite: "true", + }; + const fetchMock = vi.fn(); + let fixtureDir: string; + let fixturePath: string; + + beforeEach(() => { + fixtureDir = mkdtempSync(join(tmpdir(), "bailian-prime-upload-")); + fixturePath = join(fixtureDir, "sample.png"); + writeFileSync(fixturePath, "synthetic media fixture"); + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === "/api/v1/uploads") { + return Response.json({ data: policy }); + } + if (url.origin === policy.upload_host) return new Response(null, { status: 200 }); + if (url.pathname === "/api/v1/services/aigc/video-generation/video-synthesis") { + return Response.json({ output: { task_id: "synthetic-task" } }); + } + throw new Error(`Unexpected test request: ${url.href}`); + }); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + rmSync(fixtureDir, { recursive: true, force: true }); + }); + + function runVideo( + flags: Record, + options: { + baseUrl?: string; + settingsWorkspaceId?: string; + credentialSource?: ApiKeyCredential["source"]; + } = {}, + ) { + const baseUrl = options.baseUrl ?? REGIONS.cn; + const commandSettings = { ...settings, workspaceId: options.settingsWorkspaceId }; + return videoGenerate.run({ + identity, + settings: commandSettings, + flags: { + prime: true, + model: "wan3.0-video", + prompt: "synthetic video", + async: true, + ...flags, + }, + client: new Client({ + identity, + settings: commandSettings, + baseUrl, + baseUrlIsDefault: options.baseUrl === undefined, + apiCred: { + token: "synthetic-workspace-key", + baseUrl, + source: options.credentialSource ?? "flag", + }, + }), + } as never); + } + + function expectUploadOrigin(origin: string, uploadCount = 1) { + const policyCalls = fetchMock.mock.calls.filter(([input]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url.includes("/api/v1/uploads?"); + }); + expect(policyCalls).toHaveLength(uploadCount); + for (const [policyUrl, policyOptions] of policyCalls) { + expect(policyUrl).toBe(`${origin}/api/v1/uploads?action=getPolicy&model=wan3.0-video`); + expect(new Headers(policyOptions?.headers).get("Authorization")).toBe( + "Bearer synthetic-workspace-key", + ); + } + const [submitUrl, submitOptions] = fetchMock.mock.calls.at(-1)!; + expect(submitUrl).toBe(`${origin}/api/v1/services/aigc/video-generation/video-synthesis`); + expect(new Headers(submitOptions?.headers).get("Authorization")).toBe( + "Bearer synthetic-workspace-key", + ); + expect(fetchMock).toHaveBeenCalledTimes(uploadCount * 2 + 1); + } + + test.each(["flag", "env"] as const)( + "uploads local files to the derived workspace with a %s key", + async (credentialSource) => { + await runVideo( + { file: fixturePath, ...(credentialSource === "flag" ? { workspaceId: "ws_test" } : {}) }, + { + credentialSource, + settingsWorkspaceId: credentialSource === "env" ? "ws_test" : undefined, + }, + ); + expectUploadOrigin(workspaceOrigin); + }, + ); + + test("uploads both local keyframes to the derived workspace", async () => { + await runVideo({ image: fixturePath, lastFrame: fixturePath, workspaceId: "ws_test" }); + expectUploadOrigin(workspaceOrigin, 2); + }); + + test.each([ + REGIONS.intl, + REGIONS.us, + "https://configured-workspace.ap-southeast-1.maas.aliyuncs.com", + "https://proxy.example.test", + ])("keeps the configured origin %s without requiring a workspace", async (baseUrl) => { + await runVideo({ file: fixturePath }, { baseUrl }); + expectUploadOrigin(baseUrl); + }); + + test("rejects a missing default workspace before making an upload request", async () => { + await expect(runVideo({ file: fixturePath })).rejects.toMatchObject({ + message: "Workspace ID is required for the default Prime endpoint.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("passes an existing media URL through without uploading", async () => { + await runVideo({ file: "https://media.example.test/video.mp4", workspaceId: "ws_test" }); + expectUploadOrigin(workspaceOrigin, 0); + }); +}); diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 2a549c89..78d1c798 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -148,11 +148,17 @@ export class Client { return requestJson(this.http, this.toOpts(opts)); } - /** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */ - uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise { + /** Resolve a file arg, honoring a lazy service default origin just like {@link url}. */ + uploadFile( + source: string, + model: string, + opts: { signal?: AbortSignal; defaultBaseUrl?: () => string } = {}, + ): Promise { if (!isLocalFile(source)) return Promise.resolve(source); - return resolveFileUrl(source, this.requireApi().token, model, { - ...opts, + const credential = this.requireApi(); + return resolveFileUrl(source, credential.token, model, { + signal: opts.signal, + baseUrl: this.url("", opts.defaultBaseUrl), identity: this.deps.identity, timeout: this.deps.settings.timeout, }); @@ -166,13 +172,13 @@ export class Client { resolveImageInput( source: string, model: string, - opts: { signal?: AbortSignal } = {}, + opts: { signal?: AbortSignal; defaultBaseUrl?: () => string } = {}, ): Promise { if (!isLocalFile(source)) return Promise.resolve(source); if (this.usesTokenPlanEndpoint()) { return Promise.resolve(imageFileToDataUri(source)); } - return this.uploadFile(source, model, { signal: opts.signal }); + return this.uploadFile(source, model, opts); } usesTokenPlanEndpoint(): boolean { diff --git a/packages/core/src/files/upload.ts b/packages/core/src/files/upload.ts index dc81c976..e7df81cf 100644 --- a/packages/core/src/files/upload.ts +++ b/packages/core/src/files/upload.ts @@ -11,9 +11,7 @@ import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts"; import { REGIONS } from "../config/schema.ts"; - -// Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement. -const UPLOAD_API = `${REGIONS.cn}/api/v1/uploads`; +import { normalizeModelBaseUrl } from "../config/model-base-url.ts"; interface UploadPolicy { upload_host: string; @@ -37,9 +35,11 @@ async function getUploadPolicy( apiKey: string, model: string, identity: TrackingIdentity, + baseUrl: string, signal?: AbortSignal, ): Promise { - const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`; + const uploadApi = `${normalizeModelBaseUrl(baseUrl)}/api/v1/uploads`; + const url = `${uploadApi}?action=getPolicy&model=${encodeURIComponent(model)}`; const policySignal = combineWithTimeout(15_000, signal); const res = await fetch(url, { headers: { @@ -53,7 +53,7 @@ async function getUploadPolicy( if (!res.ok) { const text = await res.text().catch(() => ""); throw new BailianError( - `Failed to get upload policy (HTTP ${res.status}): ${text}`, + `Failed to get upload policy (HTTP ${res.status}, ${uploadApi}): ${text}`, ExitCode.GENERAL, ); } @@ -108,6 +108,8 @@ async function uploadToOSS( export interface UploadOptions { apiKey: string; + /** Defaults to CN for direct SDK callers; Client supplies its resolved credential origin. */ + baseUrl?: string; model: string; filePath: string; identity: TrackingIdentity; @@ -163,7 +165,7 @@ export function redactDataUri(input: string): string { * The URL is valid for 48 hours. */ export async function uploadFile(opts: UploadOptions): Promise { - const { apiKey, model, filePath, identity, signal, timeout } = opts; + const { apiKey, baseUrl = REGIONS.cn, model, filePath, identity, signal, timeout } = opts; if (!existsSync(filePath)) { throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE); @@ -174,7 +176,7 @@ export async function uploadFile(opts: UploadOptions): Promise { throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE); } - const policy = await getUploadPolicy(apiKey, model, identity, signal); + const policy = await getUploadPolicy(apiKey, model, identity, baseUrl, signal); return uploadToOSS(policy, filePath, signal, timeout); } @@ -196,11 +198,12 @@ export async function resolveFileUrl( input: string, apiKey: string, model: string, - opts: { identity: TrackingIdentity; signal?: AbortSignal; timeout?: number }, + opts: { identity: TrackingIdentity; baseUrl?: string; signal?: AbortSignal; timeout?: number }, ): Promise { if (!isLocalFile(input)) return input; return uploadFile({ apiKey, + baseUrl: opts.baseUrl, model, filePath: input, identity: opts.identity, diff --git a/packages/core/tests/file-upload.test.ts b/packages/core/tests/file-upload.test.ts new file mode 100644 index 00000000..9dd35dba --- /dev/null +++ b/packages/core/tests/file-upload.test.ts @@ -0,0 +1,136 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { Client } from "../src/client/client.ts"; +import { REGIONS, type Settings } from "../src/config/schema.ts"; +import { ExitCode } from "../src/errors/codes.ts"; +import { resolveFileUrl, uploadFile } from "../src/files/upload.ts"; + +const identity = { + binName: "test-cli", + version: "test", + npmPackage: "test-cli", + clientName: "test-client", +}; +const settings: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; +const policy = { + upload_host: "https://uploads.example.test", + upload_dir: "temporary", + oss_access_key_id: "test-access-key", + signature: "test-signature", + policy: "test-policy", + x_oss_object_acl: "private", + x_oss_forbid_overwrite: "true", +}; +const fetchMock = vi.fn(); +let fixtureDir: string; +let fixturePath: string; + +beforeEach(() => { + fixtureDir = mkdtempSync(join(tmpdir(), "bailian-regional-upload-")); + fixturePath = join(fixtureDir, "sample.mp4"); + writeFileSync(fixturePath, "synthetic media fixture"); + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(Response.json({ data: policy })); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + rmSync(fixtureDir, { recursive: true, force: true }); +}); + +function makeClient(baseUrl: string): Client { + return new Client({ + identity, + settings, + baseUrl: REGIONS.cn, + apiCred: { token: "synthetic-profile-key", baseUrl, source: "config" }, + }); +} + +describe("regional temporary file uploads", () => { + test.each([ + REGIONS.cn, + REGIONS.intl, + REGIONS.us, + "https://ws-example.ap-southeast-1.maas.aliyuncs.com", + "https://proxy.example.test", + ])("pairs the upload policy host with the credential for %s", async (baseUrl) => { + await expect(makeClient(baseUrl).uploadFile(fixturePath, "wan3.0-video")).resolves.toBe( + "oss://temporary/sample.mp4", + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [policyUrl, policyOptions] = fetchMock.mock.calls[0]!; + expect(policyUrl).toBe(`${baseUrl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`); + expect(new Headers(policyOptions?.headers).get("Authorization")).toBe( + "Bearer synthetic-profile-key", + ); + const [uploadUrl, uploadOptions] = fetchMock.mock.calls[1]!; + expect(uploadUrl).toBe(policy.upload_host); + expect(uploadOptions?.method).toBe("POST"); + expect((uploadOptions?.body as FormData).get("key")).toBe("temporary/sample.mp4"); + expect(new Headers(uploadOptions?.headers).has("Authorization")).toBe(false); + }); + + test("keeps the CN default for existing direct uploadFile callers", async () => { + await uploadFile({ + apiKey: "synthetic-profile-key", + model: "wan3.0-video", + filePath: fixturePath, + identity, + }); + expect(fetchMock.mock.calls[0]![0]).toBe( + `${REGIONS.cn}/api/v1/uploads?action=getPolicy&model=wan3.0-video`, + ); + }); + + test("normalizes a supplied upload base URL using the shared origin contract", async () => { + const options = { + identity, + baseUrl: `${REGIONS.intl}/compatible-mode/v1/?unused=value#fragment`, + }; + await resolveFileUrl(fixturePath, "synthetic-profile-key", "wan3.0-video", options); + expect(fetchMock.mock.calls[0]![0]).toBe( + `${REGIONS.intl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`, + ); + }); + + test("preserves unsupported-service errors without retrying another region", async () => { + fetchMock.mockReset(); + const response = '{"code":"UnsupportedOperation","message":"Temporary uploads unavailable"}'; + fetchMock.mockResolvedValueOnce(new Response(response, { status: 404 })); + await expect( + makeClient(REGIONS.intl).uploadFile(fixturePath, "wan3.0-video"), + ).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: `Failed to get upload policy (HTTP 404, ${REGIONS.intl}/api/v1/uploads): ${response}`, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0]).toBe( + `${REGIONS.intl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`, + ); + }); + + test.each([ + "https://media.example.test/sample.mp4", + "oss://existing/sample.mp4", + "data:image/png;base64,AQID", + ])("passes %s through without credentials or requests", async (source) => { + const client = new Client({ identity, settings, baseUrl: REGIONS.intl }); + await expect(client.uploadFile(source, "wan3.0-video")).resolves.toBe(source); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});