From ca1989e175dffb2926a78de44323432b1ca16ae7 Mon Sep 17 00:00:00 2001 From: Uddoo <19704816+Uddoo@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:49:28 +0800 Subject: [PATCH 1/2] fix(files): use the active endpoint for temporary uploads Pass the resolved credential origin through the shared client and uploader. Keep the direct SDK CN default, show the effective policy endpoint on failure, and preserve service errors without cross-region retries. Fixes #267. AI disclosure: implemented, tested and self-reviewed by autonomous Codex on behalf of Uddoo. No human review or live provider validation is claimed. --- docs/agents/url-change.md | 10 +- packages/core/src/client/client.ts | 4 +- packages/core/src/files/upload.ts | 19 ++-- packages/core/tests/file-upload.test.ts | 136 ++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/file-upload.test.ts diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index d1bfc4c7..70e3bc14 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -23,10 +23,16 @@ 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 和自定义代理。 +上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应, +不自动换区域重试。 +如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。 + ## 必查清单 ### A. TS 源码(必须 import,不准硬编码) diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 2a549c89..796a70cc 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -151,8 +151,10 @@ export class Client { /** 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 { if (!isLocalFile(source)) return Promise.resolve(source); - return resolveFileUrl(source, this.requireApi().token, model, { + const credential = this.requireApi(); + return resolveFileUrl(source, credential.token, model, { ...opts, + baseUrl: credential.baseUrl, identity: this.deps.identity, timeout: this.deps.settings.timeout, }); 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(); + }); +}); From c0dfddec40cbe2327f40e0f866ee724cfdfb18bb Mon Sep 17 00:00:00 2001 From: Uddoo <19704816+Uddoo@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:13:45 +0800 Subject: [PATCH 2/2] fix(video): use the Prime workspace origin for uploads Honor the existing lazy service-default origin when uploading local files and keyframes for Prime video generation. Keep configured origins ahead of the workspace default and validate missing workspace before upload. Add command-level mocked HTTP regressions for workspace/default routing, explicit origins, both keyframes, and existing media URL passthrough. AI disclosure: autonomous Codex implemented, tested and self-reviewed this follow-up on behalf of Uddoo. No human review or live provider validation is claimed. --- docs/agents/url-change.md | 3 + .../commands/src/commands/video/generate.ts | 10 +- packages/commands/tests/prime.test.ts | 160 +++++++++++++++++- packages/core/src/client/client.ts | 16 +- 4 files changed, 179 insertions(+), 10 deletions(-) diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index 70e3bc14..6e3c86d9 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -29,6 +29,9 @@ core/files/upload.ts ← 文件上传 endpoint(与 model 域 ``` 临时文件上传与后续模型请求使用同一 API origin,包括国际区、Workspace 和自定义代理。 +Prime 视频命令通过 `Client.uploadFile` / `resolveImageInput` 的 `defaultBaseUrl` 回调, +复用 `resolvePrimeEndpoint` 推导默认 Workspace origin;显式配置的 API origin 仍优先, +已有媒体 URL 不触发上传或求值上传默认地址。 上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应, 不自动换区域重试。 如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。 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 796a70cc..78d1c798 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -148,13 +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); const credential = this.requireApi(); return resolveFileUrl(source, credential.token, model, { - ...opts, - baseUrl: credential.baseUrl, + signal: opts.signal, + baseUrl: this.url("", opts.defaultBaseUrl), identity: this.deps.identity, timeout: this.deps.settings.timeout, }); @@ -168,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 {