mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-15 19:55:36 +00:00
* fix: prevent streaming tool argument CPU spikes * test: update Anthropic streaming mock
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
import {
|
|
APIConnectionError,
|
|
APIContextOverflowError,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
ChatProviderError,
|
|
} from '#/errors';
|
|
import { convertAnthropicError, AnthropicChatProvider } from '#/providers/anthropic';
|
|
import {
|
|
APIConnectionError as AnthropicConnectionError,
|
|
APIConnectionTimeoutError as AnthropicTimeoutError,
|
|
APIError as AnthropicAPIError,
|
|
AnthropicError,
|
|
AuthenticationError as AnthropicAuthenticationError,
|
|
RateLimitError as AnthropicRateLimitError,
|
|
} from '@anthropic-ai/sdk';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
describe('convertAnthropicError', () => {
|
|
it('APIConnectionTimeoutError -> APITimeoutError (not misclassified as connection)', () => {
|
|
const err = new AnthropicTimeoutError({ message: 'timed out' });
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APITimeoutError);
|
|
// Must NOT be a plain APIConnectionError
|
|
expect(result.constructor).toBe(APITimeoutError);
|
|
});
|
|
|
|
it('APIConnectionError -> APIConnectionError', () => {
|
|
const err = new AnthropicConnectionError({ message: 'connection refused' });
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APIConnectionError);
|
|
});
|
|
|
|
it('APIError with status -> APIStatusError', () => {
|
|
const err = AnthropicAPIError.generate(
|
|
502,
|
|
{ type: 'error', error: { type: 'api_error', message: 'bad gateway' } },
|
|
'bad gateway',
|
|
new Headers(),
|
|
);
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APIStatusError);
|
|
expect((result as APIStatusError).statusCode).toBe(502);
|
|
});
|
|
|
|
it('context overflow APIError -> APIContextOverflowError', () => {
|
|
const err = AnthropicAPIError.generate(
|
|
422,
|
|
{
|
|
type: 'error',
|
|
error: {
|
|
type: 'invalid_request_error',
|
|
message: 'prompt is too long: 210000 tokens exceeds the maximum',
|
|
},
|
|
},
|
|
'prompt is too long: 210000 tokens exceeds the maximum',
|
|
new Headers(),
|
|
);
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APIContextOverflowError);
|
|
expect((result as APIContextOverflowError).statusCode).toBe(422);
|
|
});
|
|
|
|
it('AuthenticationError -> APIStatusError with 401', () => {
|
|
const err = new AnthropicAuthenticationError(
|
|
401,
|
|
{ type: 'error', error: { type: 'authentication_error', message: 'invalid key' } },
|
|
'invalid key',
|
|
new Headers(),
|
|
);
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APIStatusError);
|
|
expect((result as APIStatusError).statusCode).toBe(401);
|
|
});
|
|
|
|
it('RateLimitError -> APIStatusError with 429', () => {
|
|
const err = new AnthropicRateLimitError(
|
|
429,
|
|
{ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } },
|
|
'rate limited',
|
|
new Headers(),
|
|
);
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(APIStatusError);
|
|
expect((result as APIStatusError).statusCode).toBe(429);
|
|
});
|
|
|
|
it('generic AnthropicError -> ChatProviderError', () => {
|
|
const err = new AnthropicError('something went wrong');
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(ChatProviderError);
|
|
expect(result.message).toContain('something went wrong');
|
|
});
|
|
|
|
it('plain Error -> ChatProviderError', () => {
|
|
const err = new Error('unexpected');
|
|
const result = convertAnthropicError(err);
|
|
expect(result).toBeInstanceOf(ChatProviderError);
|
|
expect(result.message).toContain('unexpected');
|
|
});
|
|
|
|
it('non-Error value -> ChatProviderError', () => {
|
|
const result = convertAnthropicError('string error');
|
|
expect(result).toBeInstanceOf(ChatProviderError);
|
|
expect(result.message).toContain('string error');
|
|
});
|
|
});
|
|
describe('non-stream error propagation', () => {
|
|
function createNonStreamProvider(): AnthropicChatProvider {
|
|
return new AnthropicChatProvider({
|
|
model: 'k25',
|
|
apiKey: 'test-key',
|
|
defaultMaxTokens: 1024,
|
|
stream: false,
|
|
});
|
|
}
|
|
|
|
it('APIConnectionTimeoutError during generate is converted', async () => {
|
|
const provider = createNonStreamProvider();
|
|
const sdkError = new AnthropicTimeoutError({ message: 'stream timed out' });
|
|
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
|
|
|
|
await expect(
|
|
provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
),
|
|
).rejects.toThrow(APITimeoutError);
|
|
});
|
|
|
|
it('APIConnectionError during generate is converted', async () => {
|
|
const provider = createNonStreamProvider();
|
|
const sdkError = new AnthropicConnectionError({ message: 'connection reset' });
|
|
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
|
|
|
|
await expect(
|
|
provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
),
|
|
).rejects.toThrow(APIConnectionError);
|
|
});
|
|
|
|
it('APIError with status during generate is converted to APIStatusError', async () => {
|
|
const provider = createNonStreamProvider();
|
|
const sdkError = AnthropicAPIError.generate(
|
|
500,
|
|
{ type: 'error', error: { type: 'api_error', message: 'internal error' } },
|
|
'internal error',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
|
|
|
|
await expect(
|
|
provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
),
|
|
).rejects.toThrow(APIStatusError);
|
|
});
|
|
|
|
it('RateLimitError during generate is converted to APIStatusError(429)', async () => {
|
|
const provider = createNonStreamProvider();
|
|
const sdkError = new AnthropicRateLimitError(
|
|
429,
|
|
{ type: 'error', error: { type: 'rate_limit_error', message: 'too many requests' } },
|
|
'too many requests',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
|
|
|
|
try {
|
|
await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
expect.unreachable('Should have thrown');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(APIStatusError);
|
|
expect((error as APIStatusError).statusCode).toBe(429);
|
|
}
|
|
});
|
|
|
|
it('AuthenticationError during generate is converted to APIStatusError(401)', async () => {
|
|
const provider = createNonStreamProvider();
|
|
const sdkError = new AnthropicAuthenticationError(
|
|
401,
|
|
{ type: 'error', error: { type: 'authentication_error', message: 'invalid' } },
|
|
'invalid',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
|
|
|
|
try {
|
|
await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
expect.unreachable('Should have thrown');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(APIStatusError);
|
|
expect((error as APIStatusError).statusCode).toBe(401);
|
|
}
|
|
});
|
|
});
|
|
describe('stream error propagation', () => {
|
|
function createStreamProvider(): AnthropicChatProvider {
|
|
return new AnthropicChatProvider({
|
|
model: 'k25',
|
|
apiKey: 'test-key',
|
|
defaultMaxTokens: 1024,
|
|
stream: true,
|
|
});
|
|
}
|
|
|
|
function makeErrorStream(error: Error) {
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield {
|
|
type: 'message_start',
|
|
message: { id: 'msg_err', usage: { input_tokens: 0 } },
|
|
};
|
|
throw error;
|
|
},
|
|
};
|
|
}
|
|
|
|
it('APIConnectionTimeoutError during stream iteration is converted', async () => {
|
|
const provider = createStreamProvider();
|
|
const sdkError = new AnthropicTimeoutError({ message: 'stream timed out' });
|
|
(provider as any)._client.messages.create = vi
|
|
.fn()
|
|
.mockResolvedValue(makeErrorStream(sdkError)) as never;
|
|
|
|
const result = await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
const parts: unknown[] = [];
|
|
await expect(
|
|
(async () => {
|
|
for await (const part of result) {
|
|
parts.push(part);
|
|
}
|
|
})(),
|
|
).rejects.toThrow(APITimeoutError);
|
|
});
|
|
|
|
it('APIConnectionError during stream iteration is converted', async () => {
|
|
const provider = createStreamProvider();
|
|
const sdkError = new AnthropicConnectionError({ message: 'connection reset' });
|
|
(provider as any)._client.messages.create = vi
|
|
.fn()
|
|
.mockResolvedValue(makeErrorStream(sdkError)) as never;
|
|
|
|
const result = await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
await expect(
|
|
(async () => {
|
|
for await (const _ of result) {
|
|
void _;
|
|
}
|
|
})(),
|
|
).rejects.toThrow(APIConnectionError);
|
|
});
|
|
|
|
it('APIError with status during stream iteration is converted to APIStatusError', async () => {
|
|
const provider = createStreamProvider();
|
|
const sdkError = AnthropicAPIError.generate(
|
|
500,
|
|
{ type: 'error', error: { type: 'api_error', message: 'internal error' } },
|
|
'internal error',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi
|
|
.fn()
|
|
.mockResolvedValue(makeErrorStream(sdkError)) as never;
|
|
|
|
const result = await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
await expect(
|
|
(async () => {
|
|
for await (const _ of result) {
|
|
void _;
|
|
}
|
|
})(),
|
|
).rejects.toThrow(APIStatusError);
|
|
});
|
|
|
|
it('RateLimitError during stream iteration is converted to APIStatusError(429)', async () => {
|
|
const provider = createStreamProvider();
|
|
const sdkError = new AnthropicRateLimitError(
|
|
429,
|
|
{ type: 'error', error: { type: 'rate_limit_error', message: 'too many requests' } },
|
|
'too many requests',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi
|
|
.fn()
|
|
.mockResolvedValue(makeErrorStream(sdkError)) as never;
|
|
|
|
const result = await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
try {
|
|
for await (const _ of result) {
|
|
void _;
|
|
}
|
|
expect.unreachable('Should have thrown');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(APIStatusError);
|
|
expect((error as APIStatusError).statusCode).toBe(429);
|
|
}
|
|
});
|
|
|
|
it('AuthenticationError during stream iteration is converted to APIStatusError(401)', async () => {
|
|
const provider = createStreamProvider();
|
|
const sdkError = new AnthropicAuthenticationError(
|
|
401,
|
|
{ type: 'error', error: { type: 'authentication_error', message: 'invalid' } },
|
|
'invalid',
|
|
new Headers(),
|
|
);
|
|
(provider as any)._client.messages.create = vi
|
|
.fn()
|
|
.mockResolvedValue(makeErrorStream(sdkError)) as never;
|
|
|
|
const result = await provider.generate(
|
|
'',
|
|
[],
|
|
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
|
|
);
|
|
try {
|
|
for await (const _ of result) {
|
|
void _;
|
|
}
|
|
expect.unreachable('Should have thrown');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(APIStatusError);
|
|
expect((error as APIStatusError).statusCode).toBe(401);
|
|
}
|
|
});
|
|
});
|