Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/agents/url-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>

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 和自定义代理。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for Prime's derived workspace upload origin

When bl video generate --prime --workspace-id <id> --model wan3.0-video --file <local> uses a workspace-only key from --api-key or the environment and no base URL, credential.baseUrl still defaults to CN, so the upload at packages/commands/src/commands/video/generate.ts:228 occurs before line 235 derives the workspace submit URL. The policy request therefore still goes to CN while the model request would go to the workspace, making this new same-origin invariant false and leaving that supported workspace path broken; resolve and pass the Prime origin before uploading or qualify the guide.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autonomous Codex follow-up on behalf of Uddoo: fixed in c0dfdde.

Confirmed with a command-level regression: on ca1989e, 4 tests failed because local files and keyframes used CN, and a missing Workspace was checked only after uploading. The uploader now honors the same lazy service-default origin as Client.url; Prime video passes its existing Workspace resolver for local files and both keyframes. Explicitly configured origins still win without requiring a Workspace, and existing media URLs pass through.

The 35 focused regressions pass. On isolated Ubuntu 24.04 with Node 22.23.3, pnpm 10.33.2 and Bun 1.3.14, skill asset sync, all package builds, and pnpm check pass (4 warnings in unchanged files); the full suite reports 3157 passed, 245 skipped, 0 failed. All six committed PR files match the Linux test copy byte-for-byte. Commit hooks ran normally. Windows full-suite limitations remain documented in the updated PR description.

All HTTP responses were mocked with synthetic keys; no live Workspace/Singapore upload availability, paid provider call, human review, or manual verification is claimed. The review conversation remains for the reviewer to confirm.

Prime 视频命令通过 `Client.uploadFile` / `resolveImageInput` 的 `defaultBaseUrl` 回调,
复用 `resolvePrimeEndpoint` 推导默认 Workspace origin;显式配置的 API origin 仍优先,
已有媒体 URL 不触发上传或求值上传默认地址。
上传服务的区域/模型支持由服务端决定:错误中附上有效上传 endpoint 并保留原始服务响应,
不自动换区域重试。
如果所选服务不支持临时上传,使用服务可访问的 HTTPS 媒体 URL;已有 URL 会跳过本地上传。

## 必查清单

### A. TS 源码(必须 import,不准硬编码)
Expand Down
10 changes: 7 additions & 3 deletions packages/commands/src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
160 changes: 159 additions & 1 deletion packages/commands/tests/prime.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<typeof fetch>();
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<string, unknown>,
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);
});
});
18 changes: 12 additions & 6 deletions packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,17 @@ export class Client {
return requestJson<T>(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<string> {
/** 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<string> {
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,
});
Expand All @@ -166,13 +172,13 @@ export class Client {
resolveImageInput(
source: string,
model: string,
opts: { signal?: AbortSignal } = {},
opts: { signal?: AbortSignal; defaultBaseUrl?: () => string } = {},
): Promise<string> {
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 {
Expand Down
19 changes: 11 additions & 8 deletions packages/core/src/files/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,9 +35,11 @@ async function getUploadPolicy(
apiKey: string,
model: string,
identity: TrackingIdentity,
baseUrl: string,
signal?: AbortSignal,
): Promise<UploadPolicy> {
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: {
Expand All @@ -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,
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -163,7 +165,7 @@ export function redactDataUri(input: string): string {
* The URL is valid for 48 hours.
*/
export async function uploadFile(opts: UploadOptions): Promise<string> {
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);
Expand All @@ -174,7 +176,7 @@ export async function uploadFile(opts: UploadOptions): Promise<string> {
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);
}

Expand All @@ -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<string> {
if (!isLocalFile(input)) return input;
return uploadFile({
apiKey,
baseUrl: opts.baseUrl,
model,
filePath: input,
identity: opts.identity,
Expand Down
Loading