Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined;
const fetchImpl = (async (_url: unknown, init: { body?: unknown }) => {
body = JSON.parse(String(init.body)) as Record<string, unknown>;
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function buildLocalTurnPayloadTransform(
options.managedProvider,
thinkingOn,
);
const selectedEffort =
!options.managedProvider && thinkingOn
? readSelectedThinkingEffort(modelConfig ?? {}, capabilities ?? {})
: undefined;

return (payload: unknown, model: Model<Api>): Promise<unknown | undefined> =>
transformLocalTurnPayload({
Expand All @@ -76,6 +80,7 @@ export function buildLocalTurnPayloadTransform(
fileApiPatcher,
thinking,
managedThinking,
...(selectedEffort ? { selectedEffort } : {}),
thinkingRequestPatch: options.thinkingRequestPatch,
outputContract: options.outputContract,
jsonObjectOutputEnabled: options.supportsJsonObjectOutput === true,
Expand All @@ -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 &&
Expand All @@ -106,6 +111,28 @@ function readManagedThinking(
};
}

function readRawThinkingEffort(
model: Record<string, unknown>,
capabilities: Record<string, unknown>,
): 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<string, unknown>,
capabilities: Record<string, unknown>,
): 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<Record<string, unknown>>;
Expand Down Expand Up @@ -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<Record<string, unknown>> | undefined;
readonly outputContract: TurnOutputContract | undefined;
readonly jsonObjectOutputEnabled: boolean;
Expand All @@ -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);
Expand Down Expand Up @@ -389,6 +418,7 @@ function patchThinkingPayload(
payload: Record<string, unknown>,
thinking: Record<string, unknown> | undefined,
model: Model<Api>,
selectedEffort: string | undefined,
): boolean {
if (!thinking || !Array.isArray(payload.messages)) return false;
if (model.api === 'anthropic-messages' && isDefaultThinkingModelId(model.id)) {
Expand All @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading