Skip to content

Commit ca1989e

Browse files
committed
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.
1 parent 3638180 commit ca1989e

4 files changed

Lines changed: 158 additions & 11 deletions

File tree

‎docs/agents/url-change.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@ 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 ← 文件上传 endpoint(与 model 域凭证同源)
27+
UploadOptions.baseUrl Client 传入有效凭证的 baseUrl,经 normalizeModelBaseUrl 取 origin
28+
/api/v1/uploads 直接 SDK 调用未提供 baseUrl 时保持 REGIONS.cn 默认值
2829
```
2930

31+
临时文件上传与后续模型请求使用同一 API origin,包括国际区、Workspace 和自定义代理。
32+
上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应,
33+
不自动换区域重试。
34+
如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。
35+
3036
## 必查清单
3137

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

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,10 @@ export class Client {
151151
/** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */
152152
uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise<string> {
153153
if (!isLocalFile(source)) return Promise.resolve(source);
154-
return resolveFileUrl(source, this.requireApi().token, model, {
154+
const credential = this.requireApi();
155+
return resolveFileUrl(source, credential.token, model, {
155156
...opts,
157+
baseUrl: credential.baseUrl,
156158
identity: this.deps.identity,
157159
timeout: this.deps.settings.timeout,
158160
});

‎packages/core/src/files/upload.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import { BailianError } from "../errors/base.ts";
1111
import { ExitCode } from "../errors/codes.ts";
1212
import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts";
1313
import { REGIONS } from "../config/schema.ts";
14-
15-
// Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement.
16-
const UPLOAD_API = `${REGIONS.cn}/api/v1/uploads`;
14+
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
1715

1816
interface UploadPolicy {
1917
upload_host: string;
@@ -37,9 +35,11 @@ async function getUploadPolicy(
3735
apiKey: string,
3836
model: string,
3937
identity: TrackingIdentity,
38+
baseUrl: string,
4039
signal?: AbortSignal,
4140
): Promise<UploadPolicy> {
42-
const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`;
41+
const uploadApi = `${normalizeModelBaseUrl(baseUrl)}/api/v1/uploads`;
42+
const url = `${uploadApi}?action=getPolicy&model=${encodeURIComponent(model)}`;
4343
const policySignal = combineWithTimeout(15_000, signal);
4444
const res = await fetch(url, {
4545
headers: {
@@ -53,7 +53,7 @@ async function getUploadPolicy(
5353
if (!res.ok) {
5454
const text = await res.text().catch(() => "");
5555
throw new BailianError(
56-
`Failed to get upload policy (HTTP ${res.status}): ${text}`,
56+
`Failed to get upload policy (HTTP ${res.status}, ${uploadApi}): ${text}`,
5757
ExitCode.GENERAL,
5858
);
5959
}
@@ -108,6 +108,8 @@ async function uploadToOSS(
108108

109109
export interface UploadOptions {
110110
apiKey: string;
111+
/** Defaults to CN for direct SDK callers; Client supplies its resolved credential origin. */
112+
baseUrl?: string;
111113
model: string;
112114
filePath: string;
113115
identity: TrackingIdentity;
@@ -163,7 +165,7 @@ export function redactDataUri(input: string): string {
163165
* The URL is valid for 48 hours.
164166
*/
165167
export async function uploadFile(opts: UploadOptions): Promise<string> {
166-
const { apiKey, model, filePath, identity, signal, timeout } = opts;
168+
const { apiKey, baseUrl = REGIONS.cn, model, filePath, identity, signal, timeout } = opts;
167169

168170
if (!existsSync(filePath)) {
169171
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
@@ -174,7 +176,7 @@ export async function uploadFile(opts: UploadOptions): Promise<string> {
174176
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
175177
}
176178

177-
const policy = await getUploadPolicy(apiKey, model, identity, signal);
179+
const policy = await getUploadPolicy(apiKey, model, identity, baseUrl, signal);
178180
return uploadToOSS(policy, filePath, signal, timeout);
179181
}
180182

@@ -196,11 +198,12 @@ export async function resolveFileUrl(
196198
input: string,
197199
apiKey: string,
198200
model: string,
199-
opts: { identity: TrackingIdentity; signal?: AbortSignal; timeout?: number },
201+
opts: { identity: TrackingIdentity; baseUrl?: string; signal?: AbortSignal; timeout?: number },
200202
): Promise<string> {
201203
if (!isLocalFile(input)) return input;
202204
return uploadFile({
203205
apiKey,
206+
baseUrl: opts.baseUrl,
204207
model,
205208
filePath: input,
206209
identity: opts.identity,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
5+
import { Client } from "../src/client/client.ts";
6+
import { REGIONS, type Settings } from "../src/config/schema.ts";
7+
import { ExitCode } from "../src/errors/codes.ts";
8+
import { resolveFileUrl, uploadFile } from "../src/files/upload.ts";
9+
10+
const identity = {
11+
binName: "test-cli",
12+
version: "test",
13+
npmPackage: "test-cli",
14+
clientName: "test-client",
15+
};
16+
const settings: Settings = {
17+
output: "json",
18+
outputExplicit: true,
19+
timeout: 30,
20+
watermark: true,
21+
verbose: false,
22+
quiet: true,
23+
dryRun: false,
24+
telemetry: false,
25+
};
26+
const policy = {
27+
upload_host: "https://uploads.example.test",
28+
upload_dir: "temporary",
29+
oss_access_key_id: "test-access-key",
30+
signature: "test-signature",
31+
policy: "test-policy",
32+
x_oss_object_acl: "private",
33+
x_oss_forbid_overwrite: "true",
34+
};
35+
const fetchMock = vi.fn<typeof fetch>();
36+
let fixtureDir: string;
37+
let fixturePath: string;
38+
39+
beforeEach(() => {
40+
fixtureDir = mkdtempSync(join(tmpdir(), "bailian-regional-upload-"));
41+
fixturePath = join(fixtureDir, "sample.mp4");
42+
writeFileSync(fixturePath, "synthetic media fixture");
43+
fetchMock.mockReset();
44+
fetchMock.mockResolvedValueOnce(Response.json({ data: policy }));
45+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
46+
vi.stubGlobal("fetch", fetchMock);
47+
});
48+
49+
afterEach(() => {
50+
vi.unstubAllGlobals();
51+
rmSync(fixtureDir, { recursive: true, force: true });
52+
});
53+
54+
function makeClient(baseUrl: string): Client {
55+
return new Client({
56+
identity,
57+
settings,
58+
baseUrl: REGIONS.cn,
59+
apiCred: { token: "synthetic-profile-key", baseUrl, source: "config" },
60+
});
61+
}
62+
63+
describe("regional temporary file uploads", () => {
64+
test.each([
65+
REGIONS.cn,
66+
REGIONS.intl,
67+
REGIONS.us,
68+
"https://ws-example.ap-southeast-1.maas.aliyuncs.com",
69+
"https://proxy.example.test",
70+
])("pairs the upload policy host with the credential for %s", async (baseUrl) => {
71+
await expect(makeClient(baseUrl).uploadFile(fixturePath, "wan3.0-video")).resolves.toBe(
72+
"oss://temporary/sample.mp4",
73+
);
74+
75+
expect(fetchMock).toHaveBeenCalledTimes(2);
76+
const [policyUrl, policyOptions] = fetchMock.mock.calls[0]!;
77+
expect(policyUrl).toBe(`${baseUrl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`);
78+
expect(new Headers(policyOptions?.headers).get("Authorization")).toBe(
79+
"Bearer synthetic-profile-key",
80+
);
81+
const [uploadUrl, uploadOptions] = fetchMock.mock.calls[1]!;
82+
expect(uploadUrl).toBe(policy.upload_host);
83+
expect(uploadOptions?.method).toBe("POST");
84+
expect((uploadOptions?.body as FormData).get("key")).toBe("temporary/sample.mp4");
85+
expect(new Headers(uploadOptions?.headers).has("Authorization")).toBe(false);
86+
});
87+
88+
test("keeps the CN default for existing direct uploadFile callers", async () => {
89+
await uploadFile({
90+
apiKey: "synthetic-profile-key",
91+
model: "wan3.0-video",
92+
filePath: fixturePath,
93+
identity,
94+
});
95+
expect(fetchMock.mock.calls[0]![0]).toBe(
96+
`${REGIONS.cn}/api/v1/uploads?action=getPolicy&model=wan3.0-video`,
97+
);
98+
});
99+
100+
test("normalizes a supplied upload base URL using the shared origin contract", async () => {
101+
const options = {
102+
identity,
103+
baseUrl: `${REGIONS.intl}/compatible-mode/v1/?unused=value#fragment`,
104+
};
105+
await resolveFileUrl(fixturePath, "synthetic-profile-key", "wan3.0-video", options);
106+
expect(fetchMock.mock.calls[0]![0]).toBe(
107+
`${REGIONS.intl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`,
108+
);
109+
});
110+
111+
test("preserves unsupported-service errors without retrying another region", async () => {
112+
fetchMock.mockReset();
113+
const response = '{"code":"UnsupportedOperation","message":"Temporary uploads unavailable"}';
114+
fetchMock.mockResolvedValueOnce(new Response(response, { status: 404 }));
115+
await expect(
116+
makeClient(REGIONS.intl).uploadFile(fixturePath, "wan3.0-video"),
117+
).rejects.toMatchObject({
118+
exitCode: ExitCode.GENERAL,
119+
message: `Failed to get upload policy (HTTP 404, ${REGIONS.intl}/api/v1/uploads): ${response}`,
120+
});
121+
expect(fetchMock).toHaveBeenCalledTimes(1);
122+
expect(fetchMock.mock.calls[0]![0]).toBe(
123+
`${REGIONS.intl}/api/v1/uploads?action=getPolicy&model=wan3.0-video`,
124+
);
125+
});
126+
127+
test.each([
128+
"https://media.example.test/sample.mp4",
129+
"oss://existing/sample.mp4",
130+
"data:image/png;base64,AQID",
131+
])("passes %s through without credentials or requests", async (source) => {
132+
const client = new Client({ identity, settings, baseUrl: REGIONS.intl });
133+
await expect(client.uploadFile(source, "wan3.0-video")).resolves.toBe(source);
134+
expect(fetchMock).not.toHaveBeenCalled();
135+
});
136+
});

0 commit comments

Comments
 (0)