From 15b018fc84a36a9ebde598970e5b44bebe5d68c6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 25 May 2026 14:10:32 +0800 Subject: [PATCH] fix: Surface API-provided error messages for OAuth and managed API failures (#11) --- .changeset/pass-api-error-messages.md | 6 ++ apps/kimi-code/src/tui/kimi-tui.ts | 8 +-- .../test/tui/kimi-tui-message-flow.test.ts | 39 ++++++++++- packages/node-sdk/test/auth-facade.test.ts | 9 ++- packages/oauth/src/api-error.ts | 66 +++++++++++++++++++ packages/oauth/src/managed-feedback.ts | 6 +- packages/oauth/src/managed-kimi-code.ts | 8 ++- packages/oauth/src/managed-usage.ts | 3 +- packages/oauth/src/oauth.ts | 33 ++++------ packages/oauth/src/open-platform.ts | 6 +- packages/oauth/test/managed-feedback.test.ts | 29 +++++++- packages/oauth/test/managed-kimi-code.test.ts | 17 +++++ packages/oauth/test/managed-usage.test.ts | 51 ++++++++++++++ packages/oauth/test/oauth.test.ts | 27 ++++++++ packages/oauth/test/open-platform.test.ts | 20 ++++-- 15 files changed, 288 insertions(+), 40 deletions(-) create mode 100644 .changeset/pass-api-error-messages.md create mode 100644 packages/oauth/src/api-error.ts diff --git a/.changeset/pass-api-error-messages.md b/.changeset/pass-api-error-messages.md new file mode 100644 index 000000000..3b66a7184 --- /dev/null +++ b/.changeset/pass-api-error-messages.md @@ -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. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9dc684f3c..92021068f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b854b535c..4edeb7780 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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 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(), diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 0e15dfefd..333fda774 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -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(async () => new Response('nope', { status: 401 })), + vi.fn( + 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'); }); }); diff --git a/packages/oauth/src/api-error.ts b/packages/oauth/src/api-error.ts new file mode 100644 index 000000000..e372abac4 --- /dev/null +++ b/packages/oauth/src/api-error.ts @@ -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 { + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return fallback; + } + + return extractApiErrorMessage(parsed) ?? fallback; +} + +function stringField(record: Record, 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; +} diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts index 91ff5d8ff..15c5a65cc 100644 --- a/packages/oauth/src/managed-feedback.ts +++ b/packages/oauth/src/managed-feedback.ts @@ -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' }; diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 6f6ee1e46..caca34c3b 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -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'])) { diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index a6dc3f030..771223395 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -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) }; diff --git a/packages/oauth/src/oauth.ts b/packages/oauth/src/oauth.ts index 3a1024877..83fe5b671 100644 --- a/packages/oauth/src/oauth.ts +++ b/packages/oauth/src/oauth.ts @@ -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 { - 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): TokenInfo { @@ -84,11 +83,8 @@ async function postForm( const status = response.status; let data: Record = {}; 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) { diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 328a9b2c5..da15c893c 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -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'])) { diff --git a/packages/oauth/test/managed-feedback.test.ts b/packages/oauth/test/managed-feedback.test.ts index 2ce23b18d..499945c21 100644 --- a/packages/oauth/test/managed-feedback.test.ts +++ b/packages/oauth/test/managed-feedback.test.ts @@ -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 () => { diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index fb0291923..504775820 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -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: { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 28dfa610b..98d33ee17 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -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', () => { diff --git a/packages/oauth/test/oauth.test.ts b/packages/oauth/test/oauth.test.ts index 82a5c7f0b..0c8c577cb 100644 --- a/packages/oauth/test/oauth.test.ts +++ b/packages/oauth/test/oauth.test.ts @@ -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, diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index 79aa60618..d179fc8f7 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -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 () => {