Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/anthropic-max-tokens-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix 400 rejections from Anthropic-compatible providers that enforce a lower max_tokens output limit than the default 128000 fallback. Custom endpoints now use a conservative 32768 default, and a rejected request is automatically retried with the provider's declared limit.
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
* params, then drives a bounded request chain through the `ModelRequester`
* resolved from `IModelCatalog`: one primary `requester.request(input, signal,
* params)` attempt plus projection rebuilds for request structure or media
* compatibility; general retry policy remains in the loop's `stepRetry`
* plugin. Before each request the projected messages pass through `media`'s
* compatibility and a one-shot completion-budget clamp when the provider
* rejects `max_tokens` (the server-declared ceiling is remembered per model,
* so each model pays for at most one rejected-and-resent request); general
* retry policy remains in the loop's `stepRetry` plugin. Before each request the projected messages pass through `media`'s
* video resolver, which rewrites every `kimi-file://` prompt-video reference
* to a provider-acceptable part (uploaded `ms://`, inline base64, or a
* `<video path>` tag) so the internal reference never reaches the wire. When a
Expand Down Expand Up @@ -53,6 +55,7 @@ import {
isImageFormatError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
parseMaxTokensLimit,
} from '#/kosong/contract/errors';
import { type Message } from '#/kosong/contract/message';
import { type ThinkingEffort } from '#/kosong/contract/provider';
Expand Down Expand Up @@ -147,6 +150,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
private readonly mediaDegradedTurns = new Set<number>();
private readonly mediaStrippedTurns = new Map<number, MediaStripSnapshot>();
private readonly emittedThinkingEffortWarnings = new Set<string>();
/** Server-declared `max_tokens` ceilings learned from a rejected request, keyed by wire model name. */
private readonly maxTokensClamps = new Map<string, number>();

constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
Expand Down Expand Up @@ -284,6 +289,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
): Promise<AgentLLMRequestFinish> {
const shaped = this.toolSelect.shapeHistory(request.messages);
let mediaStripSnapshot = this.mediaStripSnapshotForTurn(request.source);
let params = this.applyMaxTokensClamp(request.model.name, request.params);
const requestInput = (projection: RequestProjection) => {
return {
systemPrompt: request.systemPrompt,
Expand Down Expand Up @@ -325,7 +331,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
modelName: request.model.name,
modelAlias: request.modelAlias,
thinkingEffort: request.thinkingEffort,
maxTokens: effectiveMaxCompletionTokens(request.params),
maxTokens: effectiveMaxCompletionTokens(params),
systemPrompt: input.systemPrompt,
tools: input.tools,
messages: input.messages,
Expand All @@ -350,7 +356,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
};

for await (const event of request.requester.request(input, signal, {
...request.params,
...params,
onTraceId: setTraceId,
})) {
switch (event.type) {
Expand Down Expand Up @@ -457,6 +463,26 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
projection = 'strict';
continue;
}
if (raw instanceof APIStatusError && raw.statusCode === 400) {
const maxTokensLimit = parseMaxTokensLimit(raw.message);
if (
maxTokensLimit !== null &&
maxTokensLimit < (params.maxCompletionTokens ?? Number.POSITIVE_INFINITY)
) {
signal?.throwIfAborted();
this.log.warn(
'provider rejected the completion-token budget; resending with clamped max tokens',
{
model: request.model.name,
maxTokens: maxTokensLimit,
...request.logFields,
},
);
this.maxTokensClamps.set(request.model.name, maxTokensLimit);
params = { ...params, maxCompletionTokens: maxTokensLimit };
continue;
}
}
throw error;
}
}
Expand Down Expand Up @@ -526,6 +552,15 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
set.add(source.turnId);
}

private applyMaxTokensClamp(modelName: string, params: ModelRequestParams): ModelRequestParams {
const clamp = this.maxTokensClamps.get(modelName);
if (clamp === undefined) return params;
return {
...params,
maxCompletionTokens: Math.min(clamp, params.maxCompletionTokens ?? clamp),
};
}

private resolveRequest(overrides: AgentLLMRequestOverrides): ResolvedLLMRequest {
const turnConfig = this.resolveTurnConfig(overrides.source);
const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext();
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-core-v2/src/kosong/contract/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,34 @@ const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [
/request.*exceed(?:ed|s|ing)?.*model token limit/,
] as const;

const MAX_TOKENS_LIMIT_MESSAGE_PATTERNS: readonly RegExp[] = [
/max_tokens[\s\S]{0,200}expected a value\s*<=\s*(\d+)/i,
/max_tokens[\s\S]{0,200}(?:must be at most|cannot exceed|maximum value[^\d]*)\s*(\d+)/i,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the existing less-than-or-equal max_tokens wording

When an Anthropic-compatible provider returns the already-recognized wording max_tokens must be less than or equal to 4096 (there are existing normalizeAPIStatusError fixtures for this shape in both packages), none of these new patterns match, so parseMaxTokensLimit() returns null and the v2 retry path never clamps or resends the request. Please include the less than or equal to / bare <= form here, and mirror the fix in the duplicate legacy parser, so this recovery covers that common max_tokens-limit response.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ab120cdparseMaxTokensLimit now recognizes the less than or equal to wording and the bare <= form, mirrored in both packages/agent-core-v2/src/kosong/contract/errors.ts and the legacy packages/kosong/src/errors.ts, with test cases added in both packages.

/max_tokens[\s\S]{0,200}(?:less than or equal to|<=)\s*(\d+)/i,
/max_tokens[\s\S]{0,200}max output tokens is\s*(\d+)/i,
];

/**
* Parse the provider's actual max_tokens ceiling from a 400 error message.
* Returns the parsed limit, or null if no max_tokens limit pattern matched.
* Patterns are matched against the full error message text (which includes
* the provider's JSON body) and each anchors on `max_tokens` to avoid false
* positives from context-overflow messages. Recognized wordings: volcano/ark
* "expected a value <= N"; "must be at most N" / "cannot exceed N" /
* "maximum value … N"; "must be less than or equal to N" and the bare "<= N"
* form; Anthropic-native "max output tokens is N".
*/
export function parseMaxTokensLimit(message: string): number | null {
for (const pattern of MAX_TOKENS_LIMIT_MESSAGE_PATTERNS) {
const match = pattern.exec(message);
if (match?.[1]) {
const limit = parseInt(match[1], 10);
if (Number.isFinite(limit) && limit > 0) return limit;
}
}
return null;
}

const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [
/(?:apistatuserror.*429|429.*apistatuserror)/,
/429.*too many requests/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ const CEILING_BY_FAMILY_VERSION: Readonly<Record<string, number>> = {
};

const FALLBACK_MAX_TOKENS = 128000;
/** Conservative fallback for custom endpoints whose actual output ceiling is unknown. */
const CUSTOM_ENDPOINT_FALLBACK_MAX_TOKENS = 32768;

function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined {
const { family, major, minor } = version;
Expand All @@ -219,11 +221,18 @@ function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined
return CEILING_BY_FAMILY_VERSION[`${family}-${major}`];
}

export function resolveDefaultMaxTokens(model: string, override?: number): number {
export function resolveDefaultMaxTokens(model: string, override?: number, baseUrl?: string): number {
const parsed = parseAnthropicModelVersion(model, true);
const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed);
if (ceiling === undefined) {
return override ?? FALLBACK_MAX_TOKENS;
if (override !== undefined) return override;
// Custom endpoint + unrecognized model: the 128k Claude-4 fallback is too
// aggressive for most Anthropic-compatible providers (32768 is the most
// common ceiling). Use the conservative value; the user can still override
// via defaultMaxTokens (max_output_size in config).
const isCustomEndpoint =
baseUrl !== undefined && baseUrl !== '' && !baseUrl.includes('api.anthropic.com');
return isCustomEndpoint ? CUSTOM_ENDPOINT_FALLBACK_MAX_TOKENS : FALLBACK_MAX_TOKENS;
}
return override === undefined ? ceiling : Math.min(override, ceiling);
}
Expand Down Expand Up @@ -821,7 +830,9 @@ export class AnthropicChatProvider implements ChatProvider {
this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey);
this._explicitMaxTokens = options.defaultMaxTokens !== undefined;
this._generationKwargs = {
max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model),
max_tokens:
options.defaultMaxTokens ??
resolveDefaultMaxTokens(options.model, undefined, options.baseUrl),
betaFeatures: options.betaFeatures ?? [INTERLEAVED_THINKING_BETA],
};
}
Expand Down Expand Up @@ -927,7 +938,7 @@ export class AnthropicChatProvider implements ChatProvider {
cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens);
}
cap = Math.max(1, cap);
const requestedCap = resolveDefaultMaxTokens(this._model, cap);
const requestedCap = resolveDefaultMaxTokens(this._model, cap, this._baseUrl);
const existingCap = kwargs.max_tokens;
kwargs = {
...kwargs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { IModelCatalog, type Model } from '#/kosong/model/catalog';
import {
type ModelRequestEvent,
type ModelRequestInput,
type ModelRequestParams,
type ModelRequester,
} from '#/kosong/model/modelRequester';
import { ITelemetryService } from '#/app/telemetry/telemetry';
Expand Down Expand Up @@ -616,6 +617,97 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
});
});

describe('AgentLLMRequesterService max-tokens clamp resend', () => {
// The harness budget is the stub model's 1000-token context window, so the
// server-declared ceiling must sit below it for the clamp to lower anything.
const MAX_TOKENS_400 = new APIStatusError(400, 'max_tokens: 128000, expected a value <= 512');

function createMaxTokensRequester(
calls: { value: number },
capturedParams: Array<ModelRequestParams | undefined>,
errors: readonly (Error | undefined)[],
): ModelRequester {
const model: Model = {
id: 'm',
name: 'wire-model',
aliases: [],
protocol: 'anthropic',
baseUrl: 'https://example.test',
headers: {},
capabilities,
maxContextSize: 1000,
alwaysThinking: false,
providerName: 'p',
authProvider: { getAuth: async () => undefined },
};
return {
model,
request: async function* (_input, _signal, params) {
const index = calls.value;
calls.value += 1;
capturedParams.push(params);
const error = errors[index];
if (error !== undefined) throw error;
yield {
type: 'finish',
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], toolCalls: [] },
providerFinishReason: 'completed',
rawFinishReason: 'stop',
id: 'resp-1',
};
},
};
}

it('resends once with the server-declared max_tokens ceiling', async () => {
const calls = { value: 0 };
const capturedParams: Array<ModelRequestParams | undefined> = [];
const requester = createMaxTokensRequester(calls, capturedParams, [
MAX_TOKENS_400,
undefined,
]);
const { service } = createService(requester, undefined);

const result = await service.request();

expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(calls.value).toBe(2);
expect(capturedParams[0]?.maxCompletionTokens).toBeGreaterThan(512);
expect(capturedParams[1]?.maxCompletionTokens).toBe(512);
});

it('stops after one clamp resend when the clamped request is rejected again', async () => {
const calls = { value: 0 };
const capturedParams: Array<ModelRequestParams | undefined> = [];
const requester = createMaxTokensRequester(calls, capturedParams, [
MAX_TOKENS_400,
MAX_TOKENS_400,
]);
const { service } = createService(requester, undefined);

await expect(service.request()).rejects.toMatchObject({ statusCode: 400 });
expect(calls.value).toBe(2);
});

it('applies the remembered clamp to later requests for the same model', async () => {
const calls = { value: 0 };
const capturedParams: Array<ModelRequestParams | undefined> = [];
const requester = createMaxTokensRequester(calls, capturedParams, [
MAX_TOKENS_400,
undefined,
undefined,
]);
const { service } = createService(requester, undefined);

await service.request({ source: { type: 'turn', turnId: 1, step: 1 } });
expect(calls.value).toBe(2);

await service.request({ source: { type: 'turn', turnId: 2, step: 1 } });
expect(calls.value).toBe(3);
expect(capturedParams[2]?.maxCompletionTokens).toBe(512);
});
});

describe('AgentLLMRequesterService fault injection (experimental)', () => {
it('raises an armed request-too-large fault before the provider and recovers via the degraded resend', async () => {
const calls = { value: 0 };
Expand Down
28 changes: 28 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 @@ -23,6 +23,7 @@ import {
isAbortError,
isRetryableGenerateError,
normalizeAPIStatusError,
parseMaxTokensLimit,
throwIfAbortError,
} from '#/kosong/contract/errors';

Expand Down Expand Up @@ -183,3 +184,30 @@ describe('classifyApiError', () => {
expect(classifyApiError('boom').kind).toBe('other');
});
});

describe('parseMaxTokensLimit', () => {
it('parses the "expected a value <= N" form', () => {
expect(parseMaxTokensLimit('max_tokens: 128000, expected a value <= 8192')).toBe(8192);
});

it('parses the "must be at most", "cannot exceed", and "maximum value" forms', () => {
expect(parseMaxTokensLimit('max_tokens: value must be at most 32768')).toBe(32768);
expect(parseMaxTokensLimit('max_tokens cannot exceed 16384 for this model')).toBe(16384);
expect(parseMaxTokensLimit('max_tokens: maximum value is 64000')).toBe(64000);
});

it('parses the "max output tokens is N" form case-insensitively', () => {
expect(parseMaxTokensLimit('Max_Tokens: 99999 but max output tokens is 12000')).toBe(12000);
});

it('parses the "less than or equal to" and bare "<=" forms', () => {
expect(parseMaxTokensLimit('max_tokens must be less than or equal to 4096')).toBe(4096);
expect(parseMaxTokensLimit('max_tokens <= 2048')).toBe(2048);
});

it('returns null when no recognizable positive limit is present', () => {
expect(parseMaxTokensLimit('max_tokens must be positive')).toBeNull();
expect(parseMaxTokensLimit('some unrelated validation problem')).toBeNull();
expect(parseMaxTokensLimit('max_tokens: expected a value <= 0')).toBeNull();
});
});
35 changes: 35 additions & 0 deletions packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,28 @@ describe('Anthropic max-tokens profile', () => {
expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(128000);
});

it('defaults unknown models on custom endpoints to a conservative ceiling', () => {
expect(
resolveDefaultMaxTokens('totally-unknown-model', undefined, 'https://proxy.example.test/v1'),
).toBe(32768);
});

it('keeps the 128000 fallback for unknown models on the official endpoint or no endpoint', () => {
expect(
resolveDefaultMaxTokens('totally-unknown-model', undefined, 'https://api.anthropic.com'),
).toBe(128000);
expect(resolveDefaultMaxTokens('totally-unknown-model', undefined, '')).toBe(128000);
});

it('does not let a custom endpoint displace an explicit override or a known ceiling', () => {
expect(resolveDefaultMaxTokens('unknown-model', 12345, 'https://proxy.example.test/v1')).toBe(
12345,
);
expect(
resolveDefaultMaxTokens('claude-opus-4-7', undefined, 'https://proxy.example.test/v1'),
).toBe(128000);
});

it('sends the profile default as max_tokens when no explicit defaultMaxTokens is set', async () => {
const provider = new AnthropicChatProvider({
model: 'claude-opus-4-7',
Expand Down Expand Up @@ -999,6 +1021,19 @@ describe('Anthropic max-tokens profile', () => {

expect(params['max_tokens']).toBe(999999);
});

it('seeds the conservative fallback for unknown models on custom endpoints', async () => {
const provider = new AnthropicChatProvider({
model: 'totally-unknown-model',
apiKey: 'sk-probe',
baseUrl: 'https://proxy.example.test/v1',
stream: false,
});

const { params } = await captureAnthropicBody(provider);

expect(params['max_tokens']).toBe(32768);
});
});

describe('OpenAI reasoning_effort path (issue #1616)', () => {
Expand Down
Loading