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
9 changes: 8 additions & 1 deletion src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { mapApiError, type ApiErrorBody } from "../errors/api";
import { SDKError } from "../errors/base";
import { ExitCode } from "../errors/codes";
import { MiniMaxSDKOptions } from "./types";
Expand Down Expand Up @@ -38,14 +39,20 @@ export class Client {
for await (const event of parseSSE(res)) {
if (event.data === '[DONE]') break;
if (!event.data) continue;
let chunk: T & ApiErrorBody;
try {
yield JSON.parse(event.data) as T;
chunk = JSON.parse(event.data) as T & ApiErrorBody;
} catch (err) {
throw new SDKError(
`Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`,
ExitCode.GENERAL,
);
}
const statusCode = chunk?.base_resp?.status_code;
if ((statusCode !== undefined && statusCode !== 0) || chunk?.error) {
throw mapApiError(res.status, chunk, res.url || undefined);
}
yield chunk as T;
}
}
}
88 changes: 88 additions & 0 deletions test/sdk/stream-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { MiniMaxSDK } from '../../src/sdk';
import { CLIError } from '../../src/errors/base';
import { ExitCode } from '../../src/errors/codes';
import { createMockServer, sseResponse, type MockServer } from '../helpers/mock-server';

describe('SDK SSE API errors', () => {
let server: MockServer;

afterEach(() => server?.close());

for (const modality of ['text', 'speech', 'transcription'] as const) {
it(`rejects a ${modality} API error without yielding it or subsequent events`, async () => {
const first = modality === 'text'
? { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Partial' } }
: modality === 'speech'
? { data: { audio: 'abcd', status: 1 }, base_resp: { status_code: 0, status_msg: 'success' } }
: { delta: 'Partial', index: 0, finish: false };
const error = modality === 'text'
? { type: 'error', error: { type: 'insufficient_balance_error', message: 'Balance exhausted' } }
: { base_resp: { status_code: 1028, status_msg: 'Insufficient quota' } };
server = createMockServer({
routes: {
'/': () => sseResponse([first, error, first].map(data => ({ data: JSON.stringify(data) }))),
},
});
const sdk = new MiniMaxSDK({ apiKey: 'test-key', region: 'global', baseUrl: server.url });
const stream = modality === 'text'
? await sdk.text.chat({ messages: [{ role: 'user', content: 'Hello' }], stream: true })
: modality === 'speech'
? await sdk.speech.synthesize({ text: 'Hello', stream: true })
: await sdk.speech.transcribe({ file: new Blob(['audio']), stream: true });

const initial = await stream.next();
expect(initial.done).toBe(false);
expect(initial.value as unknown).toEqual(first);
let caught: unknown;
try {
await stream.next();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CLIError);
if (modality === 'speech') {
expect((caught as CLIError).hint).toContain('Speech models require the Plus plan');
}
expect(caught).toMatchObject({
exitCode: ExitCode.QUOTA,
message: expect.stringContaining(modality === 'text' ? 'Balance exhausted' : 'Insufficient quota'),
hint: expect.stringContaining(modality === 'text' ? 'billing' : 'mmx quota show'),
});
expect(await stream.next()).toEqual({ value: undefined, done: true });
});
}

it('preserves the content-filter classification of the first speech event', async () => {
server = createMockServer({
routes: {
'/v1/t2a_v2': () => sseResponse([{
data: JSON.stringify({ base_resp: { status_code: 1027, status_msg: 'output new_sensitive' } }),
}]),
},
});
const sdk = new MiniMaxSDK({ apiKey: 'test-key', region: 'global', baseUrl: server.url });
const stream = await sdk.speech.synthesize({ text: 'Hello', stream: true });
await expect(stream.next()).rejects.toMatchObject({
exitCode: ExitCode.CONTENT_FILTER,
message: expect.stringContaining('output new_sensitive'),
});
});

it('preserves the message of an Anthropic-compatible server error', async () => {
server = createMockServer({
routes: {
'/anthropic/v1/messages': () => new Response(
'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
{ headers: { 'Content-Type': 'text/event-stream' } },
),
},
});
const sdk = new MiniMaxSDK({ apiKey: 'test-key', region: 'global', baseUrl: server.url });
const stream = await sdk.text.chat({ messages: [{ role: 'user', content: 'Hello' }], stream: true });
await expect(stream.next()).rejects.toMatchObject({
exitCode: ExitCode.GENERAL,
message: 'API error: Overloaded (HTTP 200)',
});
});
});
Loading