diff --git a/packages/config/src/minimax-model-catalog.ts b/packages/config/src/minimax-model-catalog.ts index 4eeb62d07..6f88e5a05 100644 --- a/packages/config/src/minimax-model-catalog.ts +++ b/packages/config/src/minimax-model-catalog.ts @@ -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, }; diff --git a/packages/config/test/builtin-model-fallback.test.ts b/packages/config/test/builtin-model-fallback.test.ts index 42095a582..900c3ae54 100644 --- a/packages/config/test/builtin-model-fallback.test.ts +++ b/packages/config/test/builtin-model-fallback.test.ts @@ -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, }, }); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/file-api-capabilities.ts b/packages/local-runtime-v2/src/service/model-system/resolution/file-api-capabilities.ts index 1885bd72b..19814c856 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/file-api-capabilities.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/file-api-capabilities.ts @@ -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; @@ -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 | undefined; +}): number { + if (input.providerSource === 'minimax_api') return MINIMAX_API_MAX_REQUEST_BODY_BYTES; + return Number(normalizeLocalMultimodalLimitCapabilities(input.capabilities).max_request_body_bytes); +} diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 50e6c42db..ae0415268 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -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) => + 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(); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts index 3d5bce42d..8f29ee51f 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts @@ -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 = { @@ -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 diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts index ab5c389de..b9f073bee 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.test.ts @@ -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, }); @@ -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( @@ -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, }); }); @@ -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, }); }, @@ -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, }); }); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts index 42b58c610..0baeba901 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts @@ -10,6 +10,7 @@ import type { } from './contracts.js'; import { ContextCompactionResultValidationError, + automaticCompactionByteTrigger, captureContextCompactionResult, createCompactionTokenUsageAccumulator, createCompactionReplaceMetadata, @@ -168,6 +169,15 @@ function hookInput(overrides: Partial = {}): 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 })); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts index 6ab3e3db7..feb1ecaa7 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts @@ -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, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts index c6dd3d615..3b6385d90 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts @@ -23,6 +23,7 @@ import { } from './compaction/agent-host-compaction-executor.js'; import { ContextCompactionAttemptFactory, + automaticCompactionByteTrigger, createAutomaticContextCompactionHook, } from './compaction/context-compaction.js'; import { @@ -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