From 6dafaea0763750d2c3a52b108a90e14b7286f0e9 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:33:50 +0800 Subject: [PATCH] fix: keep --effort on the MiniMax API-key request pi-ai sets output_config.effort from the selected thinking effort, but patchThinkingPayload replaced the openplatform thinking variant and then deleted output_config for every non-custom provider, so --effort never reached the MiniMax open platform on the API-key path. For minimax_api requests, keep the explicitly selected effort in output_config the same way patchManagedThinkingPayload does on the managed path. 'default', plain on/off models and other providers are unchanged. --- .../local-turn-payload-transform.test.ts | 142 ++++++++++++++++++ .../assembly/local-turn-payload-transform.ts | 43 +++++- release/public-source.json | 1 + test/vitest-suites.json | 1 + 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts new file mode 100644 index 000000000..8855165d4 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts @@ -0,0 +1,142 @@ +import { streamSimple } from '@earendil-works/pi-ai'; +import { describe, expect, it } from 'vitest'; + +import { LocalModelResolver } from '../../../model-system/resolution/local-model-resolver.js'; +import { modelRefForModel } from '../../../model-system/resolution/model-ref.js'; +import type { LocalModelConfig } from '../../../model-system/contracts.js'; +import { buildLocalRequestPayloadTransform } from './local-turn-payload-transform.js'; + +// Mirrors the bundled MiniMax catalog entries that matter for thinking. +const M31_FLASH = { + name: 'M3.1-Flash-Preview', + reasoning: true, + tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 512_000, output: 128_000 }, + thinking: { + effortOptions: ['default', 'low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'default', + }, + thinking_config: { mode: 'forced_on' }, + variants: { + 'none-thinking': { thinking: { type: 'disabled' } }, + thinking: { thinking: { type: 'adaptive' } }, + }, +} as unknown as LocalModelConfig; + +const M3 = { + name: 'M3', + reasoning: true, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 1_000_000, output: 128_000 }, + thinking_config: { mode: 'forced_on' }, + variants: { + 'none-thinking': { thinking: { type: 'disabled' } }, + thinking: { thinking: { type: 'adaptive' } }, + }, +} as unknown as LocalModelConfig; + +/** Resolve an API-key MiniMax model and capture the request body pi-ai actually sends. */ +async function captureWireBody(input: { + readonly provider: string; + readonly modelId: string; + readonly modelConfig: LocalModelConfig; + readonly effort?: string; + readonly minimaxModelSource?: string; +}) { + const modelRef = modelRefForModel(input.provider, input.modelId, input.modelConfig, { + ...(input.effort ? { thinking: { effort: input.effort } } : {}), + } as never); + const agentConfig = { + system_prompt: 'system', + agent_id: 'agent', + model: modelRef, + tools: [], + skills: [], + } as never; + const resolver = new LocalModelResolver({ + providerConfig: { minimax: { models: { [input.modelId]: input.modelConfig } } } as never, + byokConfigGetter: () => + ({ + ...(input.minimaxModelSource ? { minimaxModelSource: input.minimaxModelSource } : {}), + minimax_api: { apiKey: 'k', baseURL: 'https://byok.invalid/anthropic' }, + }) as never, + }); + const llm = await resolver.resolveModel({ sessionId: 's', turnId: 't', agentConfig }); + expect(llm.model.provider).toBe('minimax_api'); + expect(llm.managedProvider).not.toBe(true); + let body: Record | undefined; + const fetchImpl = (async (_url: unknown, init: { body?: unknown }) => { + body = JSON.parse(String(init.body)) as Record; + return new Response('stop here', { status: 500 }); + }) as unknown as typeof fetch; + const onPayload = buildLocalRequestPayloadTransform( + { agentConfig, llm, sessionId: 's', signal: new AbortController().signal }, + {}, + ); + const stream = streamSimple( + llm.model as never, + { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] }, + { + apiKey: 'k', + reasoning: llm.thinkingLevel ?? undefined, + fetch: fetchImpl, + onPayload, + maxRetries: 0, + } as never, + ); + for await (const _event of stream) { + // Drain until the stubbed provider error ends the stream. + } + expect(body).toBeDefined(); + return body!; +} + +describe('API-key MiniMax thinking effort', () => { + it.each(['high', 'low', 'xhigh', 'max'])( + 'keeps --effort %s on the minimax_api wire request', + async (effort) => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + effort, + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body.output_config).toEqual({ effort }); + }, + ); + + it('keeps --effort when a MiniMax model is routed through the API key source', async () => { + const body = await captureWireBody({ + provider: 'minimax', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + effort: 'high', + minimaxModelSource: 'minimax_api_key', + }); + expect(body.output_config).toEqual({ effort: 'high' }); + }); + + it('sends no effort when none was selected (default)', async () => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body).not.toHaveProperty('output_config'); + }); + + it('sends no effort for a model without effort levels', async () => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3', + modelConfig: M3, + effort: 'high', + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body).not.toHaveProperty('output_config'); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts index 941294718..842d76b30 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts @@ -67,6 +67,10 @@ export function buildLocalTurnPayloadTransform( options.managedProvider, thinkingOn, ); + const selectedEffort = + !options.managedProvider && thinkingOn + ? readSelectedThinkingEffort(modelConfig ?? {}, capabilities ?? {}) + : undefined; return (payload: unknown, model: Model): Promise => transformLocalTurnPayload({ @@ -76,6 +80,7 @@ export function buildLocalTurnPayloadTransform( fileApiPatcher, thinking, managedThinking, + ...(selectedEffort ? { selectedEffort } : {}), thinkingRequestPatch: options.thinkingRequestPatch, outputContract: options.outputContract, jsonObjectOutputEnabled: options.supportsJsonObjectOutput === true, @@ -90,7 +95,7 @@ function readManagedThinking( enabled: boolean, ) { if (!managed) return undefined; - const effort = model.thinking_effort ?? capabilities.selected_thinking_effort; + const effort = readRawThinkingEffort(model, capabilities); if ( model.thinking_level === undefined && capabilities.thinking_mode === undefined && @@ -106,6 +111,28 @@ function readManagedThinking( }; } +function readRawThinkingEffort( + model: Record, + capabilities: Record, +): unknown { + return model.thinking_effort ?? capabilities.selected_thinking_effort; +} + +/** + * The explicitly selected effort level for an API-key request. Plain on/off + * models have no level, and `default` means "let the provider decide", so + * neither is forwarded. + */ +function readSelectedThinkingEffort( + model: Record, + capabilities: Record, +): string | undefined { + const effort = readRawThinkingEffort(model, capabilities); + return typeof effort === 'string' && !['', 'on', 'off', 'default'].includes(effort) + ? effort + : undefined; +} + export function buildLocalRequestPayloadTransform( input: { readonly agentConfig: Readonly>; @@ -187,6 +214,8 @@ async function transformLocalTurnPayload(input: { readonly mode: unknown; readonly effort?: string; }; + /** User-selected effort for an unmanaged (API-key) request; see patchThinkingPayload. */ + readonly selectedEffort?: string; readonly thinkingRequestPatch: Readonly> | undefined; readonly outputContract: TurnOutputContract | undefined; readonly jsonObjectOutputEnabled: boolean; @@ -203,7 +232,7 @@ async function transformLocalTurnPayload(input: { ); const thinkingChanged = input.managedThinking ? patchManagedThinkingPayload(input.payload, input.thinking, input.managedThinking) - : patchThinkingPayload(input.payload, input.thinking, input.model); + : patchThinkingPayload(input.payload, input.thinking, input.model, input.selectedEffort); changed = videoChanged || fileApiChanged || thinkingChanged; } const requestPatchChanged = patchRequestPayload(input.payload, input.thinkingRequestPatch); @@ -389,6 +418,7 @@ function patchThinkingPayload( payload: Record, thinking: Record | undefined, model: Model, + selectedEffort: string | undefined, ): boolean { if (!thinking || !Array.isArray(payload.messages)) return false; if (model.api === 'anthropic-messages' && isDefaultThinkingModelId(model.id)) { @@ -397,7 +427,14 @@ function patchThinkingPayload( return hadThinking; } payload.thinking = { ...thinking }; - if (!shouldPreserveCustomProviderTopEffort(payload, model)) delete payload.output_config; + if (shouldPreserveCustomProviderTopEffort(payload, model)) return true; + if (selectedEffort && parseProviderId(model.provider)?.source === 'minimax_api') { + // The MiniMax API-key path speaks the same Messages dialect as the managed + // gateway; keep the selected `--effort` exactly like patchManagedThinkingPayload. + payload.output_config = { ...readRecord(payload.output_config), effort: selectedEffort }; + return true; + } + delete payload.output_config; return true; } diff --git a/release/public-source.json b/release/public-source.json index 7539f8cd2..e82c00b04 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1320,6 +1320,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/host-capability/skill-disclosure.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index a30ece859..1038eaa0f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -114,6 +114,7 @@ "packages/agent-core/test/unit/pi-turn-runner/llm-stream-timeout.test.ts", "packages/agent-core/test/unit/pi-turn-runner/request-image-limit.test.ts", "packages/tui/test/unit/runtime-error-presentation-rejection.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts", "packages/local-runtime-v2/src/services.test.ts", "packages/local-runtime-v2/src/application/session/session-title-policy.test.ts",