mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: Surface API-provided error messages for OAuth and managed API failures (#11)
This commit is contained in:
parent
e503e6963a
commit
15b018fc84
15 changed files with 288 additions and 40 deletions
6
.changeset/pass-api-error-messages.md
Normal file
6
.changeset/pass-api-error-messages.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-oauth": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Surface API-provided error messages during feedback, usage, login, and model setup failures.
|
||||
|
|
@ -164,13 +164,11 @@ import {
|
|||
FEEDBACK_ISSUE_URL,
|
||||
FEEDBACK_STATUS_CANCELLED,
|
||||
FEEDBACK_STATUS_FALLBACK,
|
||||
FEEDBACK_STATUS_NETWORK_ERROR,
|
||||
FEEDBACK_STATUS_NOT_SIGNED_IN,
|
||||
FEEDBACK_STATUS_SUBMITTING,
|
||||
FEEDBACK_STATUS_SUCCESS,
|
||||
FEEDBACK_TELEMETRY_EVENT,
|
||||
errorReportHintLine,
|
||||
feedbackHttpErrorMessage,
|
||||
feedbackSessionLine,
|
||||
withFeedbackVersionPrefix,
|
||||
} from './constant/feedback';
|
||||
|
|
@ -5078,11 +5076,7 @@ export class KimiTUI {
|
|||
return;
|
||||
}
|
||||
|
||||
const failLabel =
|
||||
res.status !== undefined
|
||||
? feedbackHttpErrorMessage(res.status)
|
||||
: FEEDBACK_STATUS_NETWORK_ERROR;
|
||||
spinner.stop({ ok: false, label: failLabel });
|
||||
spinner.stop({ ok: false, label: res.message });
|
||||
fallback(FEEDBACK_STATUS_FALLBACK);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui
|
|||
import type { QueuedMessage } from '#/tui/types';
|
||||
import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
|
||||
|
||||
vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
function stripSgr(text: string): string {
|
||||
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
|
@ -128,7 +130,11 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown>
|
|||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
getManagedUsage: vi.fn(),
|
||||
submitFeedback: vi.fn(async () => ({ kind: 'ok' })),
|
||||
submitFeedback: vi.fn(
|
||||
async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({
|
||||
kind: 'ok',
|
||||
}),
|
||||
),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -274,6 +280,37 @@ describe('KimiTUI message flow', () => {
|
|||
expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined);
|
||||
});
|
||||
|
||||
it('shows feedback API error messages without replacing them with HTTP status text', async () => {
|
||||
const { driver, harness } = await makeDriver(
|
||||
makeSession(),
|
||||
{
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
model: 'moonshot-v1',
|
||||
maxContextSize: 100,
|
||||
provider: 'managed:kimi-code',
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
);
|
||||
const feedbackDriver = driver as unknown as FeedbackDriver;
|
||||
feedbackDriver.promptFeedbackInput = vi.fn(async () => 'useful feedback');
|
||||
harness.auth.submitFeedback.mockResolvedValueOnce({
|
||||
kind: 'error',
|
||||
status: 500,
|
||||
message: 'backend says no',
|
||||
});
|
||||
|
||||
await feedbackDriver.handleFeedbackCommand();
|
||||
|
||||
const transcript = stripSgr(renderTranscript(driver));
|
||||
expect(transcript).toContain('backend says no');
|
||||
expect(transcript).toContain('Opening GitHub Issues as fallback');
|
||||
expect(transcript).not.toContain('Failed to submit feedback (HTTP 500).');
|
||||
});
|
||||
|
||||
it('does not track feedback when the dialog is cancelled', async () => {
|
||||
const { driver, harness } = await makeDriver(
|
||||
makeSession(),
|
||||
|
|
|
|||
|
|
@ -290,7 +290,13 @@ oauth = { storage = "file", key = "oauth/kimi-code" }
|
|||
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<FetchMock>(async () => new Response('nope', { status: 401 })),
|
||||
vi.fn<FetchMock>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ message: 'feedback API rejected the request' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const harness = new KimiHarness({ homeDir });
|
||||
|
|
@ -305,5 +311,6 @@ oauth = { storage = "file", key = "oauth/kimi-code" }
|
|||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.message).toBe('feedback API rejected the request');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
packages/oauth/src/api-error.ts
Normal file
66
packages/oauth/src/api-error.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { isRecord } from './utils';
|
||||
|
||||
const DIRECT_ERROR_KEYS = ['error_description', 'message', 'detail'] as const;
|
||||
const NESTED_ERROR_KEYS = ['message', 'error_description', 'detail', 'code', 'type'] as const;
|
||||
|
||||
export function extractApiErrorMessage(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const message = extractApiErrorMessage(item);
|
||||
if (message !== undefined) return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
for (const key of DIRECT_ERROR_KEYS) {
|
||||
const message = stringField(value, key);
|
||||
if (message !== undefined) return message;
|
||||
}
|
||||
|
||||
const error = value['error'];
|
||||
const errorString = nonEmptyString(error);
|
||||
if (errorString !== undefined) return errorString;
|
||||
|
||||
if (isRecord(error)) {
|
||||
for (const key of NESTED_ERROR_KEYS) {
|
||||
const message = stringField(error, key);
|
||||
if (message !== undefined) return message;
|
||||
}
|
||||
}
|
||||
|
||||
const errors = value['errors'];
|
||||
if (Array.isArray(errors)) {
|
||||
for (const item of errors) {
|
||||
const message = extractApiErrorMessage(item);
|
||||
if (message !== undefined) return message;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readApiErrorMessage(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return extractApiErrorMessage(parsed) ?? fallback;
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||
return nonEmptyString(record[key]);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
* backend can identify this client.
|
||||
*/
|
||||
|
||||
import { readApiErrorMessage } from './api-error';
|
||||
import { kimiCodeBaseUrl } from './managed-usage';
|
||||
|
||||
export interface SubmitFeedbackBody {
|
||||
|
|
@ -57,7 +58,10 @@ export async function fetchSubmitFeedback(
|
|||
return {
|
||||
kind: 'error',
|
||||
status: res.status,
|
||||
message: `Failed to submit feedback: HTTP ${String(res.status)}`,
|
||||
message: await readApiErrorMessage(
|
||||
res,
|
||||
`Failed to submit feedback: HTTP ${String(res.status)}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { kind: 'ok' };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readApiErrorMessage } from './api-error';
|
||||
import { kimiCodeBaseUrl } from './managed-usage';
|
||||
import { isRecord } from './utils';
|
||||
|
||||
|
|
@ -167,7 +168,12 @@ export async function fetchManagedKimiCodeModels(
|
|||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list Kimi Code models (HTTP ${response.status}).`);
|
||||
throw new Error(
|
||||
await readApiErrorMessage(
|
||||
response,
|
||||
`Failed to list Kimi Code models (HTTP ${response.status}).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
if (!isRecord(payload) || !Array.isArray(payload['data'])) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* `reset_at`, `duration+timeUnit` window labels, etc.).
|
||||
*/
|
||||
|
||||
import { readApiErrorMessage } from './api-error';
|
||||
import { isRecord } from './utils';
|
||||
|
||||
const MANAGED_PREFIX = 'managed:';
|
||||
|
|
@ -219,7 +220,7 @@ export async function fetchManagedUsage(
|
|||
: status === 404
|
||||
? 'Usage endpoint not available. Try Kimi For Coding.'
|
||||
: `Failed to fetch usage: HTTP ${String(status)}`;
|
||||
return { kind: 'error', status, message: hint };
|
||||
return { kind: 'error', status, message: await readApiErrorMessage(res, hint) };
|
||||
}
|
||||
const json: unknown = await res.json();
|
||||
return { kind: 'ok', parsed: parseManagedUsagePayload(json) };
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* when to poll / refresh / store.
|
||||
*/
|
||||
|
||||
import { extractApiErrorMessage } from './api-error';
|
||||
import { OAuthError, OAuthUnauthorizedError, RetryableRefreshError } from './errors';
|
||||
import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types';
|
||||
import { isRecord } from './utils';
|
||||
|
|
@ -17,9 +18,7 @@ import { isRecord } from './utils';
|
|||
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
||||
|
||||
function pickErrorDetail(data: Record<string, unknown>): string {
|
||||
if (typeof data['error_description'] === 'string') return data['error_description'];
|
||||
if (typeof data['error'] === 'string') return data['error'];
|
||||
return 'unknown';
|
||||
return extractApiErrorMessage(data) ?? 'unknown';
|
||||
}
|
||||
|
||||
function tokenFromResponse(payload: Record<string, unknown>): TokenInfo {
|
||||
|
|
@ -84,11 +83,8 @@ async function postForm(
|
|||
const status = response.status;
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text.length > 0) {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (isRecord(parsed)) data = parsed;
|
||||
}
|
||||
const parsed: unknown = await response.json();
|
||||
if (isRecord(parsed)) data = parsed;
|
||||
} catch {
|
||||
// Non-JSON response — leave data empty; caller interprets by status.
|
||||
}
|
||||
|
|
@ -109,7 +105,9 @@ export async function requestDeviceAuthorization(
|
|||
);
|
||||
|
||||
if (status !== 200) {
|
||||
throw new OAuthError(`Device authorization failed (HTTP ${status}): ${pickErrorDetail(data)}`);
|
||||
throw new OAuthError(
|
||||
`Device authorization failed (HTTP ${status}): ${pickErrorDetail(data)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Required-field validation for the device authorization response.
|
||||
|
|
@ -171,8 +169,9 @@ export async function pollDeviceToken(
|
|||
}
|
||||
|
||||
const errorCode = typeof data['error'] === 'string' ? data['error'] : 'unknown_error';
|
||||
const detail = extractApiErrorMessage(data);
|
||||
const description =
|
||||
typeof data['error_description'] === 'string' ? data['error_description'] : '';
|
||||
typeof data['error_description'] === 'string' ? data['error_description'] : (detail ?? '');
|
||||
switch (errorCode) {
|
||||
case 'authorization_pending':
|
||||
case 'slow_down':
|
||||
|
|
@ -183,7 +182,7 @@ export async function pollDeviceToken(
|
|||
return { kind: 'denied', description };
|
||||
default:
|
||||
throw new OAuthError(
|
||||
`Device token polling failed (HTTP ${status}): ${errorCode} ${description}`,
|
||||
`Device token polling failed (HTTP ${status}): ${detail ?? `${errorCode} ${description}`}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -246,18 +245,12 @@ export async function refreshAccessToken(
|
|||
}
|
||||
|
||||
const errorCode = typeof data['error'] === 'string' ? data['error'] : '';
|
||||
const detail = extractApiErrorMessage(data);
|
||||
if (status === 401 || status === 403 || errorCode === 'invalid_grant') {
|
||||
throw new OAuthUnauthorizedError(
|
||||
typeof data['error_description'] === 'string'
|
||||
? data['error_description']
|
||||
: 'Token refresh unauthorized.',
|
||||
);
|
||||
throw new OAuthUnauthorizedError(detail ?? 'Token refresh unauthorized.');
|
||||
}
|
||||
|
||||
const desc =
|
||||
typeof data['error_description'] === 'string'
|
||||
? data['error_description']
|
||||
: `Token refresh failed (HTTP ${status}).`;
|
||||
const desc = detail ?? `Token refresh failed (HTTP ${status}).`;
|
||||
if (RETRYABLE_STATUSES.has(status)) {
|
||||
lastError = new RetryableRefreshError(desc);
|
||||
if (attempt < maxRetries - 1) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readApiErrorMessage } from './api-error';
|
||||
import { isRecord } from './utils';
|
||||
import type {
|
||||
ManagedKimiCodeModelInfo,
|
||||
|
|
@ -93,7 +94,10 @@ export async function fetchOpenPlatformModels(
|
|||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new OpenPlatformApiError(`Failed to list models (HTTP ${res.status}).`, res.status);
|
||||
throw new OpenPlatformApiError(
|
||||
await readApiErrorMessage(res, `Failed to list models (HTTP ${res.status}).`),
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
if (!isRecord(payload) || !Array.isArray(payload['data'])) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe('fetchSubmitFeedback', () => {
|
|||
it('returns an error with status when the server responds 401', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 401 })),
|
||||
vi.fn(async () => new Response('', { status: 401 })),
|
||||
);
|
||||
|
||||
const result = await fetchSubmitFeedback(
|
||||
|
|
@ -85,10 +85,34 @@ describe('fetchSubmitFeedback', () => {
|
|||
expect(result.message).toMatch(/401/);
|
||||
});
|
||||
|
||||
it('surfaces API error messages from failed submissions', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'feedback rejected' } }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchSubmitFeedback(
|
||||
'https://api.example/feedback',
|
||||
'access-token',
|
||||
SAMPLE_BODY,
|
||||
);
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.message).toBe('feedback rejected');
|
||||
});
|
||||
|
||||
it('returns an error with status when the server responds 500', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('boom', { status: 500 })),
|
||||
vi.fn(async () => new Response('', { status: 500 })),
|
||||
);
|
||||
|
||||
const result = await fetchSubmitFeedback(
|
||||
|
|
@ -100,6 +124,7 @@ describe('fetchSubmitFeedback', () => {
|
|||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.message).toBe('Failed to submit feedback: HTTP 500');
|
||||
});
|
||||
|
||||
it('returns a timeout error when the request aborts', async () => {
|
||||
|
|
|
|||
|
|
@ -467,6 +467,23 @@ describe('provisionManagedKimiCodeConfig', () => {
|
|||
).rejects.toThrow(/positive context_length/);
|
||||
});
|
||||
|
||||
it('surfaces API error messages from model listing failures', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'quota exceeded' } }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchManagedKimiCodeModels({
|
||||
accessToken: 'oauth-access-token',
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow('quota exceeded');
|
||||
});
|
||||
|
||||
it('clears managed provider, models, default model, and services on logout', () => {
|
||||
const config: ManagedKimiConfigShape = {
|
||||
providers: {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,57 @@ describe('fetchManagedUsage', () => {
|
|||
expect(headers.get('user-agent')).toBeNull();
|
||||
expect(headers.get('x-msh-platform')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces JSON API error messages with status', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ message: 'usage quota unavailable' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.message).toBe('usage quota unavailable');
|
||||
});
|
||||
|
||||
it('surfaces nested JSON API error messages', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'usage endpoint moved' } }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.message).toBe('usage endpoint moved');
|
||||
});
|
||||
|
||||
it('falls back to local usage hints when the API error body is empty', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||
|
||||
const result = await fetchManagedUsage('https://api.example/usages', 'access-token');
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.message).toBe('Usage endpoint not available. Try Kimi For Coding.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
|
|
|
|||
|
|
@ -266,6 +266,15 @@ describe('requestDeviceAuthorization', () => {
|
|||
await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError);
|
||||
});
|
||||
|
||||
it('surfaces message fields from failed device authorization responses', async () => {
|
||||
server.enqueue('/api/oauth/device_authorization', {
|
||||
status: 400,
|
||||
body: { message: 'device authorization disabled' },
|
||||
});
|
||||
|
||||
await expect(requestAuth()).rejects.toThrow(/device authorization disabled/);
|
||||
});
|
||||
|
||||
it('throws when device_code is missing (required-field validation)', async () => {
|
||||
server.enqueue('/api/oauth/device_authorization', {
|
||||
status: 200,
|
||||
|
|
@ -382,6 +391,15 @@ describe('pollDeviceToken', () => {
|
|||
await expect(pollToken(flowConfig(), 'd')).rejects.toBeInstanceOf(OAuthError);
|
||||
});
|
||||
|
||||
it('surfaces nested API error messages from failed polling responses', async () => {
|
||||
server.enqueue('/api/oauth/token', {
|
||||
status: 400,
|
||||
body: { error: { code: 'invalid_request', message: 'poll rejected by server' } },
|
||||
});
|
||||
|
||||
await expect(pollToken(flowConfig(), 'd')).rejects.toThrow(/poll rejected by server/);
|
||||
});
|
||||
|
||||
it('throws when success response is missing refresh_token (required-field validation)', async () => {
|
||||
server.enqueue('/api/oauth/token', {
|
||||
status: 200,
|
||||
|
|
@ -480,6 +498,15 @@ describe('refreshAccessToken', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('surfaces nested API error messages from unauthorized refresh responses', async () => {
|
||||
server.enqueue('/api/oauth/token', {
|
||||
status: 401,
|
||||
body: { error: { message: 'refresh token revoked' } },
|
||||
});
|
||||
|
||||
await expect(refreshToken(flowConfig(), 'old-rt')).rejects.toThrow(/refresh token revoked/);
|
||||
});
|
||||
|
||||
it('throws OAuthUnauthorizedError on invalid_grant refresh responses', async () => {
|
||||
server.enqueue('/api/oauth/token', {
|
||||
status: 400,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
getOpenPlatformById,
|
||||
isOpenPlatformId,
|
||||
OPEN_PLATFORMS,
|
||||
OpenPlatformApiError,
|
||||
removeOpenPlatformConfig,
|
||||
type ManagedKimiConfigShape,
|
||||
} from '../src/open-platform';
|
||||
|
|
@ -95,13 +96,22 @@ describe('fetchOpenPlatformModels', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('throws on HTTP error', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('Unauthorized', { status: 401 }));
|
||||
it('surfaces API error messages and status on HTTP error', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'invalid API key' } }), { status: 401 }),
|
||||
);
|
||||
const platform = getOpenPlatformById('moonshot-cn')!;
|
||||
|
||||
await expect(
|
||||
fetchOpenPlatformModels(platform, 'sk-bad', fetchMock as unknown as typeof fetch),
|
||||
).rejects.toThrow('Failed to list models (HTTP 401).');
|
||||
const error = await fetchOpenPlatformModels(
|
||||
platform,
|
||||
'sk-bad',
|
||||
fetchMock as unknown as typeof fetch,
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(OpenPlatformApiError);
|
||||
expect((error as OpenPlatformApiError).status).toBe(401);
|
||||
expect((error as Error).message).toBe('invalid API key');
|
||||
});
|
||||
|
||||
it('throws on unexpected response shape', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue