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
4 changes: 3 additions & 1 deletion packages/config/src/minimax-model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ const MINIMAX_M3_FILE_API_CAPABILITIES: ModelCapabilitiesConfig = {
files_api_upload_endpoint: "/v1/files/upload",
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
// Serialized request JSON bytes; the strictest delivery channel (Bedrock)
// rejects bodies around 16-18 MB. The API-key path raises this for itself.
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
};

Expand Down
2 changes: 1 addition & 1 deletion packages/config/test/builtin-model-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("built-in model fallback", () => {
files_api_upload_endpoint: "/v1/files/upload",
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,24 @@ export function normalizeLocalFileApiCapabilities(
};
}

/**
* Request-body budget (serialized JSON bytes) when a model declares none. The
* strictest MiniMax delivery channel (Bedrock) rejects bodies around 16-18 MB,
* so 64 MiB was never reached before the provider answered 413.
*/
export const DEFAULT_MAX_REQUEST_BODY_BYTES = 16_777_216;

/**
* Request-body budget for the MiniMax open-platform API-key path, which talks
* to MiniMax directly and accepts bodies up to about 28 MB.
*/
export const MINIMAX_API_MAX_REQUEST_BODY_BYTES = 29_360_128;

/** Client protection budgets, not claims about a Provider's actual transport limits. */
const DEFAULT_LOCAL_MULTIMODAL_LIMITS = {
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
max_attachments_count: 4,
} as const;

Expand Down Expand Up @@ -69,3 +82,18 @@ function normalizePositiveByteLimit(value: unknown): number | string | undefined
const parsed = Number(candidate);
return Number.isSafeInteger(parsed) && parsed > 0 ? candidate : undefined;
}

/**
* Effective client request-body budget for one resolved model. The MiniMax
* catalog is shared by every delivery channel and carries the strictest
* channel's budget; the API-key path is a known direct channel and uses its own
* larger budget. Custom providers and the managed path keep the declared
* capability (or the conservative default).
*/
export function resolveMaxRequestBodyBytes(input: {
readonly providerSource: 'minimax_api' | 'custom_provider' | 'provider' | undefined;
readonly capabilities: Pick<CapabilityConfig, keyof MultimodalLimits> | undefined;
}): number {
if (input.providerSource === 'minimax_api') return MINIMAX_API_MAX_REQUEST_BODY_BYTES;
return Number(normalizeLocalMultimodalLimitCapabilities(input.capabilities).max_request_body_bytes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -457,11 +457,102 @@ describe('LocalModelResolver request-body byte authority', () => {
);

expect(resolved.map((model) => model.maxRequestBodyBytes)).toEqual([
128, 256, 67_108_864, 67_108_864, 67_108_864, 67_108_864, 67_108_864, 67_108_864, 67_108_864,
128, 256, 16_777_216, 16_777_216, 16_777_216, 16_777_216, 16_777_216, 16_777_216, 16_777_216,
]);
});
});

describe('LocalModelResolver request-body budget per delivery channel', () => {
const catalogModel = {
reasoning: true,
limit: { context: 512_000, output: 128_000 },
capabilities: { max_request_body_bytes: 16_777_216 },
};

it('uses the strict catalog budget for the managed MiniMax path', async () => {
const resolver = new LocalModelResolver({
providerConfig: {
minimax: {
options: { apiKey: 'managed-key', baseURL: 'https://managed.example/messages-api' },
models: { 'MiniMax-M3': catalogModel },
},
},
});
const resolved = await resolver.resolveModel({
sessionId: 'session-managed-budget',
turnId: 'turn-managed-budget',
agentConfig: {
...AGENT_CONFIG,
model: {
provider: 'minimax',
model_id: 'MiniMax-M3',
capabilities: { max_request_body_bytes: 16_777_216 },
},
},
});
expect(resolved.model.provider).toBe('minimax');
expect(resolved.maxRequestBodyBytes).toBe(16_777_216);
});

it.each([
['minimax_api', undefined],
['minimax', 'minimax_api_key'],
])('uses the direct-channel budget for the MiniMax API key path (%s)', async (provider, source) => {
const resolver = new LocalModelResolver({
providerConfig: { minimax: { models: { 'MiniMax-M3': catalogModel } } },
byokConfigGetter: () => ({
...(source ? { minimaxModelSource: source } : {}),
minimax_api: { apiKey: 'minimax-user-key', baseURL: 'https://byok.example/messages-api' },
}),
});
const resolved = await resolver.resolveModel({
sessionId: `session-api-budget-${provider}`,
turnId: `turn-api-budget-${provider}`,
agentConfig: {
...AGENT_CONFIG,
model: {
provider,
model_id: 'MiniMax-M3',
capabilities: { max_request_body_bytes: 16_777_216 },
},
},
});
expect(resolved.model.provider).toBe('minimax_api');
expect(resolved.maxRequestBodyBytes).toBe(28 * 1024 * 1024);
});

it('keeps a custom provider configurable, defaulting to 16 MiB', async () => {
const resolver = new LocalModelResolver({
byokConfigGetter: () => ({
custom_provider: {
work: {
api: 'openai-completions',
options: { apiKey: 'custom-user-key', baseURL: 'https://custom.example/v1/' },
models: { model: { limit: { context: 96_000, output: 12_000 } } },
},
},
}),
});
const resolve = (capabilities?: Record<string, unknown>) =>
resolver.resolveModel({
sessionId: 'session-custom-budget',
turnId: 'turn-custom-budget',
agentConfig: {
...AGENT_CONFIG,
model: {
provider: 'custom_provider:work',
model_id: 'model',
...(capabilities ? { capabilities } : {}),
},
},
});
expect((await resolve()).maxRequestBodyBytes).toBe(16_777_216);
expect(
(await resolve({ max_request_body_bytes: 100 * 1024 * 1024 })).maxRequestBodyBytes,
).toBe(100 * 1024 * 1024);
});
});

describe('LocalModelResolver BYOK routing and fallback', () => {
it('honors the MiniMax BYOK source switch and the injected stream implementation', async () => {
const streamFn = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
import { hasOpenPlatformThinkingVariants } from './openplatform-thinking.js';
import { withByokErrorAttribution } from './byok-error-attribution.js';
import { withLocalDynamicMaxTokens } from './dynamic-max-tokens.js';
import { normalizeLocalMultimodalLimitCapabilities } from './file-api-capabilities.js';
import { resolveMaxRequestBodyBytes } from './file-api-capabilities.js';
import { resolveLocalFileApiGatewayAuth } from './file-api-gateway-auth.js';

const FALLBACK_MODEL_LIMITS = {
Expand Down Expand Up @@ -204,9 +204,10 @@ export class LocalModelResolver implements LocalModelResolverLike {
const maxTokens = positive(input.modelRef.max_tokens) || input.maxTokens;
const thinking = resolveThinking(input, this.options.implicitCustomProviderThinking === true);
const baseUrl = normalizeResolvedBaseUrl(input.api, input.baseUrl);
const maxRequestBodyBytes = Number(
normalizeLocalMultimodalLimitCapabilities(input.modelRef.capabilities).max_request_body_bytes,
);
const maxRequestBodyBytes = resolveMaxRequestBodyBytes({
providerSource: parseProviderId(input.runtimeProvider ?? input.provider)?.source,
capabilities: input.modelRef.capabilities,
});
const model = buildResolvedModel({ input, contextWindow, maxTokens, baseUrl, thinking });
const streamFn = resolveModelStream(input, this.options.streamFn);
const fileApiGatewayAuth = input.managedProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ describe('model capability helpers', () => {
support_files_api: false,
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
thinking_mode: ThinkingMode.FORCED_ON,
});
Expand Down Expand Up @@ -701,7 +701,7 @@ describe('model capability helpers', () => {
support_files_api: false,
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
});
expect(
Expand All @@ -711,7 +711,7 @@ describe('model capability helpers', () => {
).toMatchObject({
support_video: true,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
});
});

Expand All @@ -730,7 +730,7 @@ describe('model capability helpers', () => {
).toMatchObject({
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
});
},
Expand Down Expand Up @@ -829,7 +829,7 @@ describe('model capability helpers', () => {
expect(capabilities).toMatchObject({
max_image_bytes_inline: 10_485_760,
max_video_bytes_inline: 52_428_800,
max_request_body_bytes: 67_108_864,
max_request_body_bytes: 16_777_216,
max_attachments_count: 4,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from './contracts.js';
import {
ContextCompactionResultValidationError,
automaticCompactionByteTrigger,
captureContextCompactionResult,
createCompactionTokenUsageAccumulator,
createCompactionReplaceMetadata,
Expand Down Expand Up @@ -168,6 +169,15 @@ function hookInput(overrides: Partial<PiBeforeLlmCallHookInput> = {}): PiBeforeL
};
}

describe('automatic compaction byte trigger', () => {
it('starts automatic compaction at 90% of the client request-body limit', () => {
expect(automaticCompactionByteTrigger(16_777_216)).toBe(15_099_494);
expect(automaticCompactionByteTrigger(29_360_128)).toBe(26_424_115);
expect(automaticCompactionByteTrigger(1)).toBe(1);
expect(automaticCompactionByteTrigger(undefined)).toBeUndefined();
});
});

describe('automatic ContextCompactor trigger probe', () => {
it('returns unchanged history without taking a semantic snapshot', async () => {
const probeBeforeLlm = vi.fn(() => ({ shouldStart: false }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ export class ContextCompactionAttemptFactory {
}
}

/**
* Share of the client request-body limit at which automatic compaction starts.
* The trigger measures an estimate of the provider request while the hard
* check runs on the final serialized body, so compaction starts with headroom
* instead of only after the request would already be rejected.
*/
export const AUTOMATIC_COMPACTION_BODY_TRIGGER_RATIO = 0.9;

export function automaticCompactionByteTrigger(
maxRequestBodyBytes: number | undefined,
): number | undefined {
if (maxRequestBodyBytes === undefined || !Number.isFinite(maxRequestBodyBytes)) {
return maxRequestBodyBytes;
}
return Math.max(1, Math.floor(maxRequestBodyBytes * AUTOMATIC_COMPACTION_BODY_TRIGGER_RATIO));
}

export function createAutomaticContextCompactionHook(
dependencies: AgentHostCompactionHookDependencies,
context: AgentEventContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './compaction/agent-host-compaction-executor.js';
import {
ContextCompactionAttemptFactory,
automaticCompactionByteTrigger,
createAutomaticContextCompactionHook,
} from './compaction/context-compaction.js';
import {
Expand Down Expand Up @@ -595,7 +596,9 @@ export class LocalAgentHost<
context,
input.lease.leaseId,
{
maxSerializedInputBytes: preflight.preparation.llm.maxRequestBodyBytes,
maxSerializedInputBytes: automaticCompactionByteTrigger(
preflight.preparation.llm.maxRequestBodyBytes,
),
attempts: this.compactionAttempts,
logger: this.dependencies.logger,
...(pluginHooks.length
Expand Down
Loading