diff --git a/.changeset/context-overflow-401-classification.md b/.changeset/context-overflow-401-classification.md new file mode 100644 index 0000000000..ec7c91fd05 --- /dev/null +++ b/.changeset/context-overflow-401-classification.md @@ -0,0 +1,22 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Classify Kimi managed providers' 401/403 context-window capability rejections +(" supports only context") as `context.overflow` instead of auth +errors. The managed subscription intentionally uses auth statuses for capability +errors; misclassifying them forced a pointless token-refresh retry and surfaced +a misleading "Authentication required" / "Run /login" for what is actually an +over-long context. + +Behavior changes worth noting: the OAuth auth wrappers (v1 and v2) skip the +forced refresh for these errors and surface `context.overflow` carrying the +provider's message internally; full compaction propagates that code unwrapped +and treats it as recoverable (shrink-and-retry); and ACP `session/prompt` now +rejects a `turn.ended` `context.overflow` failure with a JSON-RPC error +carrying the whitelisted public code as structured `data` +(`{ code: "context.overflow" }`) instead of silently resolving `end_turn` — +raw provider messages and stacks still never cross the wire. The wording gate +is Kimi-scoped and quantity-anchored, so other providers' 401/403s and genuine +credential failures are unaffected. diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..2e581a050d 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -1236,7 +1236,12 @@ export class AcpSession { // so the client can trigger its re-auth UX. Other failure // codes still resolve with `end_turn` (the spec discourages // signaling errors through `stopReason`; the failure is - // observable in the log). + // observable in the log) — with one exception: a + // `context.overflow` failure rejects with a JSON-RPC error + // carrying the machine-readable code as structured `data`, + // because resolving it as `end_turn` would make the client + // mistake a dead turn for a completed one. The raw provider + // message/stack never crosses the wire. log.warn('acp: turn ended with failed reason', { sessionId, error: event.error, @@ -1250,6 +1255,15 @@ export class AcpSession { reject(authErr); return; } + if (event.error?.code === ErrorCodes.CONTEXT_OVERFLOW) { + reject( + RequestError.internalError( + { code: ErrorCodes.CONTEXT_OVERFLOW }, + 'session prompt failed', + ), + ); + return; + } } else { if (event.reason === 'blocked') { // Provider safety and prompt hooks both map to ACP `refusal` @@ -1605,7 +1619,42 @@ function mapPromptError(err: unknown, sessionId: string): RequestError { sessionId, error: err instanceof Error ? { message: err.message, stack: err.stack } : String(err), }); - return RequestError.internalError(undefined, 'session prompt failed'); + // Keep the raw message/stack off the wire (see the privacy regression + // test), but forward a whitelisted public KimiError code as structured + // data so clients can tell e.g. context.overflow apart from a generic + // failure without diving into agent logs. + const code = extractPublicErrorCode(err); + return RequestError.internalError( + code === undefined ? undefined : { code }, + 'session prompt failed', + ); +} + +/** + * Public KimiError codes allowed to cross the ACP wire as structured + * `data.code`. Anything else that happens to carry a string `code` — Node + * `ErrnoException` ('ENOENT'), undici codes, internal-only taxonomy — + * stays local: the client gets a bare `-32603`. + */ +const PUBLIC_WIRE_ERROR_CODES: ReadonlySet = new Set([ + ErrorCodes.CONTEXT_OVERFLOW, + ErrorCodes.PROVIDER_API_ERROR, + ErrorCodes.PROVIDER_FILTERED, + ErrorCodes.PROVIDER_RATE_LIMIT, +]); + +/** + * Read a thrown value's `code` ONLY when it is one of the + * {@link PUBLIC_WIRE_ERROR_CODES}; the ACP boundary may have stripped class + * identity, so a bare `instanceof KimiError` check is not reliable and an + * unchecked string read would leak non-taxonomy codes onto the wire. + */ +function extractPublicErrorCode(err: unknown): string | undefined { + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code?: unknown }).code; + if (typeof code === 'string' && PUBLIC_WIRE_ERROR_CODES.has(code)) return code; + } + return undefined; } /** diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts index f05bfef128..e04c0b7cde 100644 --- a/packages/acp-adapter/test/error-mapping.test.ts +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -164,14 +164,15 @@ describe('AcpServer error mapping', () => { }); it('resolves with end_turn when turn.ended fails with a non-auth code (log-only path)', async () => { - // Non-auth failures stay on the existing log-and-resolve path so - // the client is unblocked. The error appears in the agent log; - // `stopReason` does not signal it (ACP spec discourages errors-via-stopReason). - const sessionId = 'sess-context-overflow'; + // Non-auth failures other than context.overflow stay on the existing + // log-and-resolve path so the client is unblocked. The error appears in + // the agent log; `stopReason` does not signal it (ACP spec discourages + // errors-via-stopReason). + const sessionId = 'sess-provider-api-error'; const errorPayload: KimiErrorPayload = { - code: ErrorCodes.CONTEXT_OVERFLOW, - message: 'Context window exceeded', - retryable: true, + code: ErrorCodes.PROVIDER_API_ERROR, + message: 'Provider returned 500', + retryable: false, }; const { session, unsubscribeCount } = makeScriptedSession(sessionId, { script: [ @@ -196,6 +197,47 @@ describe('AcpServer error mapping', () => { expect(unsubscribeCount()).toBe(1); }); + it('rejects a turn.ended context.overflow failure with the machine-readable code (full lifecycle)', async () => { + // The production failure path: a turn dies with context.overflow and the + // ACP client must get an actionable signal — resolving end_turn would + // look like a completed turn. The wire carries the whitelisted code as + // structured data only; the raw provider message stays in the agent log. + const sessionId = 'sess-turn-ended-overflow'; + const rawProviderMessage = '401 k3-256k supports only 256K context.'; + const errorPayload: KimiErrorPayload = { + code: ErrorCodes.CONTEXT_OVERFLOW, + message: rawProviderMessage, + retryable: true, + }; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, { + script: [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'failed', + error: errorPayload, + } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + let captured: unknown; + try { + await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + } catch (err) { + captured = err; + } + expect(captured).toMatchObject({ code: -32603, data: { code: 'context.overflow' } }); + expect(JSON.stringify(captured)).not.toContain(rawProviderMessage); + expect(unsubscribeCount()).toBe(1); + }); + it('maps a synchronous session.prompt rejection carrying an auth code to authRequired (-32000)', async () => { const sessionId = 'sess-prompt-rejects-auth'; const { session } = makeScriptedSession(sessionId, { @@ -243,6 +285,56 @@ describe('AcpServer error mapping', () => { expect(serialized).not.toContain('boom internal'); }); + it('forwards the KimiError code as structured data on non-auth prompt rejections', async () => { + // A context.overflow rejection is not an auth failure: the client gets a + // generic internalError whose `data.code` carries the machine-readable + // cause — never the raw provider message. + const sessionId = 'sess-prompt-rejects-overflow'; + const { session } = makeScriptedSession(sessionId, { + rejectWith: new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + '401 k3-256k supports only 256K context.', + ), + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + let captured: unknown; + try { + await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + } catch (err) { + captured = err; + } + expect(captured).toMatchObject({ code: -32603, data: { code: 'context.overflow' } }); + expect(JSON.stringify(captured)).not.toContain('supports only 256K context'); + }); + + it('does not forward non-whitelist codes as wire data on prompt rejections', async () => { + // A Node ErrnoException-style code is not KimiError taxonomy: the client + // gets a bare -32603 with no `data`, not a foreign code it cannot act on. + const sessionId = 'sess-prompt-rejects-errno'; + const errno = new Error('spawn ENOENT') as Error & { code: string }; + errno.code = 'ENOENT'; + const { session } = makeScriptedSession(sessionId, { rejectWith: errno }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + let captured: unknown; + try { + await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + } catch (err) { + captured = err; + } + expect(captured).toMatchObject({ code: -32603 }); + expect(JSON.stringify(captured)).not.toContain('ENOENT'); + }); + it('still maps reason: cancelled to stop_reason: cancelled (Phase 3/4 regression guard)', async () => { const sessionId = 'sess-cancel-regression'; const { session } = makeScriptedSession(sessionId, { diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index bea23797a8..16f0e3411e 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -348,6 +348,32 @@ export function isContextOverflowErrorCode(code: string | null | undefined): boo return code === 'context_length_exceeded'; } +// Moonshot's managed subscription intentionally uses auth statuses (401/403) +// for capability rejections worded as " supports only context" — +// e.g. "k3-256k supports only 256K context." or "Your current plan supports +// only kimi-k3 up to 256K context". The quantity right after "supports only" +// (optionally behind a model/plan qualifier) is what keeps this +// high-confidence: genuine auth/scope rejections that merely mention +// "context" in prose carry no size, so phrasings like "supports only Bearer +// token authentication … context" or "supports only the chat scope … +// context" must NOT match — a false positive would skip the OAuth token +// refresh a real credential failure needs. Twin of the v1 helper in +// `kosong/src/providers/kimi-errors.ts` (v2 vendors its own kosong copy). +const KIMI_CONTEXT_CAPABILITY_MESSAGE_PATTERN = + /supports only (?:[\w.-]+ )*(?:up to )?\d[\d.,]*\s*[kmgt]?\s*(?:tokens?(?: of)? )?context/; + +/** + * Whether a status error from a Kimi managed provider is a context-window + * capability rejection misusing an auth status, rather than a credential + * failure. Kimi-vendor knowledge only — deliberately NOT part of the + * provider-agnostic classification above, so other providers + * (OpenAI / Anthropic / Google) never have their 401/403 re-labeled. + */ +export function isKimiContextCapabilityError(statusCode: number, message: string): boolean { + if (statusCode !== 401 && statusCode !== 403) return false; + return KIMI_CONTEXT_CAPABILITY_MESSAGE_PATTERN.test(message.toLowerCase()); +} + export function normalizeAPIStatusError( statusCode: number, message: string, @@ -519,6 +545,11 @@ export function classifyApiError(error: unknown): ApiErrorClassification { if (isContextOverflowStatusError(error.statusCode, error.message)) { return { kind: 'context_overflow', statusCode }; } + // Kimi managed providers intentionally use 401/403 for context-window + // capability rejections — classify those as overflow, not auth. + if (isKimiContextCapabilityError(error.statusCode, error.message)) { + return { kind: 'context_overflow', statusCode }; + } if (error.statusCode === 429) return { kind: 'rate_limit', statusCode }; if (error.statusCode === 529) return { kind: 'overloaded', statusCode }; if (error.statusCode === 401 || error.statusCode === 403) return { kind: 'auth', statusCode }; diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts index b22ed1eab7..3039fc63ba 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts @@ -22,7 +22,13 @@ import { AsyncEventQueue } from '#/_base/asyncEventQueue'; import type { VideoURLPart } from '#/kosong/contract/message'; -import { APIStatusError, isAbortError, VideoUploadUnsupportedError } from '#/kosong/contract/errors'; +import { + APIContextOverflowError, + APIStatusError, + isAbortError, + isKimiContextCapabilityError, + VideoUploadUnsupportedError, +} from '#/kosong/contract/errors'; import { generate, type GenerateResult } from '#/kosong/contract/generate'; import type { ChatProvider, @@ -189,6 +195,12 @@ export class ModelRequesterImpl implements ModelRequester { try { return await run(auth); } catch (error) { + // A Kimi-managed 401 worded as a context-window capability error + // (" supports only context") is not a credential rejection: + // a token refresh cannot shrink the request, so skip the forced + // refresh and surface context.overflow with the provider's message. + const capability = asContextCapabilityOverflow(error); + if (capability !== undefined) throw capability; if (!this.shouldForceRefresh(error)) throw error; } @@ -196,6 +208,8 @@ export class ModelRequesterImpl implements ModelRequester { try { return await run(refreshedAuth); } catch (error) { + const capability = asContextCapabilityOverflow(error); + if (capability !== undefined) throw capability; if (isUnauthorizedStatusError(error)) throw translateProviderError(error); throw error; } @@ -214,6 +228,24 @@ function isUnauthorizedStatusError(error: unknown): error is APIStatusError { return error instanceof APIStatusError && error.statusCode === 401; } +/** + * Re-mint a Kimi-managed 401/403 context-window capability rejection as an + * `APIContextOverflowError` (code `context.overflow`), preserving the + * provider's message/request metadata. Returns `undefined` for anything + * else, so callers can fall through to the normal auth mapping. + */ +function asContextCapabilityOverflow(error: unknown): APIContextOverflowError | undefined { + if (!(error instanceof APIStatusError)) return undefined; + if (!isKimiContextCapabilityError(error.statusCode, error.message)) return undefined; + return new APIContextOverflowError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + error.traceId, + ); +} + type MutableModelRequestTiming = { -readonly [K in keyof ModelRequestTiming]: ModelRequestTiming[K] }; export function buildStreamTiming( diff --git a/packages/agent-core-v2/test/kosong/contract/errors.test.ts b/packages/agent-core-v2/test/kosong/contract/errors.test.ts index 2bf3efc758..c905fd2f59 100644 --- a/packages/agent-core-v2/test/kosong/contract/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/contract/errors.test.ts @@ -183,6 +183,28 @@ describe('classifyApiError', () => { ); }); + it('classifies a Kimi context-worded 401/403 as context_overflow, not auth', () => { + // The Kimi managed provider intentionally uses auth statuses for + // capability rejections (" supports only context"). + expect( + classifyApiError(new APIStatusError(401, '401 k3-256k supports only 256K context.')).kind, + ).toBe('context_overflow'); + expect( + classifyApiError( + new APIStatusError(403, 'Your current plan supports only kimi-k3 up to 256K context'), + ).kind, + ).toBe('context_overflow'); + // Auth-flavored prose with both words but no quantity stays auth. + expect( + classifyApiError( + new APIStatusError( + 401, + 'This endpoint supports only Bearer token authentication. See the docs for context on migrating.', + ), + ).kind, + ).toBe('auth'); + }); + it('falls back to other for unknown values', () => { expect(classifyApiError(new Error('boom')).kind).toBe('other'); expect(classifyApiError('boom').kind).toBe('other'); diff --git a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts index d145688776..b3671f287e 100644 --- a/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelRequester.test.ts @@ -275,6 +275,61 @@ describe('ModelRequesterImpl request execution', () => { expect(provider.calls).toHaveLength(1); }); + it('skips the forced refresh on a Kimi context-worded 401 and surfaces context.overflow', async () => { + // The Kimi managed provider intentionally uses 401 for capability + // rejections; a token refresh cannot shrink the request, so the + // requester must not replay and must not label it provider.auth_error. + const provider = new FakeChatProvider(); + provider.handler = () => + Promise.reject(new APIStatusError(401, '401 k3-256k supports only 256K context.')); + const authCalls: Array<{ force?: boolean }> = []; + const requester = new ModelRequesterImpl( + modelWith({ + canRefresh: true, + getAuth: (options) => { + authCalls.push(options ?? {}); + return Promise.resolve({ apiKey: 'tok' }); + }, + }), + registryReturning(provider), + ); + + const failure = await collect(requester.request(INPUT)).catch((error: unknown) => error); + expect((failure as { code: string }).code).toBe(ProtocolErrors.codes.CONTEXT_OVERFLOW); + expect((failure as Error).message).toContain('supports only 256K context'); + expect(provider.calls).toHaveLength(1); + expect(authCalls).toEqual([{}]); + }); + + it('still force-refreshes a 401 that mentions supports-only/context without a size', async () => { + // Auth-flavored prose with both words but no quantity stays on the auth + // path: the wording gate must not swallow real credential failures. + const provider = new FakeChatProvider(); + provider.handler = () => + Promise.reject( + new APIStatusError( + 401, + 'This endpoint supports only Bearer token authentication. See the docs for context on migrating.', + ), + ); + const authCalls: Array<{ force?: boolean }> = []; + const requester = new ModelRequesterImpl( + modelWith({ + canRefresh: true, + getAuth: (options) => { + authCalls.push(options ?? {}); + return Promise.resolve({ apiKey: 'tok' }); + }, + }), + registryReturning(provider), + ); + + const failure = await collect(requester.request(INPUT)).catch((error: unknown) => error); + expect((failure as { code: string }).code).toBe(ProtocolErrors.codes.PROVIDER_AUTH_ERROR); + expect(provider.calls).toHaveLength(2); + expect(authCalls).toEqual([{}, { force: true }]); + }); + it('translates other provider failures and rethrows aborts untouched', async () => { const provider = new FakeChatProvider(); provider.handler = () => Promise.reject(new APIStatusError(500, 'boom')); diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index f56c7bc919..b363e90fe1 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -143,6 +143,10 @@ export class FullCompaction { estimatedRequestTokens = this.estimateCurrentRequestTokens(), ): boolean { if (error instanceof APIContextOverflowError) return true; + // The OAuth auth wrappers re-throw a Kimi-managed 401/403 capability + // rejection as KimiError(context.overflow) — the request is equally + // oversized, so it gets the same shrink-and-retry recovery. + if (isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW) return true; if (!(error instanceof APIStatusError) || error.statusCode !== 413) return false; const effectiveMax = this.getEffectiveMaxContextTokens(); return ( @@ -661,7 +665,8 @@ export class FullCompaction { if ( isKimiError(error) && (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || - error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + error.code === ErrorCodes.PROVIDER_AUTH_ERROR || + error.code === ErrorCodes.CONTEXT_OVERFLOW) ) throw error; throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index ccdeed9399..70603f1fad 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -10,6 +10,7 @@ import { APITimeoutError, inputTotal, isContextOverflowStatusError, + isKimiContextCapabilityError, type ContentPart, type Message, type TokenUsage, @@ -1552,6 +1553,13 @@ function classifyApiError(error: unknown, summary: KimiErrorPayload): ApiErrorCl const statusCode = apiStatusCode(error) ?? summaryStatusCode(summary); if (statusCode !== undefined) { if (statusCode === 429) return { errorType: 'rate_limit', statusCode }; + // Kimi managed providers intentionally use 401/403 for context-window + // capability rejections (" supports only context") — those + // are not credential failures, so check the Kimi-scoped wording before + // the auth statuses. + if (isKimiContextCapabilityError(statusCode, summary.message)) { + return { errorType: 'context_overflow', statusCode }; + } if (statusCode === 401 || statusCode === 403) return { errorType: 'auth', statusCode }; if (statusCode >= 500) return { errorType: '5xx_server', statusCode }; if (isContextOverflowStatusError(statusCode, summary.message)) { diff --git a/packages/agent-core/src/errors/serialize.ts b/packages/agent-core/src/errors/serialize.ts index 632334c8d7..b4961bbf78 100644 --- a/packages/agent-core/src/errors/serialize.ts +++ b/packages/agent-core/src/errors/serialize.ts @@ -5,6 +5,7 @@ import { APIStatusError, APITimeoutError, ChatProviderError, + isKimiContextCapabilityError, } from '@moonshot-ai/kosong'; import { KimiError } from './classes'; @@ -62,7 +63,12 @@ export function makeErrorPayload( * Exception: a quota-exhausted 429 maps to api_error (retryable: false) — * the rate_limit code would re-mint a rate-limit error across the wire * boundary and drive the swarm requeue/suspend loop, which cannot help - * until the account is recharged. + * until the account is recharged. A second exception: a Kimi managed + * provider's 401/403 worded as a context-window capability error + * (" supports only context" — the provider intentionally uses + * auth statuses for capability rejections) maps to context.overflow + * instead of auth_error, since re-authentication cannot change the + * request's context size. * - `APIConnectionError` / `APITimeoutError`: connection_error. * - `ChatProviderError`: api_error. * @@ -86,9 +92,11 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload { ? ErrorCodes.PROVIDER_API_ERROR : error.statusCode === 429 ? ErrorCodes.PROVIDER_RATE_LIMIT - : error.statusCode === 401 - ? ErrorCodes.PROVIDER_AUTH_ERROR - : ErrorCodes.PROVIDER_API_ERROR; + : isKimiContextCapabilityError(error.statusCode, error.message) + ? ErrorCodes.CONTEXT_OVERFLOW + : error.statusCode === 401 + ? ErrorCodes.PROVIDER_AUTH_ERROR + : ErrorCodes.PROVIDER_API_ERROR; return { code, message: sanitizeStatusErrorMessage(error.message), diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 7fb313b461..84d61209cd 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -4,6 +4,7 @@ import { APIStatusError, classifyKimiQuotaError, getModelCapability, + isKimiContextCapabilityError, UNKNOWN_CAPABILITY, } from '@moonshot-ai/kosong'; import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; @@ -215,8 +216,23 @@ export class ProviderManager implements ModelProvider { return await request(auth); } catch (error) { if (!(error instanceof APIStatusError) || error.statusCode !== 401) throw error; + const reason = error.message.replaceAll('\r', ''); + // A 401 worded as a context-window capability error (" + // supports only context") is not a credential rejection — the + // Kimi managed provider intentionally uses 401 for capability + // errors, and a token refresh cannot change the context size. + // Skip the refresh retry and surface the real, actionable cause. + if (isKimiContextCapabilityError(error.statusCode, reason)) { + throw new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + reason.length > 0 ? reason : 'Request exceeds the model context window.', + { + cause: error, + details: { statusCode: error.statusCode, requestId: error.requestId }, + }, + ); + } if (refreshed) { - const reason = error.message.replaceAll('\r', ''); throw new KimiError( ErrorCodes.PROVIDER_AUTH_ERROR, reason.length > 0 ? reason : 'OAuth provider credentials were rejected.', diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index abc4138dfa..2f9d52ae22 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1053,6 +1053,52 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('passes a Kimi context-worded 401 through auto compaction as context.overflow without a token refresh', async () => { + // Production chain: the Kimi managed provider answers the oversized + // compaction request with a capability 401; the OAuth wrapper rethrows + // KimiError(context.overflow) WITHOUT a forced token refresh, and + // compaction must propagate that code instead of wrapping it into the + // generic compaction.failed. + const tokenCalls: Array = []; + const generate: GenerateFn = async () => { + throw new APIStatusError(401, '401 k3-256k supports only 256K context.', 'req-ctx-401'); + }; + const ctx = testAgent({ + ...oauthTestAgentOptions(async (options) => { + tokenCalls.push(options?.force); + return 'fresh-token'; + }), + generate, + compactionStrategy: alwaysCompactOnce, + }); + ctx.configure(); + await ctx.rpc.setModel({ model: 'kimi-code' }); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger failed auto compaction' }] }); + const events = await ctx.untilTurnEnd(); + + expect(tokenCalls).not.toContain(true); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'failed', + error: expect.objectContaining({ code: 'context.overflow' }), + }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + error: expect.objectContaining({ code: 'compaction.failed' }), + }), + }), + ); + await ctx.expectResumeMatches(); + }); + it('names truncated compaction responses when retries are exhausted', async () => { vi.useFakeTimers(); let attempts = 0; diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 08b29fc194..5f5de5fef2 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -2624,6 +2624,48 @@ describe('Agent turn flow', () => { expect(result.output).not.toContain('Send /login to login'); }); + it('does not token-refresh on a context-worded 401 and fails the turn with context.overflow', async () => { + // The managed provider intentionally returns 401 for capability errors + // (" supports only context"). A token refresh cannot shrink + // the request, so the OAuth wrapper must skip the forced refresh and the + // turn must surface context.overflow — never an auth error. + const tokenCalls: Array = []; + const oauthOptions = oauthAgentOptions(async (options) => { + tokenCalls.push(options?.force); + return 'fresh-token'; + }); + const generate: GenerateFn = async () => { + throw new APIStatusError(401, '401 k3-256k supports only 256K context.', 'req-ctx-401'); + }; + const ctx = testAgent({ ...oauthOptions, generate }); + ctx.configure(); + await ctx.rpc.setModel({ model: 'kimi-code' }); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + const events = await ctx.untilTurnEnd(); + + expect(tokenCalls.length).toBeGreaterThan(0); + expect(tokenCalls).not.toContain(true); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'failed', + error: expect.objectContaining({ code: 'context.overflow' }), + }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + error: expect.objectContaining({ code: 'provider.auth_error' }), + }), + }), + ); + }); + it('cancels an active turn', async () => { const records: TelemetryRecord[] = []; const ctx = testAgent({ diff --git a/packages/agent-core/test/errors/serialize.test.ts b/packages/agent-core/test/errors/serialize.test.ts index db095b0613..6f2a878652 100644 --- a/packages/agent-core/test/errors/serialize.test.ts +++ b/packages/agent-core/test/errors/serialize.test.ts @@ -66,3 +66,34 @@ describe('toKimiErrorPayload — quota-exhausted 429', () => { expect(payload.details).toMatchObject({ statusCode: 429, requestId: 'req-quota' }); }); }); + +describe('toKimiErrorPayload — 401/403 context-window capability errors', () => { + it('maps a 401 worded as "supports only N context" to context.overflow, keeping the server message', () => { + // Kimi managed providers intentionally use 401 for capability rejections; + // re-authentication cannot shrink the request, so this must not surface + // as provider.auth_error. + const payload = toKimiErrorPayload( + new APIStatusError(401, '401 k3-256k supports only 256K context.', 'req-ctx'), + ); + expect(payload.code).toBe('context.overflow'); + expect(payload.message).toContain('supports only 256K context'); + expect(payload.details).toMatchObject({ statusCode: 401, requestId: 'req-ctx' }); + }); + + it('maps a 403 worded as a plan context limit to context.overflow', () => { + const payload = toKimiErrorPayload( + new APIStatusError(403, 'Your current plan supports only kimi-k3 up to 256K context'), + ); + expect(payload.code).toBe('context.overflow'); + }); + + it('keeps a 401 without context-overflow wording as provider.auth_error', () => { + expect(toKimiErrorPayload(new APIStatusError(401, 'Invalid token')).code).toBe( + 'provider.auth_error', + ); + // A 403 without context-overflow wording keeps the generic mapping. + expect(toKimiErrorPayload(new APIStatusError(403, 'Forbidden')).code).toBe( + 'provider.api_error', + ); + }); +}); diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index cd0440637b..895a184740 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -32,7 +32,7 @@ export type { ProviderConfig, ProviderType } from './providers'; // kwargs, `thinking.keep` extra body). export { KimiChatProvider } from './providers/kimi'; export type { ExtraBody, GenerationKwargs, KimiOptions, ThinkingConfig } from './providers/kimi'; -export { classifyKimiQuotaError } from './providers/kimi-errors'; +export { classifyKimiQuotaError, isKimiContextCapabilityError } from './providers/kimi-errors'; // Model capability matrix export { isUnknownCapability, UNKNOWN_CAPABILITY } from './capability'; diff --git a/packages/kosong/src/providers/kimi-errors.ts b/packages/kosong/src/providers/kimi-errors.ts index 88109a1f22..ac8163114c 100644 --- a/packages/kosong/src/providers/kimi-errors.ts +++ b/packages/kosong/src/providers/kimi-errors.ts @@ -85,3 +85,28 @@ export function classifyKimiQuotaError( parseTraceId(headers), ); } + +// Moonshot's managed subscription intentionally uses auth statuses (401/403) +// for capability rejections worded as " supports only context" — +// e.g. "k3-256k supports only 256K context." or "Your current plan supports +// only kimi-k3 up to 256K context". The quantity right after "supports only" +// (optionally behind a model/plan qualifier) is what keeps this +// high-confidence: genuine auth/scope rejections that merely mention +// "context" in prose carry no size, so phrasings like "supports only Bearer +// token authentication … context" or "supports only the chat scope … +// context" must NOT match — a false positive would skip the OAuth token +// refresh a real credential failure needs. +const KIMI_CONTEXT_CAPABILITY_MESSAGE_PATTERN = + /supports only (?:[\w.-]+ )*(?:up to )?\d[\d.,]*\s*[kmgt]?\s*(?:tokens?(?: of)? )?context/; + +/** + * Whether a status error from a Kimi managed provider is a context-window + * capability rejection misusing an auth status, rather than a credential + * failure. Kimi-vendor knowledge only — deliberately NOT part of the + * provider-agnostic classification in `#/errors`, so other providers + * (OpenAI / Anthropic / Google) never have their 401/403 re-labeled. + */ +export function isKimiContextCapabilityError(statusCode: number, message: string): boolean { + if (statusCode !== 401 && statusCode !== 403) return false; + return KIMI_CONTEXT_CAPABILITY_MESSAGE_PATTERN.test(message.toLowerCase()); +} diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 35601942b9..78ec9e2de0 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -3,7 +3,7 @@ import { APIProviderQuotaExhaustedError, isRetryableGenerateError } from '#/erro import { generate } from '#/generate'; import type { ContentPart, Message, ToolCall } from '#/message'; import { extractUsageFromChunk, KimiChatProvider } from '#/providers/kimi'; -import { classifyKimiQuotaError } from '#/providers/kimi-errors'; +import { classifyKimiQuotaError, isKimiContextCapabilityError } from '#/providers/kimi-errors'; import { extractUsage } from '#/providers/openai-common'; import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; @@ -2232,3 +2232,36 @@ describe('classifyKimiQuotaError', () => { expect(isRetryableGenerateError(error)).toBe(false); }); }); + +describe('isKimiContextCapabilityError', () => { + it.each([ + // The two wordings observed from the Kimi managed subscription. + [401, 'k3-256k supports only 256K context.'], + [401, '401 k3-256k supports only 256K context.'], + [403, 'Your current plan supports only kimi-k3 up to 256K context'], + [401, 'model supports only 128000 tokens of context'], + ])('matches the capability rejection %i "%s"', (statusCode, message) => { + expect(isKimiContextCapabilityError(statusCode, message)).toBe(true); + }); + + it.each([ + // Genuine auth/scope rejections that merely mention "context" in prose: + // no quantity after "supports only", so they must stay auth errors and + // keep their OAuth refresh path. + [401, 'This endpoint supports only Bearer token authentication. See the docs for context on migrating.'], + [403, 'Your API key supports only the chat scope; contact support in the context of an upgrade.'], + [401, 'Authentication failed: account supports only SSO. Provide context.'], + [401, 'Token supports only read access to this context'], + [401, 'OAuth client supports only confidential clients in this context'], + [401, '{"error":{"message":"This key supports only the v1 endpoints"},"context":"auth"}'], + [401, 'This model supports only text input; image context is not accepted.'], + [401, 'Invalid authentication credentials'], + [403, 'Forbidden'], + // The wording gate only applies to the auth statuses the Kimi managed + // provider abuses; other statuses keep the base classification. + [400, 'k3-256k supports only 256K context.'], + [429, 'k3-256k supports only 256K context.'], + ])('rejects %i "%s"', (statusCode, message) => { + expect(isKimiContextCapabilityError(statusCode, message)).toBe(false); + }); +}); diff --git a/packages/node-sdk/src/kimi-code-model-provider.ts b/packages/node-sdk/src/kimi-code-model-provider.ts index 39350c13d9..3d666e4f8b 100644 --- a/packages/node-sdk/src/kimi-code-model-provider.ts +++ b/packages/node-sdk/src/kimi-code-model-provider.ts @@ -21,7 +21,11 @@ import type { ProviderConfig as KosongProviderConfig, ProviderRequestAuth, } from '@moonshot-ai/kosong'; -import { APIStatusError, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { + APIStatusError, + isKimiContextCapabilityError, + UNKNOWN_CAPABILITY, +} from '@moonshot-ai/kosong'; import { mapOAuthTokenError } from '#/oauth-error'; @@ -112,6 +116,22 @@ export class KimiForCodingProvider implements ModelProvider { } catch (error) { const is401 = error instanceof APIStatusError && error.statusCode === 401; if (!is401) throw error; + // A 401 worded as a context-window capability error (" + // supports only context") is not a credential rejection — the + // Kimi managed provider intentionally uses 401 for capability + // errors, and a token refresh cannot change the context size. + // Skip the refresh retry and surface the real, actionable cause. + const reason = error.message.replaceAll('\r', ''); + if (isKimiContextCapabilityError(error.statusCode, reason)) { + throw new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + reason.length > 0 ? reason : 'Request exceeds the model context window.', + { + cause: error, + details: { statusCode: error.statusCode, requestId: error.requestId }, + }, + ); + } if (refreshed) { throw new KimiError( ErrorCodes.AUTH_LOGIN_REQUIRED, diff --git a/packages/node-sdk/test/kimi-code-model-provider.test.ts b/packages/node-sdk/test/kimi-code-model-provider.test.ts index f303efc446..e5d03bb6f3 100644 --- a/packages/node-sdk/test/kimi-code-model-provider.test.ts +++ b/packages/node-sdk/test/kimi-code-model-provider.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { APIStatusError } from '@moonshot-ai/kosong'; import { KimiOAuthToolkit, OAuthConnectionError, @@ -73,4 +74,64 @@ describe('KimiForCodingProvider OAuth error mapping', () => { const auth = resolveAuth(); await expect(auth(async () => 'ok')).rejects.toBe(oauthError); }); + + it('maps a context-worded 401 from the request to context.overflow without a token refresh', async () => { + // The managed provider intentionally returns 401 for capability errors + // (" supports only context"); refreshing the token cannot + // shrink the request, so the wrapper must not burn a refresh retry and + // must not mislabel it as an auth failure. + const ensureFresh = vi + .spyOn(KimiOAuthToolkit.prototype, 'ensureFresh') + .mockResolvedValue('test-api-key'); + const server401 = new APIStatusError(401, '401 k3-256k supports only 256K context.', 'req-ctx'); + + const auth = resolveAuth(); + const caught = await auth(async () => { + throw server401; + }).catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(KimiError); + expect(caught).toMatchObject({ + code: ErrorCodes.CONTEXT_OVERFLOW, + message: expect.stringContaining('supports only 256K context'), + cause: server401, + }); + expect(ensureFresh).toHaveBeenCalledTimes(1); + expect(ensureFresh).toHaveBeenCalledWith(expect.anything(), expect.not.objectContaining({ force: true })); + }); + + it('still force-refreshes once on a plain 401 and then surfaces auth.login_required', async () => { + const ensureFresh = vi + .spyOn(KimiOAuthToolkit.prototype, 'ensureFresh') + .mockResolvedValue('test-api-key'); + + const auth = resolveAuth(); + await expect( + auth(async () => { + throw new APIStatusError(401, 'Unauthorized'); + }), + ).rejects.toMatchObject({ code: ErrorCodes.AUTH_LOGIN_REQUIRED }); + // Initial token fetch plus one forced refresh — the wording gate must not + // swallow genuine credential rejections. + expect(ensureFresh).toHaveBeenCalledTimes(2); + }); + + it('force-refreshes a 401 that mentions supports-only/context without a size', async () => { + // Auth-flavored prose with both words but no quantity is a real + // credential/scope failure: the wording gate must not intercept it. + const ensureFresh = vi + .spyOn(KimiOAuthToolkit.prototype, 'ensureFresh') + .mockResolvedValue('test-api-key'); + + const auth = resolveAuth(); + await expect( + auth(async () => { + throw new APIStatusError( + 401, + 'This endpoint supports only Bearer token authentication. See the docs for context on migrating.', + ); + }), + ).rejects.toMatchObject({ code: ErrorCodes.AUTH_LOGIN_REQUIRED }); + expect(ensureFresh).toHaveBeenCalledTimes(2); + }); });