Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/context-overflow-401-classification.md
Original file line number Diff line number Diff line change
@@ -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
("<model> supports only <N> 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.
53 changes: 51 additions & 2 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand Down Expand Up @@ -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<string> = new Set<string>([
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;
}

/**
Expand Down
106 changes: 99 additions & 7 deletions packages/acp-adapter/test/error-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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, {
Expand Down Expand Up @@ -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, {
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core-v2/src/kosong/contract/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<model> supports only <N> 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,
Expand Down Expand Up @@ -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 };
Expand Down
34 changes: 33 additions & 1 deletion packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -189,13 +195,21 @@ export class ModelRequesterImpl implements ModelRequester {
try {
return await run(auth);
} catch (error) {
// A Kimi-managed 401 worded as a context-window capability error
// ("<model> supports only <N> 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;
}

const refreshedAuth = await this.authProvider.getAuth({ force: true });
try {
return await run(refreshedAuth);
} catch (error) {
const capability = asContextCapabilityOverflow(error);
if (capability !== undefined) throw capability;
if (isUnauthorizedStatusError(error)) throw translateProviderError(error);
throw error;
}
Expand All @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-core-v2/test/kosong/contract/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<model> supports only <N> 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');
Expand Down
Loading
Loading