Skip to content

Commit 0d449ca

Browse files
authored
Merge pull request #271 from Uddoo/fix/267-regional-file-upload
fix(files): use the active endpoint for temporary uploads
2 parents 12806ec + 7bc4851 commit 0d449ca

6 files changed

Lines changed: 513 additions & 22 deletions

File tree

‎docs/agents/url-change.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,24 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
2323
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
2424
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
2525
26-
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
27-
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
26+
core/files/upload.ts ← model baseUrl → 同源临时上传 endpoint
27+
北京/新加坡公共站 保持当前公共站 origin + /api/v1/uploads
28+
北京/新加坡 Workspace 保持当前 Workspace origin + /api/v1/uploads
29+
显式自定义代理 保持代理 origin,不改写到公共站
30+
其他官方地域 / Token Plan 联网前报 usage error,引导使用 HTTPS URL
31+
UploadOptions.baseUrl 直接 SDK 调用未提供时保持北京公共站默认值
2832
```
2933

34+
Prime 视频命令通过 `Client.uploadFile` / `resolveImageInput` 的 `defaultBaseUrl` 回调,
35+
复用 `resolvePrimeEndpoint` 推导 model Workspace origin;上传策略与模型提交均保持该
36+
Workspace origin。显式配置的 model origin 仍优先;已有媒体 URL 不触发上传或求值上传默认地址。
37+
38+
官方临时文件上传当前只支持 `cn-beijing` 与 `ap-southeast-1`。Token Plan 主机,以及这些官方
39+
endpoint 上的 `sk-sp-` 密钥,在发送 API Key 前失败。显式自定义代理视为自身路由的权威来源,
40+
保持同源且不按密钥前缀拦截,由代理/服务决定能力。任何路径都不得回退公共站或跨 origin 重试;
41+
不支持时用户可改用服务可访问的 HTTPS 媒体 URL。服务端错误保留 message/code/request ID/raw
42+
response,并在 message 的结构化上下文中展示本次有效上传 endpoint。
43+
3044
## 必查清单
3145

3246
### A. TS 源码(必须 import,不准硬编码)

‎packages/commands/src/commands/video/generate.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,18 @@ export default defineCommand({
203203
);
204204
}
205205

206+
const uploadOptions = flags.prime
207+
? { defaultBaseUrl: () => resolvePrimeEndpoint(ctx, "") }
208+
: undefined;
209+
206210
// Auto-upload local image file for i2v
207211
let resolvedImageUrl: string | undefined;
208212
if (imageUrl) {
209-
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
213+
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model, uploadOptions);
210214
}
211215
let resolvedLastFrameUrl: string | undefined;
212216
if (lastFrameUrl) {
213-
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model);
217+
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model, uploadOptions);
214218
}
215219

216220
// kf2v mode: both --image and --last-frame provided.
@@ -225,7 +229,7 @@ export default defineCommand({
225229

226230
let resolvedFileUrl: string | undefined;
227231
if (fileUrl) {
228-
resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model);
232+
resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model, uploadOptions);
229233
}
230234

231235
const watermark = resolveWatermark(flags.watermark, settings.watermark);

‎packages/commands/tests/prime.test.ts‎

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { describe, expect, test } from "vite-plus/test";
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { Client, ExitCode, REGIONS, type ApiKeyCredential, type Settings } from "bailian-cli-core";
5+
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
26
import { resolvePrimeEndpoint, validatePrimeFlags } from "../src/commands/shared/prime.ts";
7+
import videoGenerate from "../src/commands/video/generate.ts";
38

49
function createUrlResolver(baseUrl: string, usesDefaultBaseUrl: boolean) {
510
return {
@@ -48,3 +53,190 @@ describe("Prime command helpers", () => {
4853
);
4954
});
5055
});
56+
57+
describe("Prime video upload origins", () => {
58+
const identity = {
59+
binName: "test-cli",
60+
version: "test",
61+
npmPackage: "test-cli",
62+
clientName: "test-client",
63+
};
64+
const settings: Settings = {
65+
output: "json",
66+
outputExplicit: true,
67+
timeout: 30,
68+
watermark: true,
69+
verbose: false,
70+
quiet: true,
71+
dryRun: false,
72+
telemetry: false,
73+
};
74+
const workspaceOrigin = "https://ws_test.cn-beijing.maas.aliyuncs.com";
75+
const policy = {
76+
upload_host: "https://uploads.example.test",
77+
upload_dir: "temporary",
78+
oss_access_key_id: "test-access-key",
79+
signature: "test-signature",
80+
policy: "test-policy",
81+
x_oss_object_acl: "private",
82+
x_oss_forbid_overwrite: "true",
83+
};
84+
const fetchMock = vi.fn<typeof fetch>();
85+
let fixtureDir: string;
86+
let fixturePath: string;
87+
88+
beforeEach(() => {
89+
fixtureDir = mkdtempSync(join(tmpdir(), "bailian-prime-upload-"));
90+
fixturePath = join(fixtureDir, "sample.png");
91+
writeFileSync(fixturePath, "synthetic media fixture");
92+
fetchMock.mockReset();
93+
fetchMock.mockImplementation(async (input) => {
94+
const url = new URL(input instanceof Request ? input.url : input);
95+
if (url.pathname === "/api/v1/uploads") {
96+
return Response.json({ data: policy });
97+
}
98+
if (url.origin === policy.upload_host) return new Response(null, { status: 200 });
99+
if (url.pathname === "/api/v1/services/aigc/video-generation/video-synthesis") {
100+
return Response.json({ output: { task_id: "synthetic-task" } });
101+
}
102+
throw new Error(`Unexpected test request: ${url.href}`);
103+
});
104+
vi.stubGlobal("fetch", fetchMock);
105+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
106+
});
107+
108+
afterEach(() => {
109+
vi.restoreAllMocks();
110+
vi.unstubAllGlobals();
111+
rmSync(fixtureDir, { recursive: true, force: true });
112+
});
113+
114+
function runVideo(
115+
flags: Record<string, unknown>,
116+
options: {
117+
baseUrl?: string;
118+
settingsWorkspaceId?: string;
119+
credentialSource?: ApiKeyCredential["source"];
120+
} = {},
121+
) {
122+
const baseUrl = options.baseUrl ?? REGIONS.cn;
123+
const commandSettings = { ...settings, workspaceId: options.settingsWorkspaceId };
124+
return videoGenerate.run({
125+
identity,
126+
settings: commandSettings,
127+
flags: {
128+
prime: true,
129+
model: "wan3.0-video",
130+
prompt: "synthetic video",
131+
async: true,
132+
...flags,
133+
},
134+
client: new Client({
135+
identity,
136+
settings: commandSettings,
137+
baseUrl,
138+
baseUrlIsDefault: options.baseUrl === undefined,
139+
apiCred: {
140+
token: "synthetic-workspace-key",
141+
baseUrl,
142+
source: options.credentialSource ?? "flag",
143+
},
144+
}),
145+
} as never);
146+
}
147+
148+
function expectRequestOrigins(options: {
149+
uploadOrigin: string;
150+
submitOrigin: string;
151+
uploadCount?: number;
152+
}) {
153+
const { uploadOrigin, submitOrigin, uploadCount = 1 } = options;
154+
const policyCalls = fetchMock.mock.calls.filter(([input]) => {
155+
const url = input instanceof Request ? input.url : input.toString();
156+
return url.includes("/api/v1/uploads?");
157+
});
158+
expect(policyCalls).toHaveLength(uploadCount);
159+
for (const [policyUrl, policyOptions] of policyCalls) {
160+
expect(policyUrl).toBe(`${uploadOrigin}/api/v1/uploads?action=getPolicy&model=wan3.0-video`);
161+
expect(new Headers(policyOptions?.headers).get("Authorization")).toBe(
162+
"Bearer synthetic-workspace-key",
163+
);
164+
}
165+
const [submitUrl, submitOptions] = fetchMock.mock.calls.at(-1)!;
166+
expect(submitUrl).toBe(`${submitOrigin}/api/v1/services/aigc/video-generation/video-synthesis`);
167+
expect(new Headers(submitOptions?.headers).get("Authorization")).toBe(
168+
"Bearer synthetic-workspace-key",
169+
);
170+
expect(fetchMock).toHaveBeenCalledTimes(uploadCount * 2 + 1);
171+
}
172+
173+
test.each(["flag", "env"] as const)(
174+
"uploads local files to the derived workspace with a %s key",
175+
async (credentialSource) => {
176+
await runVideo(
177+
{ file: fixturePath, ...(credentialSource === "flag" ? { workspaceId: "ws_test" } : {}) },
178+
{
179+
credentialSource,
180+
settingsWorkspaceId: credentialSource === "env" ? "ws_test" : undefined,
181+
},
182+
);
183+
expectRequestOrigins({ uploadOrigin: workspaceOrigin, submitOrigin: workspaceOrigin });
184+
},
185+
);
186+
187+
test("uploads both local keyframes to the derived workspace", async () => {
188+
await runVideo({ image: fixturePath, lastFrame: fixturePath, workspaceId: "ws_test" });
189+
expectRequestOrigins({
190+
uploadOrigin: workspaceOrigin,
191+
submitOrigin: workspaceOrigin,
192+
uploadCount: 2,
193+
});
194+
});
195+
196+
test.each([
197+
[REGIONS.cn, REGIONS.cn],
198+
[REGIONS.intl, REGIONS.intl],
199+
[
200+
"https://configured-workspace.cn-beijing.maas.aliyuncs.com",
201+
"https://configured-workspace.cn-beijing.maas.aliyuncs.com",
202+
],
203+
[
204+
"https://configured-workspace.ap-southeast-1.maas.aliyuncs.com",
205+
"https://configured-workspace.ap-southeast-1.maas.aliyuncs.com",
206+
],
207+
["https://proxy.example.test", "https://proxy.example.test"],
208+
])(
209+
"routes configured origin %s through upload service %s without requiring a workspace",
210+
async (baseUrl, uploadOrigin) => {
211+
await runVideo({ file: fixturePath }, { baseUrl });
212+
expectRequestOrigins({ uploadOrigin, submitOrigin: baseUrl });
213+
},
214+
);
215+
216+
test.each([REGIONS.us, "https://configured-workspace.us-east-1.maas.aliyuncs.com"])(
217+
"rejects unsupported local upload origin %s before submitting",
218+
async (baseUrl) => {
219+
await expect(runVideo({ file: fixturePath }, { baseUrl })).rejects.toMatchObject({
220+
exitCode: ExitCode.USAGE,
221+
hint: expect.stringContaining("HTTPS media URL"),
222+
});
223+
expect(fetchMock).not.toHaveBeenCalled();
224+
},
225+
);
226+
227+
test("rejects a missing default workspace before making an upload request", async () => {
228+
await expect(runVideo({ file: fixturePath })).rejects.toMatchObject({
229+
message: "Workspace ID is required for the default Prime endpoint.",
230+
});
231+
expect(fetchMock).not.toHaveBeenCalled();
232+
});
233+
234+
test("passes an existing media URL through without uploading", async () => {
235+
await runVideo({ file: "https://media.example.test/video.mp4", workspaceId: "ws_test" });
236+
expectRequestOrigins({
237+
uploadOrigin: workspaceOrigin,
238+
submitOrigin: workspaceOrigin,
239+
uploadCount: 0,
240+
});
241+
});
242+
});

‎packages/core/src/client/client.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,17 @@ export class Client {
148148
return requestJson<T>(this.http, this.toOpts(opts));
149149
}
150150

151-
/** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */
152-
uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise<string> {
151+
/** Resolve a file arg, honoring a lazy service default origin just like {@link url}. */
152+
uploadFile(
153+
source: string,
154+
model: string,
155+
opts: { signal?: AbortSignal; defaultBaseUrl?: () => string } = {},
156+
): Promise<string> {
153157
if (!isLocalFile(source)) return Promise.resolve(source);
154-
return resolveFileUrl(source, this.requireApi().token, model, {
155-
...opts,
158+
const credential = this.requireApi();
159+
return resolveFileUrl(source, credential.token, model, {
160+
signal: opts.signal,
161+
baseUrl: this.url("", opts.defaultBaseUrl),
156162
identity: this.deps.identity,
157163
timeout: this.deps.settings.timeout,
158164
});
@@ -166,13 +172,13 @@ export class Client {
166172
resolveImageInput(
167173
source: string,
168174
model: string,
169-
opts: { signal?: AbortSignal } = {},
175+
opts: { signal?: AbortSignal; defaultBaseUrl?: () => string } = {},
170176
): Promise<string> {
171177
if (!isLocalFile(source)) return Promise.resolve(source);
172178
if (this.usesTokenPlanEndpoint()) {
173179
return Promise.resolve(imageFileToDataUri(source));
174180
}
175-
return this.uploadFile(source, model, { signal: opts.signal });
181+
return this.uploadFile(source, model, opts);
176182
}
177183

178184
usesTokenPlanEndpoint(): boolean {

0 commit comments

Comments
 (0)