diff --git a/README.md b/README.md index accc066..4feea0a 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,195 @@ mmx update mmx update latest ``` +## SDK Usage + +You can also use MiniMax programmatically via the TypeScript SDK. + +### Installation + +```bash +npm install mmx-cli +``` + +### Basic Usage + +```typescript +import { MiniMaxSDK } from 'mmx-cli/sdk'; + +const sdk = new MiniMaxSDK({ + apiKey: 'sk-xxxxx', + region: 'global', // or 'cn' +}); +``` + +### Text Chat + +```typescript +// Non-streaming +const response = await sdk.text.chat({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Hello!' }], + max_tokens: 4096, +}); + +// Streaming +const stream = await sdk.text.chat({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Write a poem' }], + stream: true, +}); + +for await (const event of stream) { + console.log(event.choices[0]?.delta?.content); +} +``` + +### Image Generation + +```typescript +const result = await sdk.image.generate({ + model: 'image-01', + prompt: 'A cat in a spacesuit', + width: 1024, + height: 1024, + n: 1, +}); +``` + +### Video Generation + +```typescript +// Synchronous (waits for completion) +const video = await sdk.video.generate({ + model: 'MiniMax-Hailuo-2.3', + prompt: 'Ocean waves at sunset', +}); + +// Asynchronous (returns task ID immediately) +const { taskId } = await sdk.video.generate({ + prompt: 'A robot painting', + async: true, +}); + +// Check task status +const task = await sdk.video.getTask({ taskId }); + +// Download video +const { size, save, downloadUrl } = await sdk.video.download({ + fileId: '176844028768320', + outPath: './video.mp4', +}); +``` + +### Speech Synthesis + +```typescript +// Non-streaming +const speech = await sdk.speech.synthesize({ + model: 'speech-2.8-hd', + text: 'Hello, world!', + voice_setting: { voice_id: 'English_expressive_narrator' }, + audio_setting: { format: 'mp3', sample_rate: 32000, bitrate: 128000, channel: 1 }, +}); + +// Streaming +const stream = await sdk.speech.synthesize({ + text: 'Stream me', + stream: true, +}); + +for await (const chunk of stream) { + // Process audio chunks +} + +// List available voices +const voices = await sdk.speech.voices(); +const englishVoices = await sdk.speech.voices('en'); +``` + +### Music Generation + +```typescript +// With lyrics +const music = await sdk.music.generate({ + model: 'music-2.6', + prompt: 'Upbeat pop song', + lyrics: '[verse] La da dee, sunny day', + output_format: 'hex', +}); + +// Instrumental +const instrumental = await sdk.music.generate({ + prompt: 'Cinematic orchestral', + instrumental: true, +}); + +// Auto-generate lyrics +const autoLyrics = await sdk.music.generate({ + prompt: 'Indie folk, melancholic, rainy night', + lyrics_optimizer: true, +}); + +// Streaming +const stream = await sdk.music.generate({ + prompt: 'Upbeat pop', + lyrics: '[verse] Hello world', + stream: true, +}); + +for await (const chunk of stream) { + // Process audio chunks +} + +// Structured prompt +const structured = await sdk.music.generate({ + prompt: 'A beautiful song', + vocals: 'warm male baritone', + genre: 'jazz', + mood: 'relaxing', + instruments: 'piano, saxophone', + bpm: 120, + key: 'C major', +}); +``` + +### Vision (Image Description) + +```typescript +const result = await sdk.vision.describe({ + image: 'https://example.com/photo.jpg', + prompt: 'What breed is this dog?', +}); + +console.log(result.content); +``` + +### Web Search + +```typescript +const results = await sdk.search.query('MiniMax AI latest news'); + +for (const item of results.organic) { + console.log(item.title, item.link, item.snippet); +} +``` + +### Quota Information + +```typescript +const quota = await sdk.quota.info(); +console.log(quota); +``` + +### Custom Base URL + +```typescript +const sdk = new MiniMaxSDK({ + apiKey: 'sk-xxxxx', + baseUrl: 'https://api.minimax.io', // custom endpoint +}); +``` + ## Thanks to diff --git a/README_CN.md b/README_CN.md index 40b14eb..d197394 100644 --- a/README_CN.md +++ b/README_CN.md @@ -158,6 +158,195 @@ mmx update mmx update latest ``` +## SDK 使用 + +你也可以通过 TypeScript SDK 以编程方式使用 MiniMax。 + +### 安装 + +```bash +npm install mmx-cli +``` + +### 基础用法 + +```typescript +import { MiniMaxSDK } from 'mmx-cli/sdk'; + +const sdk = new MiniMaxSDK({ + apiKey: 'sk-xxxxx', + region: 'global', // 或 'cn' +}); +``` + +### 文本对话 + +```typescript +// 非流式 +const response = await sdk.text.chat({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: '你好!' }], + max_tokens: 4096, +}); + +// 流式 +const stream = await sdk.text.chat({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: '写一首诗' }], + stream: true, +}); + +for await (const event of stream) { + console.log(event.choices[0]?.delta?.content); +} +``` + +### 图像生成 + +```typescript +const result = await sdk.image.generate({ + model: 'image-01', + prompt: '一只穿宇航服的猫', + width: 1024, + height: 1024, + n: 1, +}); +``` + +### 视频生成 + +```typescript +// 同步(等待完成) +const video = await sdk.video.generate({ + model: 'MiniMax-Hailuo-2.3', + prompt: '海浪拍打礁石', +}); + +// 异步(立即返回任务 ID) +const { taskId } = await sdk.video.generate({ + prompt: '机器人作画', + async: true, +}); + +// 查询任务状态 +const task = await sdk.video.getTask({ taskId }); + +// 下载视频 +const { size, save, downloadUrl } = await sdk.video.download({ + fileId: '176844028768320', + outPath: './video.mp4', +}); +``` + +### 语音合成 + +```typescript +// 非流式 +const speech = await sdk.speech.synthesize({ + model: 'speech-2.8-hd', + text: '你好,世界!', + voice_setting: { voice_id: 'English_expressive_narrator' }, + audio_setting: { format: 'mp3', sample_rate: 32000, bitrate: 128000, channel: 1 }, +}); + +// 流式 +const stream = await sdk.speech.synthesize({ + text: '流式输出', + stream: true, +}); + +for await (const chunk of stream) { + // 处理音频块 +} + +// 获取可用音色列表 +const voices = await sdk.speech.voices(); +const chineseVoices = await sdk.speech.voices('zh'); +``` + +### 音乐生成 + +```typescript +// 带歌词 +const music = await sdk.music.generate({ + model: 'music-2.6', + prompt: '欢快的流行乐', + lyrics: '[主歌] 啦啦啦,阳光照', + output_format: 'hex', +}); + +// 纯音乐 +const instrumental = await sdk.music.generate({ + prompt: '史诗管弦乐', + instrumental: true, +}); + +// 自动生词 +const autoLyrics = await sdk.music.generate({ + prompt: '忧郁的独立民谣,雨夜', + lyrics_optimizer: true, +}); + +// 流式 +const stream = await sdk.music.generate({ + prompt: '欢快的流行乐', + lyrics: '[主歌] 你好世界', + stream: true, +}); + +for await (const chunk of stream) { + // 处理音频块 +} + +// 结构化提示词 +const structured = await sdk.music.generate({ + prompt: '一首优美的歌曲', + vocals: '温暖的男中音', + genre: '爵士', + mood: '放松', + instruments: '钢琴,萨克斯', + bpm: 120, + key: 'C 大调', +}); +``` + +### 图像理解 + +```typescript +const result = await sdk.vision.describe({ + image: 'https://example.com/photo.jpg', + prompt: '这是什么品种的狗?', +}); + +console.log(result.content); +``` + +### 网络搜索 + +```typescript +const results = await sdk.search.query('MiniMax AI 最新动态'); + +for (const item of results.organic) { + console.log(item.title, item.link, item.snippet); +} +``` + +### 配额信息 + +```typescript +const quota = await sdk.quota.info(); +console.log(quota); +``` + +### 自定义基础 URL + +```typescript +const sdk = new MiniMaxSDK({ + apiKey: 'sk-xxxxx', + baseUrl: 'https://api.minimax.io', // 自定义端点 +}); +``` + ## 贡献者 diff --git a/build.ts b/build.ts index 20cab98..4e16d78 100644 --- a/build.ts +++ b/build.ts @@ -1,8 +1,10 @@ import { readFileSync, writeFileSync } from 'fs'; +import dts from 'bun-plugin-dts'; const pkg = JSON.parse(readFileSync('package.json', 'utf-8')); const VERSION = process.env.VERSION ?? pkg.version; const OUT = 'dist/mmx.mjs'; +const SDK_OUT = 'dist/sdk.mjs'; await Bun.build({ entrypoints: ['src/main.ts'], @@ -18,3 +20,16 @@ writeFileSync(OUT, Buffer.concat([Buffer.from('#!/usr/bin/env node\n'), content] const size = (content.length / 1024).toFixed(0); console.log(`dist/mmx.mjs ${size}KB`); + +await Bun.build({ + entrypoints: ['src/sdk/index.ts'], + outdir: 'dist', + naming: 'sdk.mjs', + target: 'node', + minify: true, + plugins: [dts()], +}); + +const sdkContent = readFileSync(SDK_OUT); +const sdkSize = (sdkContent.length / 1024).toFixed(0); +console.log(`dist/sdk.mjs ${sdkSize}KB`); diff --git a/bun.lock b/bun.lock index 8e711f3..7d732be 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,8 @@ "name": "minimax-cli", "dependencies": { "@clack/prompts": "^0.7.0", + "bun-plugin-dts": "^0.4.0", + "es-toolkit": "^1.46.1", }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -81,6 +83,8 @@ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -89,16 +93,22 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "bun-plugin-dts": ["bun-plugin-dts@0.4.0", "https://registry.npmmirror.com/bun-plugin-dts/-/bun-plugin-dts-0.4.0.tgz", { "dependencies": { "common-path-prefix": "^3.0.0", "dts-bundle-generator": "^9.5.1", "get-tsconfig": "^4.13.6" } }, "sha512-g/pHy9SuhnUw+E+bHnJvADOnnZlEIci3nvZY8EuQEMwkpC4V4Kmoa2nG9nfda4jmjj+0POlCRCjdqXrL9gjYtA=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "common-path-prefix": ["common-path-prefix@3.0.0", "https://registry.npmmirror.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz", {}, "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -107,6 +117,14 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "https://registry.npmmirror.com/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + + "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], @@ -141,6 +159,10 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -155,6 +177,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -201,8 +225,12 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -211,6 +239,10 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -233,6 +265,14 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "@clack/prompts/is-unicode-supported": ["is-unicode-supported@2.1.0", "", { "bundled": true }, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], diff --git a/package.json b/package.json index ba9efc7..f26f706 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,17 @@ "mmx": "dist/mmx.mjs" }, "files": [ - "dist/mmx.mjs" + "dist/mmx.mjs", + "dist/sdk.mjs", + "dist/index.d.ts" ], + "exports": { + "./sdk": { + "import": "./dist/sdk.mjs", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, "scripts": { "dev": "bun run src/main.ts", "build": "bun run build.ts", @@ -23,7 +32,9 @@ "test:watch": "bun test --watch" }, "dependencies": { - "@clack/prompts": "^0.7.0" + "@clack/prompts": "^0.7.0", + "bun-plugin-dts": "^0.4.0", + "es-toolkit": "^1.46.1" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/src/commands/speech/voices.ts b/src/commands/speech/voices.ts index df87469..f1424ee 100644 --- a/src/commands/speech/voices.ts +++ b/src/commands/speech/voices.ts @@ -13,7 +13,7 @@ function extractLanguage(voiceId: string): string { return match ? match[1] : voiceId; } -function filterByLanguage(voices: SystemVoiceInfo[], language: string): SystemVoiceInfo[] { +export function filterByLanguage(voices: SystemVoiceInfo[], language: string): SystemVoiceInfo[] { const lang = language.toLowerCase(); return voices.filter(v => { const voiceLang = extractLanguage(v.voice_id).toLowerCase(); diff --git a/src/commands/vision/describe.ts b/src/commands/vision/describe.ts index c7d9d36..c2bbd8c 100644 --- a/src/commands/vision/describe.ts +++ b/src/commands/vision/describe.ts @@ -24,7 +24,7 @@ const MIME_TYPES: Record = { const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB limit -async function toDataUri(image: string): Promise { +export async function toDataUri(image: string): Promise { if (image.startsWith('data:')) return image; if (image.startsWith('http://') || image.startsWith('https://')) { diff --git a/src/errors/base.ts b/src/errors/base.ts index 0bb03ba..bab9fc4 100644 --- a/src/errors/base.ts +++ b/src/errors/base.ts @@ -21,3 +21,15 @@ export class CLIError extends Error { }; } } + +export class SDKError extends CLIError { + readonly exitCode: ExitCode; + readonly hint?: string; + + constructor(message: string, exitCode: ExitCode = ExitCode.GENERAL, hint?: string) { + super(message); + this.name = 'SDKError'; + this.exitCode = exitCode; + this.hint = hint; + } +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts new file mode 100644 index 0000000..f4ada4f --- /dev/null +++ b/src/sdk/client.ts @@ -0,0 +1,33 @@ +import { loadConfig } from "../config/loader"; +import { Config, Region } from "../config/schema"; +import { request as requestClient, requestJson as requestJsonClient, RequestOpts } from "../client/http"; +import { MiniMaxSDKOptions } from "./types"; + +export class Client { + protected config: Config; + + constructor(options: MiniMaxSDKOptions) { + const { apiKey, region, baseUrl } = options; + this.config = loadConfig({ + apiKey, + baseUrl, + region, + quiet: true, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: false, + async: false, + }); + } + + protected request(opts: RequestOpts) { + return requestClient(this.config, opts); + } + + protected requestJson(opts: RequestOpts): Promise { + return requestJsonClient(this.config, opts); + } +} diff --git a/src/sdk/image/index.ts b/src/sdk/image/index.ts new file mode 100644 index 0000000..b7bb578 --- /dev/null +++ b/src/sdk/image/index.ts @@ -0,0 +1,40 @@ +import { Client } from "../client"; +import { imageEndpoint } from "../../client/endpoints"; +import { ImageRequest, ImageResponse } from "../../types/api"; +import { ModelPartial } from "../types"; +import { SDKError } from "../../errors/base"; +import { ExitCode } from "../../errors/codes"; +import { toMerged } from 'es-toolkit/object'; + +export class ImageSDK extends Client { + async generate(request: ModelPartial): Promise { + const body = this.validateParams(request); + const url = imageEndpoint(this.config.baseUrl); + + return await this.requestJson({ + url, + method: "POST", + body, + }); + } + + private validateParams(params: Partial): ImageRequest { + const {width, height} = params; + for (const [name, val] of Object.entries({width, height})) { + if (!val) { + throw new SDKError(`${name} is required`, ExitCode.USAGE); + } + if (val < 512 || val > 2048) { + throw new SDKError(`${name} must be between 512 and 2048, got ${val}.`, ExitCode.USAGE); + } + if (val % 8 !== 0) { + throw new SDKError(`${name} must be a multiple of 8, got ${val}.`, ExitCode.USAGE); + } + } + + return toMerged({ + model: "image-01", + n: 1, + }, params) as ImageRequest; + } +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..41c0d16 --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,33 @@ +import { TextSDK } from "./text"; +import { SpeechSDK } from "./speech"; +import { ImageSDK } from "./image"; +import { VideoSDK } from "./video"; +import { MusicSDK } from "./music"; +import { SearchSDK } from "./search"; +import { VisionSDK } from "./vision"; +import { QuotaSDK } from "./quota"; +import { Client } from "./client"; +import { MiniMaxSDKOptions } from "./types"; + +export class MiniMaxSDK extends Client { + readonly text: TextSDK; + readonly speech: SpeechSDK; + readonly image: ImageSDK; + readonly video: VideoSDK; + readonly music: MusicSDK; + readonly search: SearchSDK; + readonly vision: VisionSDK; + readonly quota: QuotaSDK; + + constructor(options: MiniMaxSDKOptions) { + super(options); + this.text = new TextSDK(options); + this.speech = new SpeechSDK(options); + this.image = new ImageSDK(options); + this.video = new VideoSDK(options); + this.music = new MusicSDK(options); + this.search = new SearchSDK(options); + this.vision = new VisionSDK(options); + this.quota = new QuotaSDK(options); + } +} diff --git a/src/sdk/music/index.ts b/src/sdk/music/index.ts new file mode 100644 index 0000000..c779f6d --- /dev/null +++ b/src/sdk/music/index.ts @@ -0,0 +1,171 @@ +import { Client } from "../client"; +import { musicEndpoint } from "../../client/endpoints"; +import { MusicRequest, MusicResponse } from "../../types/api"; +import { ModelPartial } from "../types"; +import { SDKError } from "../../errors/base"; +import { ExitCode } from "../../errors/codes"; +import { toMerged } from "es-toolkit"; +import { musicGenerateModel } from "../../commands/music/models"; + +export interface MusicGenerateRequest extends MusicRequest { + /** Vocal style, e.g. "warm male baritone", "bright female soprano", "duet with harmonies" */ + vocals?: string; + /** Music genre, e.g. folk, pop, jazz */ + genre?: string; + /** Mood or emotion, e.g. warm, melancholic, uplifting */ + mood?: string; + /** Instruments to feature, e.g. "acoustic guitar, piano" */ + instruments?: string; + /** Tempo description, e.g. fast, slow, moderate */ + tempo?: string; + /** Exact tempo in beats per minute */ + bpm?: number; + /** Musical key, e.g. C major, A minor, G sharp */ + key?: string; + /** Elements to avoid in the generated music */ + avoid?: string; + /** Use case context, e.g. "background music for video", "theme song" */ + use_case?: string; + /** Song structure, e.g. "verse-chorus-verse-bridge-chorus" */ + structure?: string; + /** Reference tracks or artists, e.g. "similar to Ed Sheeran, Taylor Swift" */ + references?: string; + /** Additional fine-grained requirements not covered above */ + extra?: string; + /** Generate instrumental music (no vocals) */ + instrumental?: boolean; + /** Use case */ + useCase?: string; +} + +export class MusicSDK extends Client { + private async *generateStream(body: ModelPartial, url: string): AsyncGenerator> { + const res = await this.request({ + url, + method: 'POST', + body, + stream: true, + }); + + const reader = res.body?.getReader(); + if (!reader) { + throw new SDKError('No response body', ExitCode.GENERAL); + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + } + + async generate(request: ModelPartial & { stream: true }): Promise>>; + async generate(request: ModelPartial): Promise; + async generate(request: ModelPartial): Promise>> { + const body = this.validateParams(request); + + const url = musicEndpoint(this.config.baseUrl); + + if (request.stream) { + return this.generateStream(body, url); + } + + return await this.requestJson({ + url, + method: 'POST', + body, + }); + } + + private buildPrompt(request: ModelPartial) { + const structuredParts: string[] = []; + if (request.vocals) structuredParts.push(`Vocals: ${request.vocals as string}`); + if (request.genre) structuredParts.push(`Genre: ${request.genre as string}`); + if (request.mood) structuredParts.push(`Mood: ${request.mood as string}`); + if (request.instruments) structuredParts.push(`Instruments: ${request.instruments as string}`); + if (request.tempo) structuredParts.push(`Tempo: ${request.tempo as string}`); + if (request.bpm) structuredParts.push(`BPM: ${request.bpm as number}`); + if (request.key) structuredParts.push(`Key: ${request.key as string}`); + if (request.avoid) structuredParts.push(`Avoid: ${request.avoid as string}`); + if (request.useCase) structuredParts.push(`Use case: ${request.useCase as string}`); + if (request.structure) structuredParts.push(`Structure: ${request.structure as string}`); + if (request.references) structuredParts.push(`References: ${request.references as string}`); + if (request.extra) structuredParts.push(`Extra: ${request.extra as string}`); + + let lyrics = request.lyrics; + let prompt = request.prompt; + + if (request.instrumental || !lyrics || lyrics === '无歌词' || lyrics === 'no lyrics') { + lyrics = '[intro] [outro]'; + structuredParts.push('Style: instrumental, no vocals, pure music'); + } + + if (structuredParts.length > 0) { + const structured = structuredParts.join('. '); + prompt = prompt ? `${prompt}. ${structured}` : structured; + } + return prompt; + } + + private validateParams(params: ModelPartial) { + const { + model, output_format, stream, prompt, lyrics, is_instrumental, lyrics_optimizer, + } = params; + if (is_instrumental && lyrics) { + throw new SDKError('Cannot use is_instrumental with lyrics', ExitCode.USAGE); + } + + if (lyrics_optimizer && (lyrics || is_instrumental)) { + throw new SDKError('Cannot use lyrics_optimizer with lyrics or is_instrumental', ExitCode.USAGE); + } + + if (!prompt && !lyrics && !is_instrumental && !lyrics_optimizer) { + throw new SDKError('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required', ExitCode.USAGE); + } + + if (!is_instrumental && !lyrics_optimizer && !lyrics?.trim()) { + throw new SDKError('lyrics is required', ExitCode.USAGE); + } + + const VALID_MODELS = ['music-2.6', 'music-2.6-free', 'music-2.5+', 'music-2.5']; + if (model && !VALID_MODELS.includes(model)) { + throw new SDKError( + `Invalid model: ${model}. Valid models are ${VALID_MODELS.join(', ')}.`, + ExitCode.USAGE, + ); + } + + const VALID_OUTPUT_FORMATS = ['hex', 'url']; + if (output_format && !VALID_OUTPUT_FORMATS.includes(output_format)) { + throw new SDKError( + `Invalid output format: ${output_format}. Valid formats are ${VALID_OUTPUT_FORMATS.join(', ')}.`, + ExitCode.USAGE, + ); + } + if (stream && output_format === 'url') { + throw new SDKError( + `stream and output_format url cannot be used together. Streaming requires hex format.`, + ExitCode.USAGE, + ); + } + + const targetPrompt = this.buildPrompt(params); + + return toMerged({ + model: musicGenerateModel(this.config), + audio_setting: { + format: 'mp3', + sample_rate: 44100, + bitrate: 256000, + }, + output_format: 'hex', + }, { + ...params, + prompt: targetPrompt, + }); + } +} diff --git a/src/sdk/quota/index.ts b/src/sdk/quota/index.ts new file mode 100644 index 0000000..b48b359 --- /dev/null +++ b/src/sdk/quota/index.ts @@ -0,0 +1,12 @@ +import { Client } from "../client"; +import { quotaEndpoint } from "../../client/endpoints"; +import type { QuotaResponse } from "../../types/api"; + +export class QuotaSDK extends Client { + async info(): Promise { + const url = quotaEndpoint(this.config.baseUrl); + const res = await this.requestJson({ url }); + + return res; + } +} diff --git a/src/sdk/search/index.ts b/src/sdk/search/index.ts new file mode 100644 index 0000000..090af8d --- /dev/null +++ b/src/sdk/search/index.ts @@ -0,0 +1,26 @@ +import { Client } from "../client"; +import { searchEndpoint } from "../../client/endpoints"; + +interface SearchResult { + title: string; + link: string; + snippet: string; + date: string; +} + +export interface SearchResponse { + organic: SearchResult[]; +} + +export class SearchSDK extends Client { + async query(query: string): Promise { + const url = searchEndpoint(this.config.baseUrl); + const res = await this.requestJson({ + url, + method: 'POST', + body: { q: query }, + }); + + return res; + } +} diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts new file mode 100644 index 0000000..56b8412 --- /dev/null +++ b/src/sdk/speech/index.ts @@ -0,0 +1,90 @@ +import { Client } from "../client"; +import { speechEndpoint, voicesEndpoint } from "../../client/endpoints"; +import { SpeechRequest, SpeechResponse, VoiceListResponse } from "../../types/api"; +import { parseSSE } from "../../client/stream"; +import { filterByLanguage } from "../../commands/speech/voices"; +import { SDKError } from "../../errors/base"; +import { ExitCode } from "../../errors/codes"; +import { toMerged } from "es-toolkit/object"; +import { ModelPartial } from "../types"; + +export class SpeechSDK extends Client { + async synthesize(request: ModelPartial & { stream: true }): Promise>; + async synthesize(request: ModelPartial): Promise; + async synthesize(request: ModelPartial): Promise> { + const body = this.validateParams(request); + + const url = speechEndpoint(this.config.baseUrl); + + if (body.stream) { + return this.synthesizeStream(body, url); + } + + const res = await this.requestJson({ + url, + method: "POST", + body, + }); + + return res; + } + + private async *synthesizeStream(body: SpeechRequest, url: string): AsyncGenerator { + const res = await this.request({ + url, + method: "POST", + body, + stream: true, + }); + + for await (const event of parseSSE(res)) { + if (!event.data || event.data === '[DONE]') break; + try { + const parsed = JSON.parse(event.data) as SpeechResponse; + yield parsed; + } catch (err) { + throw new SDKError( + `Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`, + ExitCode.GENERAL, + ); + } + } + } + + async voices(language?: string) { + const url = voicesEndpoint(this.config.baseUrl); + + const res = await this.requestJson({ + url, + method: "POST", + body: { voice_type: 'system' }, + }); + + const voices = res.system_voice ?? []; + if (language) { + const filtered = filterByLanguage(voices, language); + return filtered; + } + return voices; + } + + private validateParams(params: Partial): SpeechRequest { + if (!params.text) { + throw new SDKError('text is required', ExitCode.USAGE); + } + + return toMerged({ + model: "speech-2.8-hd", + voice_setting: { + voice_id:"English_expressive_narrator", + }, + audio_setting: { + format: "mp3", + sample_rate: 32000, + bitrate: 128000, + channel: 1, + }, + output_format: 'hex', + }, params) as SpeechRequest; + } +} diff --git a/src/sdk/text/index.ts b/src/sdk/text/index.ts new file mode 100644 index 0000000..8295de8 --- /dev/null +++ b/src/sdk/text/index.ts @@ -0,0 +1,75 @@ +import { Client } from "../client"; +import { chatEndpoint } from "../../client/endpoints"; +import { ChatRequest, ChatResponse, StreamEvent } from "../../types/api"; +import { parseSSE } from "../../client/stream"; +import { SDKError } from "../../errors/base"; +import { ExitCode } from "../../errors/codes"; + +export class TextSDK extends Client { + private async *chatStream(body: Partial): AsyncGenerator { + const url = chatEndpoint(this.config.baseUrl); + + const res = await this.request({ + url, + method: 'POST', + body, + stream: true, + authStyle: 'x-api-key', + }); + + const contentType = res.headers.get('content-type') || ''; + + if (!contentType.includes('text/event-stream') && !contentType.includes('stream')) { + throw new SDKError( + `Expected SSE stream but got content-type "${contentType}". Server may be experiencing issues.`, + ExitCode.GENERAL, + ); + } + + for await (const event of parseSSE(res)) { + if (event.data === '[DONE]') break; + try { + const parsed = JSON.parse(event.data) as StreamEvent; + yield parsed; + } catch(err) { + throw new SDKError( + `Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`, + ExitCode.GENERAL, + ); + } + } + } + + async chat(request: Partial & { stream: true }): Promise>; + async chat(request: Partial): Promise; + async chat(request: Partial): Promise> { + const body = this.validateParams(request); + const url = chatEndpoint(this.config.baseUrl); + + if (body.stream) { + return this.chatStream(body); + } + + return await this.requestJson({ + url, + method: 'POST', + body, + authStyle: 'x-api-key', + }); + } + + private validateParams(params: Partial): ChatRequest { + if (params.messages?.length === 0) { + throw new SDKError( + 'At least one message is required.', + ExitCode.USAGE, + ); + } + + return { + ...params, + model: params.model ?? 'MiniMax-M2.7', + max_tokens: params.max_tokens ?? 4096, + } as ChatRequest; + } +} diff --git a/src/sdk/types.ts b/src/sdk/types.ts new file mode 100644 index 0000000..a62df27 --- /dev/null +++ b/src/sdk/types.ts @@ -0,0 +1,11 @@ +import { Region } from "../config/schema"; + +export interface MiniMaxSDKOptions { + apiKey?: string; + baseUrl?: string; + region?: Region; +} + +export type ModelPartial = 'model' extends keyof T + ? Omit & { model?: T['model'] } + : T; diff --git a/src/sdk/video/index.ts b/src/sdk/video/index.ts new file mode 100644 index 0000000..cb1fb31 --- /dev/null +++ b/src/sdk/video/index.ts @@ -0,0 +1,106 @@ +import { Client } from "../client"; +import { fileRetrieveEndpoint, videoGenerateEndpoint, videoTaskEndpoint } from "../../client/endpoints"; +import { FileRetrieveResponse, VideoRequest, VideoResponse, VideoTaskResponse } from "../../types/api"; +import { ModelPartial } from "../types"; +import { poll } from "../../polling/poll"; +import { downloadFile } from "../../files/download"; +import { SDKError } from "../../errors/base"; +import { ExitCode } from "../../errors/codes"; +import { toMerged } from 'es-toolkit/object'; + +export interface VideoAsyncGenerateRequest extends ModelPartial { + async?: boolean; + pollInterval?: number; + timeout?: number; +} + +export interface VideoDownloadRequest { + fileId: string; + outPath: string; +} + +export class VideoSDK extends Client { + async generate(request: VideoAsyncGenerateRequest & { async: true }): Promise<{taskId: string}>; + async generate(request: ModelPartial): Promise; + async generate(request: VideoAsyncGenerateRequest): Promise { + const body = this.validateParams(request); + const url = videoGenerateEndpoint(this.config.baseUrl); + const res = await this.requestJson({ + url, + method: "POST", + body, + }); + + const taskId = res.task_id; + if (request.async) { + return {taskId}; + } + + const taskUrl = videoTaskEndpoint(this.config.baseUrl, taskId); + const result = await poll(this.config, { + url: taskUrl, + intervalSec: request.pollInterval ?? 5, + timeoutSec: request.timeout ?? this.config.timeout, + isComplete: (d) => (d as VideoTaskResponse).status === 'Success', + isFailed: (d) => (d as VideoTaskResponse).status === 'Failed', + getStatus: (d) => (d as VideoTaskResponse).status, + }); + + return result; + } + + async getTask({taskId}: {taskId: string}): Promise { + const url = videoTaskEndpoint(this.config.baseUrl, taskId); + return await this.requestJson({ url }); + } + + async download(request: VideoDownloadRequest) { + const url = fileRetrieveEndpoint(this.config.baseUrl, request.fileId); + const fileInfo = await this.requestJson({ url }); + const downloadUrl = fileInfo.file?.download_url; + if (!downloadUrl) { + throw new Error('No download URL available for this file.'); + } + const { size } = await downloadFile(downloadUrl, request.outPath, { quiet: true }); + return { + size, + save: request.outPath, + downloadUrl, + } + } + + private validateParams(request: VideoAsyncGenerateRequest): VideoRequest { + const { prompt, model, first_frame_image, last_frame_image, subject_reference } = request; + + if (!prompt) { + throw new SDKError('prompt is required', ExitCode.USAGE); + } + + const resolvedModel = model ?? 'MiniMax-Hailuo-2.3'; + + if (resolvedModel === 'MiniMax-Hailuo-2.3-Fast' && !first_frame_image) { + throw new SDKError( + 'MiniMax-Hailuo-2.3-Fast only supports I2V (image-to-video). Provide first_frame_image.', + ExitCode.USAGE, + ); + } + + if (last_frame_image && !first_frame_image) { + throw new SDKError( + 'last_frame_image requires first_frame_image (SEF mode).', + ExitCode.USAGE, + ); + } + + if (last_frame_image && subject_reference) { + throw new SDKError( + 'last_frame_image and subject_reference cannot be used together (SEF and S2V are different modes).', + ExitCode.USAGE, + ); + } + + return toMerged({ + model: resolvedModel, + }, request) + } +} diff --git a/src/sdk/vision/index.ts b/src/sdk/vision/index.ts new file mode 100644 index 0000000..daa72c3 --- /dev/null +++ b/src/sdk/vision/index.ts @@ -0,0 +1,30 @@ +import { Client } from "../client"; +import { vlmEndpoint } from "../../client/endpoints"; +import { toDataUri } from "../../commands/vision/describe"; + +export interface VlmResponse { + content: string; +} + +export interface ImageDescribeRequest { + prompt?: string; + image: string; +} + +export class VisionSDK extends Client { + async describe(request: ImageDescribeRequest): Promise { + const body = { + prompt: request.prompt || 'Describe the image.', + image_url: await toDataUri(request.image), + }; + + const url = vlmEndpoint(this.config.baseUrl); + const res = await this.requestJson({ + url, + method: 'POST', + body, + }); + + return res; + } +} diff --git a/src/types/api.ts b/src/types/api.ts index fc80937..47ae2cf 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -98,7 +98,7 @@ export interface SpeechRequest { vol?: number; pitch?: number; }; - audio_setting: { + audio_setting?: { format?: string; sample_rate?: number; bitrate?: number; diff --git a/test/sdk/image.test.ts b/test/sdk/image.test.ts new file mode 100644 index 0000000..7a6851b --- /dev/null +++ b/test/sdk/image.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.image', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should generate image successfully', async () => { + server = createMockServer({ + routes: { + '/v1/image_generation': () => jsonResponse({ + base_resp: { status_code: 0, status_msg: 'success' }, + data: { + task_id: 'img-123', + success_count: 1, + failed_count: 0, + }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.image.generate({ + prompt: 'A beautiful sunset', + width: 1024, + height: 1024, + }); + + expect(result.data.task_id).toBe('img-123'); + }); +}); diff --git a/test/sdk/music.test.ts b/test/sdk/music.test.ts new file mode 100644 index 0000000..4b822e0 --- /dev/null +++ b/test/sdk/music.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.music', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should generate music successfully', async () => { + server = createMockServer({ + routes: { + '/v1/music_generation': () => jsonResponse({ + data: { audio_url: 'https://example.com/music.mp3' }, + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.music.generate({ + lyrics: 'no lyrics', + instrumental: true, + }); + + expect(result.data.audio_url).toBe('https://example.com/music.mp3'); + }); +}); diff --git a/test/sdk/quota.test.ts b/test/sdk/quota.test.ts new file mode 100644 index 0000000..4268b77 --- /dev/null +++ b/test/sdk/quota.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, afterEach, mock } from 'bun:test'; +import { jsonResponse } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.quota', () => { + it('should get quota info successfully', async () => { + const mockFetch = mock(async (url: string) => { + if (url.includes('/v1/token_plan/remains')) { + return new Response(JSON.stringify({ + model_remains: [ + { + model_name: 'MiniMax-M2.7', + start_time: 0, + end_time: 9999999999, + remains_time: 1000, + current_interval_total_count: 1000, + current_interval_usage_count: 500, + current_weekly_total_count: 5000, + current_weekly_usage_count: 2000, + weekly_start_time: 0, + weekly_end_time: 9999999999, + weekly_remains_time: 3000, + }, + ], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Not found', { status: 404 }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch as unknown as typeof fetch; + + try { + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + }); + + const result = await sdk.quota.info(); + + expect(result.model_remains).toHaveLength(1); + expect(result.model_remains[0].model_name).toBe('MiniMax-M2.7'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/test/sdk/search.test.ts b/test/sdk/search.test.ts new file mode 100644 index 0000000..182d0ce --- /dev/null +++ b/test/sdk/search.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.search', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should search successfully', async () => { + server = createMockServer({ + routes: { + '/v1/coding_plan/search': () => jsonResponse({ + organic: [ + { title: 'Test Result', link: 'https://example.com', snippet: 'A test snippet', date: '2024-01-01' }, + ], + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.search.query('test query'); + + expect(result.organic).toHaveLength(1); + expect(result.organic[0].title).toBe('Test Result'); + }); +}); diff --git a/test/sdk/speech.test.ts b/test/sdk/speech.test.ts new file mode 100644 index 0000000..7a29309 --- /dev/null +++ b/test/sdk/speech.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.speech', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should synthesize speech successfully', async () => { + server = createMockServer({ + routes: { + '/v1/t2a_v2': () => jsonResponse({ + data: { audio: 'base64audio' }, + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.speech.synthesize({ + text: 'Hello world', + }); + + expect(result.data.audio).toBe('base64audio'); + }); + + it('should get voices list', async () => { + server = createMockServer({ + routes: { + '/v1/get_voice': () => jsonResponse({ + system_voice: [ + { voice_id: 'voice-1', voice_name: 'Voice 1', description: [] }, + ], + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const voices = await sdk.speech.voices(); + + expect(voices).toHaveLength(1); + expect(voices[0].voice_id).toBe('voice-1'); + }); +}); diff --git a/test/sdk/text.test.ts b/test/sdk/text.test.ts new file mode 100644 index 0000000..9219f22 --- /dev/null +++ b/test/sdk/text.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.text', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should chat successfully', async () => { + server = createMockServer({ + routes: { + '/anthropic/v1/messages': () => jsonResponse({ + id: 'msg-123', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + model: 'MiniMax-M2.7', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.text.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(result.id).toBe('msg-123'); + }); +}); diff --git a/test/sdk/video.test.ts b/test/sdk/video.test.ts new file mode 100644 index 0000000..58e4aa0 --- /dev/null +++ b/test/sdk/video.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.video', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should generate video async successfully', async () => { + server = createMockServer({ + routes: { + '/v1/video_generation': () => jsonResponse({ + task_id: 'vid-123', + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.video.generate({ + prompt: 'A cat walking', + async: true, + }); + + expect(result.taskId).toBe('vid-123'); + }); + + it('should get task status', async () => { + server = createMockServer({ + routes: { + '/v1/query/video_generation': () => jsonResponse({ + task_id: 'vid-123', + status: 'Success', + base_resp: { status_code: 0, status_msg: 'success' }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.video.getTask({ taskId: 'vid-123' }); + + expect(result.status).toBe('Success'); + }); +}); diff --git a/test/sdk/vision.test.ts b/test/sdk/vision.test.ts new file mode 100644 index 0000000..827dc32 --- /dev/null +++ b/test/sdk/vision.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { MiniMaxSDK } from '../../src/sdk'; + +describe('MiniMaxSDK.vision', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('should describe image successfully', async () => { + server = createMockServer({ + routes: { + '/v1/coding_plan/vlm': () => jsonResponse({ + content: 'A beautiful sunset over the ocean', + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.vision.describe({ + image: 'data:image/jpeg;base64,dGVzdA==', + }); + + expect(result.content).toBe('A beautiful sunset over the ocean'); + }); +});