From a33cd93ff5c6d9e98e732ad2e9a2df16ff8fb595 Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Sat, 18 Jul 2026 21:41:24 +0800 Subject: [PATCH 1/4] feat(text): model-aware max_tokens defaults and thinking parameter Follow-up to #172 (closed). Addresses NianJiuZst's review points 1 and 2 against the current Messages API contract. Changes: - New src/utils/model-defaults.ts: single source of truth for per-model max_tokens defaults (M3 -> 131072, others -> 65536) and thinking-mode validation. Imports kept symmetric across chat, repl, and the SDK to prevent drift. - src/types/api.ts: add optional ChatRequest.thinking field with type 'enabled' | 'disabled' | 'adaptive'. - src/commands/text/{chat,repl}.ts: * default max_tokens now follows the Messages API recommendation per model instead of a flat 4096; --max-tokens flag still overrides * new --thinking flag; passing it sets ChatRequest.thinking; omitting the flag leaves the field absent so the API's default (thinking disabled for M3) applies unchanged * unknown --thinking values throw CLIError (exit 2 USAGE) before any network call - src/sdk/text/index.ts: replace hard-coded 4096 default with the shared resolver; pass thinking through only when explicitly set, never auto-inject. Help text for --max-tokens updated to '(default: 131072 for M3, 65536 for other models)' in both commands. Repl keeps model-aware default as well so the value displayed in /history matches the actual request ceiling. Co-authored-by: minimax --- src/commands/text/chat.ts | 19 +++++++++++-- src/commands/text/repl.ts | 23 ++++++++++++++-- src/sdk/text/index.ts | 19 ++++++++++--- src/types/api.ts | 1 + src/utils/model-defaults.ts | 53 +++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/utils/model-defaults.ts diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index fe2702d..aa0c444 100644 --- a/src/commands/text/chat.ts +++ b/src/commands/text/chat.ts @@ -17,6 +17,7 @@ import type { import { readFileSync } from 'fs'; import { isInteractive } from '../../utils/env'; import { promptText, failIfMissing } from '../../utils/prompt'; +import { resolveMaxTokens, resolveThinkingMode } from '../../utils/model-defaults'; // --------------------------------------------------------------------------- // Thinking indicator — dynamic spinner + color-cycling label @@ -163,10 +164,11 @@ export default defineCommand({ { flag: '--message ', description: 'Message text (repeatable, prefix role: to set role)', required: true, type: 'array' }, { flag: '--messages-file ', description: 'JSON file with messages array (use - for stdin)' }, { flag: '--system ', description: 'System prompt' }, - { flag: '--max-tokens ', description: 'Maximum tokens to generate (default: 4096)', type: 'number' }, + { flag: '--max-tokens ', description: 'Maximum tokens to generate (default: 131072 for M3, 65536 for other models)', type: 'number' }, { flag: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, { flag: '--top-p ', description: 'Nucleus sampling threshold', type: 'number' }, { flag: '--stream', description: 'Stream response tokens (default: on in TTY)' }, + { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, { flag: '--tool ', description: 'Tool definition as JSON or file path (repeatable)', type: 'array' }, ], examples: [ @@ -205,16 +207,29 @@ export default defineCommand({ && process.stdout.isTTY ); + // Validate --thinking flag before any side effects. Unknown mode is a + // user error; surface it cleanly with CLIError (exit 2 = USAGE). + let thinkingMode: ReturnType; + try { + thinkingMode = resolveThinkingMode(flags.thinking); + } catch (err) { + throw new CLIError( + err instanceof Error ? err.message : String(err), + ExitCode.USAGE, + ); + } + const body: ChatRequest = { model, messages, - max_tokens: (flags.maxTokens as number) ?? 4096, + max_tokens: resolveMaxTokens(model, flags.maxTokens as number | undefined), stream: shouldStream, }; if (system) body.system = system; if (flags.temperature !== undefined) body.temperature = flags.temperature as number; if (flags.topP !== undefined) body.top_p = flags.topP as number; + if (thinkingMode !== undefined) body.thinking = { type: thinkingMode }; if (flags.tool) { const tools = (flags.tool as string[]).map(t => { diff --git a/src/commands/text/repl.ts b/src/commands/text/repl.ts index 756dc60..8dfbb2c 100644 --- a/src/commands/text/repl.ts +++ b/src/commands/text/repl.ts @@ -9,6 +9,7 @@ import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { ChatMessage, ChatRequest, StreamEvent } from '../../types/api'; import { writeFileSync } from 'node:fs'; +import { resolveMaxTokens, resolveThinkingMode, type ThinkingMode } from '../../utils/model-defaults'; // --------------------------------------------------------------------------- // ANSI helpers @@ -58,6 +59,7 @@ interface ReplState { maxTokens: number; temperature: number | undefined; topP: number | undefined; + thinking: ThinkingMode | undefined; } // --------------------------------------------------------------------------- @@ -296,9 +298,10 @@ export default defineCommand({ options: [ { flag: '--model ', description: 'Model ID (default: MiniMax-M3)' }, { flag: '--system ', description: 'System prompt' }, - { flag: '--max-tokens ', description: 'Maximum tokens per response (default: 4096)', type: 'number' }, + { flag: '--max-tokens ', description: 'Maximum tokens per response (default: 131072 for M3, 65536 for other models)', type: 'number' }, { flag: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, { flag: '--top-p ', description: 'Nucleus sampling threshold', type: 'number' }, + { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, ], examples: [ 'mmx text repl', @@ -321,14 +324,29 @@ export default defineCommand({ const dim = config.noColor ? '' : '\x1b[2m'; const reset = config.noColor ? '' : '\x1b[0m'; + // ---- Validate --thinking up-front (same surface as chat) ---- + let thinkingMode: ThinkingMode | undefined; + try { + thinkingMode = resolveThinkingMode(flags.thinking); + } catch (err) { + throw new CLIError( + err instanceof Error ? err.message : String(err), + ExitCode.USAGE, + ); + } + // ---- Initialize state ---- const state: ReplState = { messages: [], system: flags.system as string | undefined, model: (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', - maxTokens: (flags.maxTokens as number) ?? 4096, + maxTokens: resolveMaxTokens( + (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', + flags.maxTokens as number | undefined, + ), temperature: flags.temperature !== undefined ? flags.temperature as number : undefined, topP: flags.topP !== undefined ? flags.topP as number : undefined, + thinking: thinkingMode, }; const bold = config.noColor ? '' : '\x1b[1m'; @@ -368,6 +386,7 @@ export default defineCommand({ if (state.system) body.system = state.system; if (state.temperature !== undefined) body.temperature = state.temperature; if (state.topP !== undefined) body.top_p = state.topP; + if (state.thinking !== undefined) body.thinking = { type: state.thinking }; waitingForResponse = true; const url = chatEndpoint(config.baseUrl); diff --git a/src/sdk/text/index.ts b/src/sdk/text/index.ts index 8d905d4..c7d7532 100644 --- a/src/sdk/text/index.ts +++ b/src/sdk/text/index.ts @@ -3,6 +3,7 @@ import { chatEndpoint } from "../../client/endpoints"; import { ChatRequest, ChatResponse, StreamEvent } from "../../types/api"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; +import { resolveMaxTokens } from "../../utils/model-defaults"; export class TextSDK extends Client { private async *chatStream(body: Partial): AsyncGenerator { @@ -54,10 +55,22 @@ export class TextSDK extends Client { ); } - return { + const model = params.model ?? 'MiniMax-M3'; + + const body: ChatRequest = { ...params, - model: params.model ?? 'MiniMax-M3', - max_tokens: params.max_tokens ?? 4096, + model, + max_tokens: resolveMaxTokens(model, params.max_tokens), } as ChatRequest; + + // Pass thinking through only if explicitly provided; never auto-inject. + // Per Messages API contract, thinking is disabled by default when omitted. + if (params.thinking !== undefined) { + body.thinking = params.thinking; + } else { + delete body.thinking; + } + + return body; } } diff --git a/src/types/api.ts b/src/types/api.ts index ffe6af5..2a7ea2d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -27,6 +27,7 @@ export interface ChatRequest { stream?: boolean; tools?: ChatTool[]; tool_choice?: { type: 'auto' | 'any' | 'tool'; name?: string }; + thinking?: { type: 'enabled' | 'disabled' | 'adaptive' }; } export interface ChatResponse { diff --git a/src/utils/model-defaults.ts b/src/utils/model-defaults.ts new file mode 100644 index 0000000..69b954d --- /dev/null +++ b/src/utils/model-defaults.ts @@ -0,0 +1,53 @@ +/** + * Model-aware defaults for the Messages API. + * + * The MiniMax Messages API documents per-model recommendations and maxima for + * `max_tokens`. Different models also support different `thinking` controls. + * + * Centralizing these here keeps the CLI commands and the SDK in lockstep — if + * the API contract changes, the one place to update is this file. + * + * Mirror tests live at test/utils/model-defaults.test.ts. + */ + +/** + * Per-model default `max_tokens` when the user does not pass `--max-tokens`. + * + * Per the current Messages API contract: + * - MiniMax-M3: recommended 131072 (128K), max 524288 (512K) + * - other supported models (MiniMax-M2.5, MiniMax-M2.7, etc.): 65536 (64K) + * + * `max_tokens` is an output ceiling, so the recommendation can still be + * overridden downward by the CLI user with `--max-tokens ` (e.g. 8192) for + * tighter budgets or response-shape constraints. + */ +export function resolveMaxTokens(model: string, flagValue: number | undefined): number { + if (flagValue !== undefined) return flagValue; + if (model === 'MiniMax-M3') return 131072; + return 65536; +} + +/** + * Allowed values for the `--thinking` CLI flag / `ChatRequest.thinking.type`. + * + * Per the Messages API contract, when the `thinking` field is omitted entirely + * the API defaults thinking to disabled for M3. The CLI therefore omits the + * field unless the user passes `--thinking ` explicitly. + */ +export const THINKING_MODES = ['enabled', 'disabled', 'adaptive'] as const; +export type ThinkingMode = (typeof THINKING_MODES)[number]; + +/** + * Validate and normalize a `--thinking` value. Returns the lowercased mode + * when it matches a known set, or `undefined` when the input is empty/absent. + * Throws on unknown values so the CLI can surface a clean CLIError before + * sending. + */ +export function resolveThinkingMode(value: unknown): ThinkingMode | undefined { + if (value === undefined || value === null || value === '') return undefined; + const v = String(value).toLowerCase(); + if ((THINKING_MODES as readonly string[]).includes(v)) return v as ThinkingMode; + throw new Error( + `Invalid --thinking value "${String(value)}". Expected one of: ${THINKING_MODES.join(', ')}.`, + ); +} From c99261384f0a934628bfff7a7fb548b9e60f6b07 Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Sat, 18 Jul 2026 21:42:16 +0800 Subject: [PATCH 2/4] feat(text): update temperature range and top_p default help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NianJiuZst review point 3 against the current Messages API contract. Changes: - --temperature help text updated from '(0.0, 1.0]' to '[0, 2] (default: 1)' in chat.ts and repl.ts. M3 Messages API documents this range. - New resolveTemperature() helper in src/utils/model-defaults.ts validates --temperature before any network call: rejects non-numeric input and values outside [0, 2] with a clean error. Same surface in chat and repl. - --top-p help text updated to mention 0.95 as the Messages API default. We do NOT auto-send 0.95 when the flag is omitted — backward compat: users without the flag see no change in the request body. - New TOP_P_DEFAULT export in model-defaults.ts (0.95) for tests and future callers that want to mirror the documented default. Co-authored-by: minimax --- src/commands/text/chat.ts | 22 ++++++++++++++++---- src/commands/text/repl.ts | 19 ++++++++++++++---- src/utils/model-defaults.ts | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index aa0c444..08b591e 100644 --- a/src/commands/text/chat.ts +++ b/src/commands/text/chat.ts @@ -17,7 +17,7 @@ import type { import { readFileSync } from 'fs'; import { isInteractive } from '../../utils/env'; import { promptText, failIfMissing } from '../../utils/prompt'; -import { resolveMaxTokens, resolveThinkingMode } from '../../utils/model-defaults'; +import { resolveMaxTokens, resolveThinkingMode, resolveTemperature } from '../../utils/model-defaults'; // --------------------------------------------------------------------------- // Thinking indicator — dynamic spinner + color-cycling label @@ -165,8 +165,8 @@ export default defineCommand({ { flag: '--messages-file ', description: 'JSON file with messages array (use - for stdin)' }, { flag: '--system ', description: 'System prompt' }, { flag: '--max-tokens ', description: 'Maximum tokens to generate (default: 131072 for M3, 65536 for other models)', type: 'number' }, - { flag: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold', type: 'number' }, + { flag: '--temperature ', description: 'Sampling temperature [0, 2] (default: 1)', type: 'number' }, + { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 per Messages API)', type: 'number' }, { flag: '--stream', description: 'Stream response tokens (default: on in TTY)' }, { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, { flag: '--tool ', description: 'Tool definition as JSON or file path (repeatable)', type: 'array' }, @@ -219,6 +219,20 @@ export default defineCommand({ ); } + // Same surface for --temperature. Per Messages API contract, M3 uses + // [0, 2] with default 1; we validate before any network call. We do + // NOT auto-send 1 when the flag is omitted — backward compat: existing + // users get whatever the server defaults to, no body change. + let temperature: ReturnType; + try { + temperature = resolveTemperature(flags.temperature); + } catch (err) { + throw new CLIError( + err instanceof Error ? err.message : String(err), + ExitCode.USAGE, + ); + } + const body: ChatRequest = { model, messages, @@ -227,7 +241,7 @@ export default defineCommand({ }; if (system) body.system = system; - if (flags.temperature !== undefined) body.temperature = flags.temperature as number; + if (temperature !== undefined) body.temperature = temperature; if (flags.topP !== undefined) body.top_p = flags.topP as number; if (thinkingMode !== undefined) body.thinking = { type: thinkingMode }; diff --git a/src/commands/text/repl.ts b/src/commands/text/repl.ts index 8dfbb2c..f4532f1 100644 --- a/src/commands/text/repl.ts +++ b/src/commands/text/repl.ts @@ -9,7 +9,7 @@ import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { ChatMessage, ChatRequest, StreamEvent } from '../../types/api'; import { writeFileSync } from 'node:fs'; -import { resolveMaxTokens, resolveThinkingMode, type ThinkingMode } from '../../utils/model-defaults'; +import { resolveMaxTokens, resolveThinkingMode, resolveTemperature, type ThinkingMode } from '../../utils/model-defaults'; // --------------------------------------------------------------------------- // ANSI helpers @@ -299,8 +299,8 @@ export default defineCommand({ { flag: '--model ', description: 'Model ID (default: MiniMax-M3)' }, { flag: '--system ', description: 'System prompt' }, { flag: '--max-tokens ', description: 'Maximum tokens per response (default: 131072 for M3, 65536 for other models)', type: 'number' }, - { flag: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold', type: 'number' }, + { flag: '--temperature ', description: 'Sampling temperature [0, 2] (default: 1)', type: 'number' }, + { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 per Messages API)', type: 'number' }, { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, ], examples: [ @@ -335,6 +335,17 @@ export default defineCommand({ ); } + // ---- Validate --temperature up-front (same surface as chat) ---- + let temperature: number | undefined; + try { + temperature = resolveTemperature(flags.temperature); + } catch (err) { + throw new CLIError( + err instanceof Error ? err.message : String(err), + ExitCode.USAGE, + ); + } + // ---- Initialize state ---- const state: ReplState = { messages: [], @@ -344,7 +355,7 @@ export default defineCommand({ (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', flags.maxTokens as number | undefined, ), - temperature: flags.temperature !== undefined ? flags.temperature as number : undefined, + temperature, topP: flags.topP !== undefined ? flags.topP as number : undefined, thinking: thinkingMode, }; diff --git a/src/utils/model-defaults.ts b/src/utils/model-defaults.ts index 69b954d..c491e39 100644 --- a/src/utils/model-defaults.ts +++ b/src/utils/model-defaults.ts @@ -51,3 +51,43 @@ export function resolveThinkingMode(value: unknown): ThinkingMode | undefined { `Invalid --thinking value "${String(value)}". Expected one of: ${THINKING_MODES.join(', ')}.`, ); } + +/** + * Sampling temperature bounds for the Messages API. + * + * M3 documents `[0, 2]` with default `1`. The CLI rejects out-of-range values + * to fail fast on a common typo (e.g. `1.5e1` or a stray negative sign). + */ +export const TEMPERATURE_MIN = 0; +export const TEMPERATURE_MAX = 2; +export const TEMPERATURE_DEFAULT = 1; + +/** + * Validate a `--temperature` value. Returns the number when within `[0, 2]`, + * `undefined` when no value was supplied, or throws on out-of-range / non-numeric + * input. + */ +export function resolveTemperature(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) { + throw new Error(`Invalid --temperature value "${String(value)}". Must be a number in [${TEMPERATURE_MIN}, ${TEMPERATURE_MAX}].`); + } + if (n < TEMPERATURE_MIN || n > TEMPERATURE_MAX) { + throw new Error( + `Invalid --temperature value ${n}. Must be in [${TEMPERATURE_MIN}, ${TEMPERATURE_MAX}] (per Messages API contract).`, + ); + } + return n; +} + +/** + * Recommended nucleus sampling threshold per the Messages API contract. + * + * Documented in help text so users know the API's recommended default. The + * CLI does not auto-send 0.95 when the flag is omitted — preserving + * backward-compat for users who pass nothing today and rely on whatever the + * API defaults to on the server side. + */ +export const TOP_P_DEFAULT = 0.95; + From b41d08b301caaea066a0108651f0856aa64501ec Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Sat, 18 Jul 2026 21:43:56 +0800 Subject: [PATCH 3/4] test(text): cover M3/Messages API contract defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds focused coverage for the new behavior landed in the two previous commits: - model-aware max_tokens via resolveMaxTokens * M3 -> 131072 when caller / flag is absent * MiniMax-M2.7 -> 65536 * MiniMax-M2.5 and unknown models fall back to 65536 * caller-supplied flag (100, 524288) always wins - thinking parameter plumbing * --thinking enabled / disabled propagates to ChatRequest.thinking * omitting --thinking leaves the field absent in the body * invalid values throw a clean error from resolveThinkingMode - temperature range [0, 2] with default 1 * resolveTemperature accepts 0, 1, 2, fractional 0.7 / 1.5, numeric strings * resolves [-0.01, 2.01] and non-numeric input as errors - top_p default 0.95 documented via TOP_P_DEFAULT export - help text in both chat and repl reflects the documented defaults and the full --thinking mode set Coverage lives at the right altitude: - test/utils/model-defaults.test.ts — pure unit tests of the helper - test/sdk/text.test.ts — SDK chat() roundtrip via mock server - test/commands/text/*.test.ts — full command execute() via --dry-run Co-authored-by: minimax --- test/commands/text/chat.test.ts | 453 ++++++++++++++++++++++++++++++ test/commands/text/repl.test.ts | 38 +++ test/sdk/text.test.ts | 154 ++++++++++ test/utils/model-defaults.test.ts | 113 ++++++++ 4 files changed, 758 insertions(+) diff --git a/test/commands/text/chat.test.ts b/test/commands/text/chat.test.ts index 2c592b6..80c9cbf 100644 --- a/test/commands/text/chat.test.ts +++ b/test/commands/text/chat.test.ts @@ -302,4 +302,457 @@ describe('text chat command', () => { console.log = originalLog; } }); + + it('defaults max_tokens to 131072 when no --max-tokens and no --model (M3 default)', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-M3'); + expect(parsed.request.max_tokens).toBe(131072); + } finally { + console.log = originalLog; + } + }); + + it('defaults max_tokens to 65536 when --model MiniMax-M2.7 is passed without --max-tokens', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + model: 'MiniMax-M2.7', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-M2.7'); + expect(parsed.request.max_tokens).toBe(65536); + } finally { + console.log = originalLog; + } + }); + + it('--max-tokens 100 overrides model-aware default', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + maxTokens: 100, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.max_tokens).toBe(100); + } finally { + console.log = originalLog; + } + }); + + it('--thinking enabled sends thinking: { type: "enabled" }', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + thinking: 'enabled', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.thinking).toEqual({ type: 'enabled' }); + } finally { + console.log = originalLog; + } + }); + + it('--thinking disabled sends thinking: { type: "disabled" }', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + thinking: 'disabled', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.thinking).toEqual({ type: 'disabled' }); + } finally { + console.log = originalLog; + } + }); + + it('omits thinking field when --thinking is not provided', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect('thinking' in parsed.request).toBe(false); + } finally { + console.log = originalLog; + } + }); + + it('--thinking bogus throws CLIError', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + let caught: unknown; + try { + await chatCommand.execute(config, { + message: ['Hello'], + thinking: 'bogus', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect((caught as { message?: string }).message).toMatch(/Invalid --thinking value "bogus"/); + }); + + it('--temperature 3 throws CLIError (above [0,2])', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + let caught: unknown; + try { + await chatCommand.execute(config, { + message: ['Hello'], + temperature: 3, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect((caught as { message?: string }).message).toMatch(/Must be in \[0, 2\]/); + }); + + it('--temperature -0.5 throws CLIError (below [0,2])', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + let caught: unknown; + try { + await chatCommand.execute(config, { + message: ['Hello'], + temperature: -0.5, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect((caught as { message?: string }).message).toMatch(/Must be in \[0, 2\]/); + }); + + it('--temperature 0.7 is forwarded to the request body', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + temperature: 0.7, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.temperature).toBe(0.7); + } finally { + console.log = originalLog; + } + }); + + it('help text mentions the M3 default (131072) for --max-tokens', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + const options = chatCommand.options!; + const maxTokensOpt = options.find(o => o.flag.includes('max-tokens')); + expect(maxTokensOpt).toBeDefined(); + expect(maxTokensOpt!.description).toContain('131072'); + expect(maxTokensOpt!.description).toContain('65536'); + }); + + it('help text documents temperature [0, 2] range', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + const options = chatCommand.options!; + const tempOpt = options.find(o => o.flag.includes('temperature')); + expect(tempOpt).toBeDefined(); + expect(tempOpt!.description).toContain('[0, 2]'); + }); + + it('help text documents top_p 0.95 default', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + const options = chatCommand.options!; + const topPOpt = options.find(o => o.flag.includes('top-p')); + expect(topPOpt).toBeDefined(); + expect(topPOpt!.description).toContain('0.95'); + }); + + it('help text describes --thinking flag with all three modes', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + const options = chatCommand.options!; + const thinkingOpt = options.find(o => o.flag.includes('thinking')); + expect(thinkingOpt).toBeDefined(); + expect(thinkingOpt!.description).toContain('enabled'); + expect(thinkingOpt!.description).toContain('disabled'); + expect(thinkingOpt!.description).toContain('adaptive'); + }); }); diff --git a/test/commands/text/repl.test.ts b/test/commands/text/repl.test.ts index 950cfdb..0555505 100644 --- a/test/commands/text/repl.test.ts +++ b/test/commands/text/repl.test.ts @@ -57,4 +57,42 @@ describe('text repl command', () => { await expect(cmd.execute(config, flags)).rejects.toThrow('interactive'); }); + + it('help text documents the M3-default max_tokens', async () => { + const mod = await import('../../../src/commands/text/repl'); + const cmd = mod.default; + const options = cmd.options!; + const maxTokensOpt = options.find(o => o.flag.includes('max-tokens')); + expect(maxTokensOpt).toBeDefined(); + expect(maxTokensOpt!.description).toContain('131072'); + }); + + it('help text documents temperature [0, 2] range', async () => { + const mod = await import('../../../src/commands/text/repl'); + const cmd = mod.default; + const options = cmd.options!; + const tempOpt = options.find(o => o.flag.includes('temperature')); + expect(tempOpt).toBeDefined(); + expect(tempOpt!.description).toContain('[0, 2]'); + }); + + it('help text documents top_p 0.95 default', async () => { + const mod = await import('../../../src/commands/text/repl'); + const cmd = mod.default; + const options = cmd.options!; + const topPOpt = options.find(o => o.flag.includes('top-p')); + expect(topPOpt).toBeDefined(); + expect(topPOpt!.description).toContain('0.95'); + }); + + it('help text describes --thinking flag with all three modes', async () => { + const mod = await import('../../../src/commands/text/repl'); + const cmd = mod.default; + const options = cmd.options!; + const thinkingOpt = options.find(o => o.flag.includes('thinking')); + expect(thinkingOpt).toBeDefined(); + expect(thinkingOpt!.description).toContain('enabled'); + expect(thinkingOpt!.description).toContain('disabled'); + expect(thinkingOpt!.description).toContain('adaptive'); + }); }); diff --git a/test/sdk/text.test.ts b/test/sdk/text.test.ts index 4318651..128855f 100644 --- a/test/sdk/text.test.ts +++ b/test/sdk/text.test.ts @@ -88,3 +88,157 @@ describe('TextSDK.validateParams', () => { ).rejects.not.toThrow('At least one message'); }); }); + +describe('TextSDK.validateParams (model-aware max_tokens + thinking)', () => { + it('defaults max_tokens to 131072 for M3 when caller omits it', async () => { + let body: Record | undefined; + const server = createMockServer({ + routes: { + '/anthropic/v1/messages': async (req) => { + body = await req.json() as Record; + return jsonResponse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'MiniMax-M3', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }, + }, + }); + try { + const sdk = new MiniMaxSDK({ apiKey: 'sk-test', region: 'global', baseUrl: server.url }); + await sdk.text.chat({ + model: 'MiniMax-M3', + messages: [{ role: 'user', content: 'hi' }], + }); + expect(body?.max_tokens).toBe(131072); + } finally { + server.close(); + } + }); + + it('defaults max_tokens to 65536 for M2.7 when caller omits it', async () => { + let body: Record | undefined; + const server = createMockServer({ + routes: { + '/anthropic/v1/messages': async (req) => { + body = await req.json() as Record; + return jsonResponse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'MiniMax-M2.7', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }, + }, + }); + try { + const sdk = new MiniMaxSDK({ apiKey: 'sk-test', region: 'global', baseUrl: server.url }); + await sdk.text.chat({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'hi' }], + }); + expect(body?.max_tokens).toBe(65536); + } finally { + server.close(); + } + }); + + it('caller-supplied max_tokens overrides the M3 default', async () => { + let body: Record | undefined; + const server = createMockServer({ + routes: { + '/anthropic/v1/messages': async (req) => { + body = await req.json() as Record; + return jsonResponse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'MiniMax-M3', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }, + }, + }); + try { + const sdk = new MiniMaxSDK({ apiKey: 'sk-test', region: 'global', baseUrl: server.url }); + await sdk.text.chat({ + model: 'MiniMax-M3', + max_tokens: 100, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(body?.max_tokens).toBe(100); + } finally { + server.close(); + } + }); + + it('omits thinking field when caller does not pass it', async () => { + let body: Record | undefined; + const server = createMockServer({ + routes: { + '/anthropic/v1/messages': async (req) => { + body = await req.json() as Record; + return jsonResponse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'MiniMax-M3', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }, + }, + }); + try { + const sdk = new MiniMaxSDK({ apiKey: 'sk-test', region: 'global', baseUrl: server.url }); + await sdk.text.chat({ + model: 'MiniMax-M3', + messages: [{ role: 'user', content: 'hi' }], + }); + expect('thinking' in (body ?? {})).toBe(false); + } finally { + server.close(); + } + }); + + it('passes through thinking: { type: "enabled" } when caller sets it', async () => { + let body: Record | undefined; + const server = createMockServer({ + routes: { + '/anthropic/v1/messages': async (req) => { + body = await req.json() as Record; + return jsonResponse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'MiniMax-M3', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }, + }, + }); + try { + const sdk = new MiniMaxSDK({ apiKey: 'sk-test', region: 'global', baseUrl: server.url }); + await sdk.text.chat({ + model: 'MiniMax-M3', + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled' }, + }); + expect(body?.thinking).toEqual({ type: 'enabled' }); + } finally { + server.close(); + } + }); +}); diff --git a/test/utils/model-defaults.test.ts b/test/utils/model-defaults.test.ts index deeee0b..9407f9c 100644 --- a/test/utils/model-defaults.test.ts +++ b/test/utils/model-defaults.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'bun:test'; import type { Config } from '../../src/config/schema'; +import { + resolveMaxTokens, + resolveThinkingMode, + resolveTemperature, + THINKING_MODES, + TOP_P_DEFAULT, + TEMPERATURE_MIN, + TEMPERATURE_MAX, +} from '../../src/utils/model-defaults'; const baseConfig: Config = { region: 'global', @@ -62,3 +71,107 @@ describe('model resolution (flag > config default > fallback)', () => { expect(model).toBe('music-3.0'); }); }); + +describe('resolveMaxTokens (model-aware defaults)', () => { + it('uses 131072 for MiniMax-M3 when flag is absent', () => { + expect(resolveMaxTokens('MiniMax-M3', undefined)).toBe(131072); + }); + + it('uses 65536 for MiniMax-M2.7 when flag is absent', () => { + expect(resolveMaxTokens('MiniMax-M2.7', undefined)).toBe(65536); + }); + + it('uses 65536 for MiniMax-M2.5 when flag is absent', () => { + expect(resolveMaxTokens('MiniMax-M2.5', undefined)).toBe(65536); + }); + + it('uses 65536 for unknown / other models', () => { + expect(resolveMaxTokens('gpt-9-snowflake', undefined)).toBe(65536); + }); + + it('flag value 100 always overrides M3 default', () => { + expect(resolveMaxTokens('MiniMax-M3', 100)).toBe(100); + }); + + it('flag value 100 always overrides M2.7 default', () => { + expect(resolveMaxTokens('MiniMax-M2.7', 100)).toBe(100); + }); + + it('flag value 524288 (M3 documented max) passes through', () => { + expect(resolveMaxTokens('MiniMax-M3', 524288)).toBe(524288); + }); +}); + +describe('resolveThinkingMode', () => { + it('returns undefined for absent / empty input', () => { + expect(resolveThinkingMode(undefined)).toBeUndefined(); + expect(resolveThinkingMode(null)).toBeUndefined(); + expect(resolveThinkingMode('')).toBeUndefined(); + }); + + it('returns the mode for known values', () => { + expect(resolveThinkingMode('enabled')).toBe('enabled'); + expect(resolveThinkingMode('disabled')).toBe('disabled'); + expect(resolveThinkingMode('adaptive')).toBe('adaptive'); + }); + + it('normalizes case', () => { + expect(resolveThinkingMode('Enabled')).toBe('enabled'); + expect(resolveThinkingMode('ADAPTIVE')).toBe('adaptive'); + }); + + it('throws on unknown values', () => { + expect(() => resolveThinkingMode('bogus')).toThrow(/Invalid --thinking value/); + }); + + it('exposes the full allowed set', () => { + expect(THINKING_MODES).toEqual(['enabled', 'disabled', 'adaptive']); + }); +}); + +describe('resolveTemperature ([0, 2] validation)', () => { + it('returns undefined for absent / empty input', () => { + expect(resolveTemperature(undefined)).toBeUndefined(); + expect(resolveTemperature(null)).toBeUndefined(); + expect(resolveTemperature('')).toBeUndefined(); + }); + + it('accepts boundary values 0 and 2', () => { + expect(resolveTemperature(0)).toBe(0); + expect(resolveTemperature(2)).toBe(2); + }); + + it('accepts the documented default 1', () => { + expect(resolveTemperature(1)).toBe(1); + }); + + it('accepts fractional values within range', () => { + expect(resolveTemperature(0.7)).toBe(0.7); + expect(resolveTemperature(1.5)).toBe(1.5); + }); + + it('coerces numeric strings', () => { + expect(resolveTemperature('0.5')).toBe(0.5); + }); + + it('throws on values below 0', () => { + expect(() => resolveTemperature(-0.01)).toThrow(/Must be in \[0, 2\]/); + }); + + it('throws on values above 2', () => { + expect(() => resolveTemperature(2.01)).toThrow(/Must be in \[0, 2\]/); + }); + + it('throws on non-numeric strings', () => { + expect(() => resolveTemperature('hot')).toThrow(/Must be a number/); + }); + + it('exposes the contract bounds as constants', () => { + expect(TEMPERATURE_MIN).toBe(0); + expect(TEMPERATURE_MAX).toBe(2); + }); + + it('exposes the documented top_p default', () => { + expect(TOP_P_DEFAULT).toBe(0.95); + }); +}); From 86262ca8ae8894d9e6f8c04d5b217d1c0f22c861 Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Sat, 18 Jul 2026 21:58:56 +0800 Subject: [PATCH 4/4] fix(text): make top_p default and help text model-aware The Messages API documents per-model top_p defaults: - MiniMax-M3: 0.95 - M2.x models: 0.9 PR #204 previously hardcoded 0.95 in the TOP_P_DEFAULT export and the --top-p help text in both commands, which misrepresented the M2.x default. Replace TOP_P_DEFAULT constant with resolveTopPDefault(model) function that returns the model-appropriate value (M3 -> 0.95, everything else -> 0.9 as the safe M2.x fallback). Update help text in mmx text chat and mmx text repl accordingly. CLI behavior is unchanged: top_p is still only sent on the wire when the user explicitly passes --top-p. The fix is help-text accuracy / contract alignment, no payload change. Verified against the current Messages API documentation at https://platform.minimax.io/docs/api-reference/text-chat-anthropic --- src/commands/text/chat.ts | 2 +- src/commands/text/repl.ts | 2 +- src/utils/model-defaults.ts | 23 +++++++++++++++++------ test/utils/model-defaults.test.ts | 20 +++++++++++++++++--- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index 08b591e..1ca9a9b 100644 --- a/src/commands/text/chat.ts +++ b/src/commands/text/chat.ts @@ -166,7 +166,7 @@ export default defineCommand({ { flag: '--system ', description: 'System prompt' }, { flag: '--max-tokens ', description: 'Maximum tokens to generate (default: 131072 for M3, 65536 for other models)', type: 'number' }, { flag: '--temperature ', description: 'Sampling temperature [0, 2] (default: 1)', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 per Messages API)', type: 'number' }, + { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 for M3, 0.9 for M2.x per Messages API)', type: 'number' }, { flag: '--stream', description: 'Stream response tokens (default: on in TTY)' }, { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, { flag: '--tool ', description: 'Tool definition as JSON or file path (repeatable)', type: 'array' }, diff --git a/src/commands/text/repl.ts b/src/commands/text/repl.ts index f4532f1..fbfa9d0 100644 --- a/src/commands/text/repl.ts +++ b/src/commands/text/repl.ts @@ -300,7 +300,7 @@ export default defineCommand({ { flag: '--system ', description: 'System prompt' }, { flag: '--max-tokens ', description: 'Maximum tokens per response (default: 131072 for M3, 65536 for other models)', type: 'number' }, { flag: '--temperature ', description: 'Sampling temperature [0, 2] (default: 1)', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 per Messages API)', type: 'number' }, + { flag: '--top-p ', description: 'Nucleus sampling threshold (default: 0.95 for M3, 0.9 for M2.x per Messages API)', type: 'number' }, { flag: '--thinking ', description: 'Thinking mode for M3: enabled | disabled | adaptive (default: omitted — thinking disabled per Messages API contract)' }, ], examples: [ diff --git a/src/utils/model-defaults.ts b/src/utils/model-defaults.ts index c491e39..be875f4 100644 --- a/src/utils/model-defaults.ts +++ b/src/utils/model-defaults.ts @@ -82,12 +82,23 @@ export function resolveTemperature(value: unknown): number | undefined { } /** - * Recommended nucleus sampling threshold per the Messages API contract. + * Per-model default `top_p` (nucleus sampling threshold) recommended by the + * current Messages API contract: + * - MiniMax-M3: 0.95 + * - M2.x models (M2.5, M2.7, etc.): 0.9 * - * Documented in help text so users know the API's recommended default. The - * CLI does not auto-send 0.95 when the flag is omitted — preserving - * backward-compat for users who pass nothing today and rely on whatever the - * API defaults to on the server side. + * `top_p` is only sent on the wire when the user explicitly passes + * `--top-p `. The CLI does NOT auto-send the model-aware default — that + * would be a behavior change for existing users who pass nothing today and + * rely on whatever the API defaults to on the server side. + * + * This function returns the recommended value to display in help text and + * to mirror in tests, so the documentation stays aligned with the API + * contract if a future model ships a different default. Unknown models + * fall back to the M2.x default (0.9) as the safer middle ground. */ -export const TOP_P_DEFAULT = 0.95; +export function resolveTopPDefault(model: string): number { + if (model === 'MiniMax-M3') return 0.95; + return 0.9; +} diff --git a/test/utils/model-defaults.test.ts b/test/utils/model-defaults.test.ts index 9407f9c..e49e9ae 100644 --- a/test/utils/model-defaults.test.ts +++ b/test/utils/model-defaults.test.ts @@ -4,8 +4,8 @@ import { resolveMaxTokens, resolveThinkingMode, resolveTemperature, + resolveTopPDefault, THINKING_MODES, - TOP_P_DEFAULT, TEMPERATURE_MIN, TEMPERATURE_MAX, } from '../../src/utils/model-defaults'; @@ -170,8 +170,22 @@ describe('resolveTemperature ([0, 2] validation)', () => { expect(TEMPERATURE_MIN).toBe(0); expect(TEMPERATURE_MAX).toBe(2); }); +}); + +describe('resolveTopPDefault (model-aware per Messages API contract)', () => { + it('returns 0.95 for MiniMax-M3 (the documented M3 default)', () => { + expect(resolveTopPDefault('MiniMax-M3')).toBe(0.95); + }); + + it('returns 0.9 for MiniMax-M2.7 (the documented M2.x default)', () => { + expect(resolveTopPDefault('MiniMax-M2.7')).toBe(0.9); + }); + + it('returns 0.9 for MiniMax-M2.5 (the documented M2.x default)', () => { + expect(resolveTopPDefault('MiniMax-M2.5')).toBe(0.9); + }); - it('exposes the documented top_p default', () => { - expect(TOP_P_DEFAULT).toBe(0.95); + it('returns 0.9 for unknown models (safe M2.x fallback)', () => { + expect(resolveTopPDefault('gpt-9-snowflake')).toBe(0.9); }); });