mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge pull request #114 from modelstudioai/feat/token-plan-default-models
feat: update Token Plan defaults and support local image inputs
This commit is contained in:
@@ -33,6 +33,9 @@ export type AuthPersistPatch = Pick<
|
||||
| "console_switch_agent"
|
||||
| "workspace_id"
|
||||
| "default_text_model"
|
||||
| "default_video_model"
|
||||
| "default_image_to_video_model"
|
||||
| "default_reference_to_video_model"
|
||||
| "default_image_model"
|
||||
>;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
|
||||
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
|
||||
import { isLocalFile, resolveFileUrl } from "../files/upload.ts";
|
||||
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
|
||||
import { McpClient } from "./mcp.ts";
|
||||
import { callConsoleGateway } from "../console/gateway.ts";
|
||||
import { refreshAccessToken } from "../auth/refresh-token.ts";
|
||||
@@ -118,6 +118,32 @@ export class Client {
|
||||
return resolveFileUrl(source, this.requireApi().token, model, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an image input while keeping Token Plan's upload limitation isolated.
|
||||
* Token Plan local images are sent as Data URIs; every other connection keeps
|
||||
* the established temporary OSS upload flow. URLs and existing Data URIs pass through.
|
||||
*/
|
||||
resolveImageInput(
|
||||
source: string,
|
||||
model: string,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
): 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 });
|
||||
}
|
||||
|
||||
usesTokenPlanEndpoint(): boolean {
|
||||
if (this.deps.settings.configName === "token-plan") return true;
|
||||
try {
|
||||
return /^token-plan\.[a-z0-9-]+\.maas\.aliyuncs\.com$/i.test(new URL(this.baseUrl).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Open an MCP client. Accepts a path (prepended with the model baseUrl) or an absolute URL. */
|
||||
mcp(pathOrUrl: string): McpClient {
|
||||
const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : this.requireApi().baseUrl + pathOrUrl;
|
||||
|
||||
@@ -235,6 +235,8 @@ export function buildSettings(s: ResolutionSources): Settings {
|
||||
timeout,
|
||||
defaultTextModel: file.default_text_model,
|
||||
defaultVideoModel: file.default_video_model,
|
||||
defaultImageToVideoModel: file.default_image_to_video_model,
|
||||
defaultReferenceToVideoModel: file.default_reference_to_video_model,
|
||||
defaultImageModel: file.default_image_model,
|
||||
defaultSpeechModel: file.default_speech_model,
|
||||
defaultOmniModel: file.default_omni_model,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
interface ModelProfilePreset {
|
||||
baseUrl: string;
|
||||
defaultTextModel: string;
|
||||
defaultVideoModel: string;
|
||||
defaultImageToVideoModel: string;
|
||||
defaultReferenceToVideoModel: string;
|
||||
defaultImageModel: string;
|
||||
}
|
||||
|
||||
const MODEL_PROFILE_PRESETS: Readonly<Record<string, ModelProfilePreset>> = {
|
||||
"token-plan": {
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
defaultTextModel: "qwen3.7-max",
|
||||
defaultImageModel: "qwen-image-2.0",
|
||||
defaultTextModel: "qwen3.8-max-preview",
|
||||
defaultVideoModel: "happyhorse-1.1-t2v",
|
||||
defaultImageToVideoModel: "happyhorse-1.1-i2v",
|
||||
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
|
||||
defaultImageModel: "wan2.7-image",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ConfigFile {
|
||||
timeout?: number;
|
||||
default_text_model?: string;
|
||||
default_video_model?: string;
|
||||
default_image_to_video_model?: string;
|
||||
default_reference_to_video_model?: string;
|
||||
default_image_model?: string;
|
||||
default_speech_model?: string;
|
||||
default_omni_model?: string;
|
||||
@@ -54,6 +56,8 @@ export const CONFIG_FILE_KEYS = [
|
||||
"timeout",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_to_video_model",
|
||||
"default_reference_to_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
@@ -117,6 +121,16 @@ export function parseConfigFile(raw: unknown): ConfigFile {
|
||||
out.default_text_model = obj.default_text_model;
|
||||
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
|
||||
out.default_video_model = obj.default_video_model;
|
||||
if (
|
||||
typeof obj.default_image_to_video_model === "string" &&
|
||||
obj.default_image_to_video_model.length > 0
|
||||
)
|
||||
out.default_image_to_video_model = obj.default_image_to_video_model;
|
||||
if (
|
||||
typeof obj.default_reference_to_video_model === "string" &&
|
||||
obj.default_reference_to_video_model.length > 0
|
||||
)
|
||||
out.default_reference_to_video_model = obj.default_reference_to_video_model;
|
||||
if (typeof obj.default_image_model === "string" && obj.default_image_model.length > 0)
|
||||
out.default_image_model = obj.default_image_model;
|
||||
if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0)
|
||||
@@ -166,6 +180,8 @@ export interface Settings {
|
||||
timeout: number;
|
||||
defaultTextModel?: string;
|
||||
defaultVideoModel?: string;
|
||||
defaultImageToVideoModel?: string;
|
||||
defaultReferenceToVideoModel?: string;
|
||||
defaultImageModel?: string;
|
||||
defaultSpeechModel?: string;
|
||||
defaultOmniModel?: string;
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { uploadFile, isLocalFile, resolveFileUrl } from "./upload.ts";
|
||||
export {
|
||||
uploadFile,
|
||||
isLocalFile,
|
||||
resolveFileUrl,
|
||||
imageFileToDataUri,
|
||||
redactDataUri,
|
||||
} from "./upload.ts";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* X-DashScope-OssResourceResolve: enable
|
||||
*/
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { basename, extname } from "path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { trackingHeaders } from "../client/headers.ts";
|
||||
@@ -112,6 +112,49 @@ export interface UploadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
/** Encode a local image as a Data URI. */
|
||||
export function imageFileToDataUri(filePath: string): string {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const mimeType = IMAGE_MIME_TYPES[extension];
|
||||
if (!mimeType) {
|
||||
throw new BailianError(
|
||||
`Unsupported image format "${extension || "unknown"}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use an image file with a recognized extension.",
|
||||
);
|
||||
}
|
||||
|
||||
const encoded = readFileSync(filePath).toString("base64");
|
||||
return `data:${mimeType};base64,${encoded}`;
|
||||
}
|
||||
|
||||
/** Keep dry-run output readable and avoid echoing the complete inline image. */
|
||||
export function redactDataUri(input: string): string {
|
||||
const match = /^data:([^;,]+);base64,/i.exec(input);
|
||||
return match ? `data:${match[1]};base64,<omitted>` : input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file to DashScope temporary storage and return the oss:// URL.
|
||||
* The URL is valid for 48 hours.
|
||||
|
||||
@@ -32,8 +32,11 @@ const resolve = (s: Parameters<typeof src>[0]): Settings => buildSettings(src(s)
|
||||
test("token-plan Profile 预设保持固定", () => {
|
||||
expect(getModelProfilePreset("token-plan")).toEqual({
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
defaultTextModel: "qwen3.7-max",
|
||||
defaultImageModel: "qwen-image-2.0",
|
||||
defaultTextModel: "qwen3.8-max-preview",
|
||||
defaultVideoModel: "happyhorse-1.1-t2v",
|
||||
defaultImageToVideoModel: "happyhorse-1.1-i2v",
|
||||
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
|
||||
defaultImageModel: "wan2.7-image",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,10 +319,18 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成
|
||||
|
||||
test("default*Model / outputDir:仅 file 源", () => {
|
||||
const c = resolve({
|
||||
file: { default_text_model: "qwen-max", default_video_model: "wan-x", output_dir: "/tmp/out" },
|
||||
file: parseConfigFile({
|
||||
default_text_model: "qwen-max",
|
||||
default_video_model: "wan-t2v",
|
||||
default_image_to_video_model: "wan-i2v",
|
||||
default_reference_to_video_model: "wan-r2v",
|
||||
output_dir: "/tmp/out",
|
||||
}),
|
||||
});
|
||||
expect(c.defaultTextModel).toBe("qwen-max");
|
||||
expect(c.defaultVideoModel).toBe("wan-x");
|
||||
expect(c.defaultVideoModel).toBe("wan-t2v");
|
||||
expect(c.defaultImageToVideoModel).toBe("wan-i2v");
|
||||
expect(c.defaultReferenceToVideoModel).toBe("wan-r2v");
|
||||
expect(c.outputDir).toBe("/tmp/out");
|
||||
expect(resolve({}).defaultTextModel).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { Client } from "../src/client/client.ts";
|
||||
import { imageFileToDataUri, redactDataUri } from "../src/files/upload.ts";
|
||||
import type { Settings } from "../src/config/schema.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeImage(extension = ".png", content = Buffer.from([1, 2, 3, 4])): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "bailian-image-input-"));
|
||||
tempDirs.push(tempDir);
|
||||
const filePath = join(tempDir, `input${extension}`);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function makeSettings(configName?: string): Settings {
|
||||
return {
|
||||
configName,
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
telemetry: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(baseUrl: string, configName?: string): Client {
|
||||
return new Client({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: makeSettings(configName),
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Token Plan image input compatibility", () => {
|
||||
test("encodes supported local images and redacts previews", () => {
|
||||
const imagePath = makeImage(".png");
|
||||
const dataUri = imageFileToDataUri(imagePath);
|
||||
|
||||
expect(dataUri).toBe("data:image/png;base64,AQIDBA==");
|
||||
expect(redactDataUri(dataUri)).toBe("data:image/png;base64,<omitted>");
|
||||
});
|
||||
|
||||
test("rejects files whose image MIME type cannot be inferred", () => {
|
||||
const imagePath = makeImage(".unknown");
|
||||
expect(() => imageFileToDataUri(imagePath)).toThrow(/Unsupported image format/);
|
||||
});
|
||||
|
||||
test("uses Data URI for the token-plan profile even through a custom proxy", async () => {
|
||||
const imagePath = makeImage(".webp");
|
||||
const client = makeClient("https://proxy.example.com/bailian", "token-plan");
|
||||
|
||||
await expect(client.resolveImageInput(imagePath, "happyhorse-1.1-i2v")).resolves.toMatch(
|
||||
/^data:image\/webp;base64,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses Data URI for an official Token Plan endpoint under any profile name", async () => {
|
||||
const imagePath = makeImage(".jpg");
|
||||
const client = makeClient("https://token-plan.ap-southeast-1.maas.aliyuncs.com", "custom-plan");
|
||||
|
||||
await expect(client.resolveImageInput(imagePath, "wan2.7-image")).resolves.toMatch(
|
||||
/^data:image\/jpeg;base64,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary endpoints retain the existing upload path", () => {
|
||||
const imagePath = makeImage(".png");
|
||||
const client = makeClient("https://dashscope.aliyuncs.com", "default");
|
||||
|
||||
expect(() => client.resolveImageInput(imagePath, "wan2.7-image")).toThrow(
|
||||
/model-domain API key/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user