From 3b59db29ee94fa9e9ab198cc9f8adfc5eb975ff9 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Mon, 11 May 2026 09:38:07 +0800 Subject: [PATCH] refactor: deduplicate MIME types, image encoding, SSE parsing, and dry-run boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared utilities to eliminate code duplication across commands and SDK: - src/utils/image.ts: IMAGE_MIME_TYPES, localFileToDataUri, resolveImageInput, toDataUri - src/utils/prompt.ts: promptOrFail for interactive input with non-interactive fallback - src/sdk/client.ts: streamSSE generic method for SSE stream consumption - src/output/formatter.ts: dryRun helper for consistent dry-run output Fix SDK→Command architectural inversion (sdk/vision imported from commands/vision). Fix SSE stream: empty data events now skip (continue) instead of terminating (break). Standardize toMerged import to es-toolkit/object across all SDK modules. --- src/commands/image/generate.ts | 51 +++++++--------------- src/commands/music/generate.ts | 9 ++-- src/commands/speech/synthesize.ts | 9 ++-- src/commands/video/generate.ts | 72 +++++++------------------------ src/commands/vision/describe.ts | 50 ++------------------- src/output/formatter.ts | 6 +++ src/sdk/client.ts | 18 ++++++++ src/sdk/music/index.ts | 2 +- src/sdk/speech/index.ts | 14 +----- src/sdk/text/index.ts | 14 +----- src/sdk/vision/index.ts | 2 +- src/utils/image.ts | 48 +++++++++++++++++++++ src/utils/prompt.ts | 22 ++++++++++ test/commands/aliases.test.ts | 6 +-- test/sdk/text.test.ts | 39 ++++++++++++++++- 15 files changed, 181 insertions(+), 181 deletions(-) create mode 100644 src/utils/image.ts diff --git a/src/commands/image/generate.ts b/src/commands/image/generate.ts index 162f8b1..b530a72 100644 --- a/src/commands/image/generate.ts +++ b/src/commands/image/generate.ts @@ -4,19 +4,14 @@ import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; import { imageEndpoint } from '../../client/endpoints'; import { downloadFile } from '../../files/download'; -import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { ImageRequest, ImageResponse } from '../../types/api'; -import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs'; -import { dirname, join, resolve, extname } from 'path'; -import { isInteractive } from '../../utils/env'; -import { promptText, failIfMissing } from '../../utils/prompt'; - -const MIME_TYPES: Record = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.webp': 'image/webp', -}; +import { mkdirSync, existsSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { localFileToDataUri } from '../../utils/image'; +import { promptOrFail } from '../../utils/prompt'; export default defineCommand({ name: 'image generate', @@ -56,20 +51,14 @@ export default defineCommand({ async run(config: Config, flags: GlobalFlags) { let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined; - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: 'Enter your image prompt:', - }); - if (!hint) { - process.stderr.write('Image generation cancelled.\n'); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing('prompt', 'mmx image generate --prompt '); - } - } + prompt = await promptOrFail({ + value: prompt, + message: 'Enter your image prompt:', + cancelMessage: 'Image generation cancelled.', + flagName: 'prompt', + usageHint: 'mmx image generate --prompt ', + nonInteractive: config.nonInteractive, + }); // Validate width/height const width = flags.width as number | undefined; @@ -132,24 +121,16 @@ export default defineCommand({ if (params.image.startsWith('http')) { ref.image_url = params.image; } else { - const imgPath = resolve(params.image); - const imgData = readFileSync(imgPath); - const ext = extname(imgPath).toLowerCase(); - const mime = MIME_TYPES[ext] || 'image/jpeg'; - ref.image_file = `data:${mime};base64,${imgData.toString('base64')}`; + ref.image_file = localFileToDataUri(resolve(params.image)); } } body.subject_reference = [ref]; } - const format = detectOutputFormat(config.output); - - if (config.dryRun) { - console.log(formatOutput({ request: body }, format)); - return; - } + if (dryRun(config, body)) return; + const format = detectOutputFormat(config.output); const url = imageEndpoint(config.baseUrl); const response = await requestJson(config, { url, diff --git a/src/commands/music/generate.ts b/src/commands/music/generate.ts index 3a0c046..33cfd9f 100644 --- a/src/commands/music/generate.ts +++ b/src/commands/music/generate.ts @@ -3,7 +3,7 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { request, requestJson } from '../../client/http'; import { musicEndpoint } from '../../client/endpoints'; -import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; import { readTextFromPathOrStdin } from '../../utils/fs'; import type { Config } from '../../config/schema'; @@ -122,7 +122,6 @@ export default defineCommand({ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const ext = (flags.format as string) || 'mp3'; const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`; - const format = detectOutputFormat(config.output); const model = (flags.model as string) || musicGenerateModel(config); const VALID_MODELS = ['music-2.6', 'music-2.6-free', 'music-2.5+', 'music-2.5']; @@ -165,11 +164,9 @@ export default defineCommand({ if (flags.aigcWatermark) body.aigc_watermark = true; - if (config.dryRun) { - console.log(formatOutput({ request: body }, format)); - return; - } + if (dryRun(config, body)) return; + const format = detectOutputFormat(config.output); const url = musicEndpoint(config.baseUrl); if (flags.stream) { diff --git a/src/commands/speech/synthesize.ts b/src/commands/speech/synthesize.ts index 756eff0..82b6f06 100644 --- a/src/commands/speech/synthesize.ts +++ b/src/commands/speech/synthesize.ts @@ -4,7 +4,7 @@ import { ExitCode } from '../../errors/codes'; import { request, requestJson } from '../../client/http'; import { speechEndpoint } from '../../client/endpoints'; import { parseSSE } from '../../client/stream'; -import { detectOutputFormat, formatOutput } from '../../output/formatter'; +import { detectOutputFormat, formatOutput, dryRun } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; import { writeFileSync } from 'fs'; import { readTextFromPathOrStdin } from '../../utils/fs'; @@ -65,7 +65,6 @@ export default defineCommand({ const ext = (flags.format as string) || 'mp3'; const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`; const outFormat = 'hex'; - const format = detectOutputFormat(config.output); const body: SpeechRequest = { model, @@ -96,11 +95,9 @@ export default defineCommand({ }); } - if (config.dryRun) { - console.log(formatOutput({ request: body }, format)); - return; - } + if (dryRun(config, body)) return; + const format = detectOutputFormat(config.output); const url = speechEndpoint(config.baseUrl); if (flags.stream) { diff --git a/src/commands/video/generate.ts b/src/commands/video/generate.ts index ac0d045..996a336 100644 --- a/src/commands/video/generate.ts +++ b/src/commands/video/generate.ts @@ -5,19 +5,12 @@ import { requestJson } from '../../client/http'; import { videoGenerateEndpoint, videoTaskEndpoint, fileRetrieveEndpoint } from '../../client/endpoints'; import { poll } from '../../polling/poll'; import { downloadFile, formatBytes } from '../../files/download'; -import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { VideoRequest, VideoResponse, VideoTaskResponse, FileRetrieveResponse } from '../../types/api'; -import { readFileSync } from 'fs'; -import { extname } from 'path'; - -const MIME_TYPES: Record = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.webp': 'image/webp', -}; -import { isInteractive } from '../../utils/env'; -import { promptText, failIfMissing } from '../../utils/prompt'; +import { resolveImageInput } from '../../utils/image'; +import { promptOrFail } from '../../utils/prompt'; export default defineCommand({ name: 'video generate', @@ -49,18 +42,14 @@ export default defineCommand({ async run(config: Config, flags: GlobalFlags) { let prompt = flags.prompt as string | undefined; - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: 'Enter your video prompt:' }); - if (!hint) { - process.stderr.write('Video generation cancelled.\n'); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing('prompt', 'mmx video generate --prompt '); - } - } + prompt = await promptOrFail({ + value: prompt, + message: 'Enter your video prompt:', + cancelMessage: 'Video generation cancelled.', + flagName: 'prompt', + usageHint: 'mmx video generate --prompt ', + nonInteractive: config.nonInteractive, + }); // Validate mutually exclusive mode flags if (flags.lastFrame && flags.subjectImage) { @@ -92,7 +81,6 @@ export default defineCommand({ } else { model = config.defaultVideoModel || 'MiniMax-Hailuo-2.3'; } - const format = detectOutputFormat(config.output); const body: VideoRequest = { model, @@ -101,15 +89,7 @@ export default defineCommand({ // First frame (I2V) if (flags.firstFrame) { - const framePath = flags.firstFrame as string; - if (framePath.startsWith('http')) { - body.first_frame_image = framePath; - } else { - const imgData = readFileSync(framePath); - const ext = extname(framePath).toLowerCase(); - const mime = MIME_TYPES[ext] || 'image/jpeg'; - body.first_frame_image = `data:${mime};base64,${imgData.toString('base64')}`; - } + body.first_frame_image = resolveImageInput(flags.firstFrame as string); } // Last frame (SEF mode) @@ -121,41 +101,21 @@ export default defineCommand({ 'mmx video generate --prompt --first-frame --last-frame ', ); } - const framePath = flags.lastFrame as string; - if (framePath.startsWith('http')) { - body.last_frame_image = framePath; - } else { - const imgData = readFileSync(framePath); - const ext = extname(framePath).toLowerCase(); - const mime = MIME_TYPES[ext] || 'image/jpeg'; - body.last_frame_image = `data:${mime};base64,${imgData.toString('base64')}`; - } + body.last_frame_image = resolveImageInput(flags.lastFrame as string); } // Subject reference (S2V mode) if (flags.subjectImage) { - const imgPath = flags.subjectImage as string; - let imageData: string; - if (imgPath.startsWith('http')) { - imageData = imgPath; - } else { - const imgData = readFileSync(imgPath); - const ext = extname(imgPath).toLowerCase(); - const mime = MIME_TYPES[ext] || 'image/jpeg'; - imageData = `data:${mime};base64,${imgData.toString('base64')}`; - } - body.subject_reference = [{ type: 'character', image: [imageData] }]; + body.subject_reference = [{ type: 'character', image: [resolveImageInput(flags.subjectImage as string)] }]; } if (flags.callbackUrl) { body.callback_url = flags.callbackUrl as string; } - if (config.dryRun) { - console.log(formatOutput({ request: body }, format)); - return; - } + if (dryRun(config, body)) return; + const format = detectOutputFormat(config.output); const url = videoGenerateEndpoint(config.baseUrl); const response = await requestJson(config, { url, diff --git a/src/commands/vision/describe.ts b/src/commands/vision/describe.ts index c2bbd8c..05f7814 100644 --- a/src/commands/vision/describe.ts +++ b/src/commands/vision/describe.ts @@ -1,57 +1,19 @@ import { defineCommand } from '../../command'; import { requestJson } from '../../client/http'; import { vlmEndpoint } from '../../client/endpoints'; -import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import { readFileSync, existsSync } from 'fs'; -import { extname } from 'path'; import { isInteractive } from '../../utils/env'; import { promptText } from '../../utils/prompt'; +import { toDataUri } from '../../utils/image'; interface VlmResponse { content: string; } -const MIME_TYPES: Record = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.webp': 'image/webp', -}; - -const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB limit - -export async function toDataUri(image: string): Promise { - if (image.startsWith('data:')) return image; - - if (image.startsWith('http://') || image.startsWith('https://')) { - const res = await fetch(image); - if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL); - const contentType = res.headers.get('content-type') || 'image/jpeg'; - const mime = contentType.split(';')[0]!.trim(); - const buf = await res.arrayBuffer(); - if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) { - throw new CLIError( - `Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`, - ExitCode.USAGE, - ); - } - const b64 = Buffer.from(buf).toString('base64'); - return `data:${mime};base64,${b64}`; - } - - // Local file - if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE); - const ext = extname(image).toLowerCase(); - const mime = MIME_TYPES[ext]; - if (!mime) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE); - const buf = readFileSync(image); - return `data:${mime};base64,${buf.toString('base64')}`; -} - export default defineCommand({ name: 'vision describe', description: 'Describe an image using MiniMax VLM', @@ -101,13 +63,9 @@ export default defineCommand({ ); } - const format = detectOutputFormat(config.output); - - if (config.dryRun) { - process.stdout.write(formatOutput({ request: { prompt, image, fileId } }, format) + '\n'); - return; - } + if (dryRun(config, { prompt, image, fileId })) return; + const format = detectOutputFormat(config.output); const url = vlmEndpoint(config.baseUrl); const body: Record = { prompt }; diff --git a/src/output/formatter.ts b/src/output/formatter.ts index b20a0b0..87ccbdd 100644 --- a/src/output/formatter.ts +++ b/src/output/formatter.ts @@ -21,3 +21,9 @@ export function formatOutput(data: unknown, format: OutputFormat): string { return formatText(data); } } + +export function dryRun(config: { dryRun?: boolean; output?: string }, body: unknown): boolean { + if (!config.dryRun) return false; + console.log(formatOutput({ request: body }, detectOutputFormat(config.output))); + return true; +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 4e5097a..93e0d3e 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -1,6 +1,9 @@ import { loadConfig } from "../config/loader"; import { Config } from "../config/schema"; import { request as requestClient, requestJson as requestJsonClient, RequestOpts } from "../client/http"; +import { parseSSE } from "../client/stream"; +import { SDKError } from "../errors/base"; +import { ExitCode } from "../errors/codes"; import { MiniMaxSDKOptions } from "./types"; export class Client { @@ -30,4 +33,19 @@ export class Client { protected requestJson(opts: RequestOpts): Promise { return requestJsonClient(this.config, opts); } + + protected async *streamSSE(res: Response): AsyncGenerator { + for await (const event of parseSSE(res)) { + if (event.data === '[DONE]') break; + if (!event.data) continue; + try { + yield JSON.parse(event.data) as T; + } catch (err) { + throw new SDKError( + `Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`, + ExitCode.GENERAL, + ); + } + } + } } diff --git a/src/sdk/music/index.ts b/src/sdk/music/index.ts index c779f6d..37c4b14 100644 --- a/src/sdk/music/index.ts +++ b/src/sdk/music/index.ts @@ -4,7 +4,7 @@ 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 { toMerged } from "es-toolkit/object"; import { musicGenerateModel } from "../../commands/music/models"; export interface MusicGenerateRequest extends MusicRequest { diff --git a/src/sdk/speech/index.ts b/src/sdk/speech/index.ts index 56b8412..91d2f59 100644 --- a/src/sdk/speech/index.ts +++ b/src/sdk/speech/index.ts @@ -1,7 +1,6 @@ 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"; @@ -37,18 +36,7 @@ export class SpeechSDK extends Client { 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, - ); - } - } + yield* this.streamSSE(res); } async voices(language?: string) { diff --git a/src/sdk/text/index.ts b/src/sdk/text/index.ts index 8295de8..1242d3e 100644 --- a/src/sdk/text/index.ts +++ b/src/sdk/text/index.ts @@ -1,7 +1,6 @@ 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"; @@ -26,18 +25,7 @@ export class TextSDK extends Client { ); } - 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, - ); - } - } + yield* this.streamSSE(res); } async chat(request: Partial & { stream: true }): Promise>; diff --git a/src/sdk/vision/index.ts b/src/sdk/vision/index.ts index daa72c3..96150bd 100644 --- a/src/sdk/vision/index.ts +++ b/src/sdk/vision/index.ts @@ -1,6 +1,6 @@ import { Client } from "../client"; import { vlmEndpoint } from "../../client/endpoints"; -import { toDataUri } from "../../commands/vision/describe"; +import { toDataUri } from "../../utils/image"; export interface VlmResponse { content: string; diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..f19ec77 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,48 @@ +import { readFileSync, existsSync } from 'fs'; +import { extname } from 'path'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; + +export const IMAGE_MIME_TYPES: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', +}; + +export function localFileToDataUri(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + const mime = IMAGE_MIME_TYPES[ext] || 'image/jpeg'; + const data = readFileSync(filePath); + return `data:${mime};base64,${data.toString('base64')}`; +} + +export function resolveImageInput(input: string): string { + return input.startsWith('http') ? input : localFileToDataUri(input); +} + +const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; + +export async function toDataUri(image: string): Promise { + if (image.startsWith('data:')) return image; + + if (image.startsWith('http://') || image.startsWith('https://')) { + const res = await fetch(image); + if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL); + const contentType = res.headers.get('content-type') || 'image/jpeg'; + const mime = contentType.split(';')[0]!.trim(); + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) { + throw new CLIError( + `Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`, + ExitCode.USAGE, + ); + } + return `data:${mime};base64,${Buffer.from(buf).toString('base64')}`; + } + + if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE); + const ext = extname(image).toLowerCase(); + if (!IMAGE_MIME_TYPES[ext]) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE); + return localFileToDataUri(image); +} diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index aa4add3..0f70dc9 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -72,3 +72,25 @@ export function failIfMissing(flagName: string, context: string): never { context, ); } + +export async function promptOrFail(opts: { + value: string | undefined; + message: string; + cancelMessage: string; + flagName: string; + usageHint: string; + nonInteractive?: boolean; +}): Promise { + if (opts.value) return opts.value; + + if (isInteractive({ nonInteractive: opts.nonInteractive })) { + const hint = await promptText({ message: opts.message }); + if (!hint) { + process.stderr.write(opts.cancelMessage + '\n'); + process.exit(1); + } + return hint; + } + + failIfMissing(opts.flagName, opts.usageHint); +} diff --git a/test/commands/aliases.test.ts b/test/commands/aliases.test.ts index 036d11a..12d349e 100644 --- a/test/commands/aliases.test.ts +++ b/test/commands/aliases.test.ts @@ -75,8 +75,8 @@ describe('vision describe --file alias', () => { const { default: describeCommand } = await import('../../src/commands/vision/describe'); let output = ''; - const origWrite = process.stdout.write.bind(process.stdout); - (process.stdout as NodeJS.WriteStream).write = (chunk: unknown) => { output += String(chunk); return true; }; + const origLog = console.log; + console.log = (msg: string) => { output += msg; }; try { await describeCommand.execute( @@ -86,7 +86,7 @@ describe('vision describe --file alias', () => { const parsed = JSON.parse(output); expect(parsed.request.image).toBe('photo.jpg'); } finally { - (process.stdout as NodeJS.WriteStream).write = origWrite; + console.log = origLog; } }); }); diff --git a/test/sdk/text.test.ts b/test/sdk/text.test.ts index 9219f22..1ed759b 100644 --- a/test/sdk/text.test.ts +++ b/test/sdk/text.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; +import { createMockServer, jsonResponse, sseResponse, type MockServer } from '../helpers/mock-server'; import { MiniMaxSDK } from '../../src/sdk'; describe('MiniMaxSDK.text', () => { @@ -35,4 +35,41 @@ describe('MiniMaxSDK.text', () => { expect(result.id).toBe('msg-123'); }); + + it('streaming skips empty SSE data events', async () => { + const chunk = JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hi' }, + }); + + server = createMockServer({ + routes: { + '/anthropic/v1/messages': () => sseResponse([ + { data: chunk }, + { data: '' }, + { data: chunk }, + ]), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const stream = await sdk.text.chat({ + messages: [{ role: 'user', content: 'Hi' }], + stream: true, + }); + + const events = []; + for await (const event of stream) { + events.push(event); + } + + expect(events.length).toBe(2); + expect(events[0].type).toBe('content_block_delta'); + expect(events[1].type).toBe('content_block_delta'); + }); });