diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index fe2702d..1ca9a9b 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, resolveTemperature } 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: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold', 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, 2] (default: 1)', 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' }, ], examples: [ @@ -205,16 +207,43 @@ 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, + ); + } + + // 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, - 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 (temperature !== undefined) body.temperature = temperature; 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..fbfa9d0 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, resolveTemperature, 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: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, - { flag: '--top-p ', description: 'Nucleus sampling threshold', 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, 2] (default: 1)', 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: [ 'mmx text repl', @@ -321,14 +324,40 @@ 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, + ); + } + + // ---- 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: [], system: flags.system as string | undefined, model: (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', - maxTokens: (flags.maxTokens as number) ?? 4096, - temperature: flags.temperature !== undefined ? flags.temperature as number : undefined, + maxTokens: resolveMaxTokens( + (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', + flags.maxTokens as number | undefined, + ), + temperature, topP: flags.topP !== undefined ? flags.topP as number : undefined, + thinking: thinkingMode, }; const bold = config.noColor ? '' : '\x1b[1m'; @@ -368,6 +397,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..be875f4 --- /dev/null +++ b/src/utils/model-defaults.ts @@ -0,0 +1,104 @@ +/** + * 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(', ')}.`, + ); +} + +/** + * 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; +} + +/** + * 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 + * + * `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 function resolveTopPDefault(model: string): number { + if (model === 'MiniMax-M3') return 0.95; + return 0.9; +} + 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..e49e9ae 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, + resolveTopPDefault, + THINKING_MODES, + TEMPERATURE_MIN, + TEMPERATURE_MAX, +} from '../../src/utils/model-defaults'; const baseConfig: Config = { region: 'global', @@ -62,3 +71,121 @@ 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); + }); +}); + +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('returns 0.9 for unknown models (safe M2.x fallback)', () => { + expect(resolveTopPDefault('gpt-9-snowflake')).toBe(0.9); + }); +});