fix: Surface API-provided error messages for OAuth and managed API failures (#11)

This commit is contained in:
liruifengv 2026-05-25 14:10:32 +08:00 committed by GitHub
parent e503e6963a
commit 15b018fc84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 288 additions and 40 deletions

View 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.

View file

@ -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);
}

View file

@ -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(),

View file

@ -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');
});
});

View 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;
}

View file

@ -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' };

View file

@ -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'])) {

View file

@ -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) };

View file

@ -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) {

View file

@ -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'])) {

View file

@ -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 () => {

View file

@ -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: {

View file

@ -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', () => {

View file

@ -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,

View file

@ -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 () => {