From acf1b7db7f0cfa92851cf45bf9ee135a2779f69f Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:00:41 +0800 Subject: [PATCH 1/3] feat(providers): add MiniMax regional presets --- README.md | 7 +++ src/cost.ts | 8 +++ src/provider-contract.ts | 86 +++++++++++++++++++++++++--- src/provider-invocation.ts | 3 +- tests/unit/cost.test.ts | 17 ++++++ tests/unit/provider-contract.test.ts | 62 ++++++++++++++++++++ 6 files changed, 174 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 94aa4a3..5fe0ef0 100644 --- a/README.md +++ b/README.md @@ -1598,6 +1598,13 @@ availability is reported (`credentialAvailable`) but not gated, so the contract stays inspectable even when the credential is not currently exported. Exit code: `0` on success, `2` on a contract/usage error. +MiniMax has built-in global and China entries, including +the current `MiniMax-M3` and `MiniMax-M2.7` catalog. Declare +`{ "id": "minimax" }` for the global endpoint or +`{ "id": "minimax-cn" }` for the China endpoint, then export the +credential as `MINIMAX_API_KEY`. An explicit `baseUrl`, `model`, +`models`, or `apiKeyEnv` in the entry still overrides the corresponding preset. + ```text Provider: alt Contract: oh-my-cli.provider-contract v1 (settings contract version 1) diff --git a/src/cost.ts b/src/cost.ts index e0c55b4..a58033b 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -23,6 +23,14 @@ export const MODEL_PRICES: Record = { "gpt-4o-mini": { promptPerM: 0.15, completionPerM: 0.6 }, "gpt-4.1": { promptPerM: 2, completionPerM: 8 }, "gpt-4.1-mini": { promptPerM: 0.4, completionPerM: 1.6 }, + "MiniMax-M3": { + promptPerM: 0.6, + completionPerM: 2.4, + }, + "MiniMax-M2.7": { + promptPerM: 0.3, + completionPerM: 1.2, + }, }; // Conservative fallback used when the model is not in the table, so cost is diff --git a/src/provider-contract.ts b/src/provider-contract.ts index 08361f3..cbc3230 100644 --- a/src/provider-contract.ts +++ b/src/provider-contract.ts @@ -64,6 +64,40 @@ export interface ProviderEntry { capabilities?: ProviderCapabilityFlags; } +export type ProviderEndpointSource = "settings" | "builtin" | "default"; + +// Built-in entries keep common provider configuration concise while preserving +// the contract's trust boundary: users still opt in by declaring the provider +// id, and credentials remain in a named environment variable. +export interface ProviderPreset { + baseUrl: string; + model: string; + models: readonly string[]; + apiKeyEnv: string; + capabilities: Readonly; +} + +export const BUILTIN_PROVIDER_PRESETS = { + "minimax": { + baseUrl: "https://api.minimax.io/v1", + model: "MiniMax-M3", + models: ["MiniMax-M3", "MiniMax-M2.7"], + apiKeyEnv: "MINIMAX_API_KEY", + capabilities: { vision: true, tools: true, streaming: true }, + }, + "minimax-cn": { + baseUrl: "https://api.minimaxi.com/v1", + model: "MiniMax-M3", + models: ["MiniMax-M3", "MiniMax-M2.7"], + apiKeyEnv: "MINIMAX_API_KEY", + capabilities: { vision: true, tools: true, streaming: true }, + }, +} as const satisfies Readonly>; + +function builtinProviderPreset(id: string): ProviderPreset | undefined { + return (BUILTIN_PROVIDER_PRESETS as Readonly>)[id]; +} + // The validated `providers` section: a negotiated contract version, an optional // default provider id, and the validated entries (ids guaranteed unique). export interface ProviderContract { @@ -83,7 +117,7 @@ const CapabilitySchema = z const ProviderEntrySchema = z.object({ id: z.string().min(1, "providers.entries[].id must be a non-empty string"), baseUrl: z.string().url("providers.entries[].baseUrl must be a valid URL").optional(), - model: z.string().min(1, "providers.entries[].model is required"), + model: z.string().min(1, "providers.entries[].model is required").optional(), models: z.array(z.string().min(1, "providers.entries[].models entries must be non-empty")).optional(), apiKeyEnv: z .string() @@ -206,11 +240,33 @@ export function parseProviderContract(section: unknown): ProviderContract { const issues = result.error.issues.map((i) => i.message).join("; "); throw new Error(`Settings error: ${issues}`); } + const preset = builtinProviderPreset(result.data.id); + const model = result.data.model ?? preset?.model; + if (model === undefined) { + throw new Error("Settings error: providers.entries[].model is required"); + } if (seen.has(result.data.id)) { throw new Error(`Settings error: duplicate provider id "${result.data.id}"`); } seen.add(result.data.id); - entries.push(result.data); + entries.push({ + id: result.data.id, + model, + ...(result.data.baseUrl !== undefined ? { baseUrl: result.data.baseUrl } : {}), + ...(result.data.models !== undefined + ? { models: result.data.models } + : preset + ? { models: [...preset.models] } + : {}), + ...(result.data.apiKeyEnv !== undefined + ? { apiKeyEnv: result.data.apiKeyEnv } + : preset + ? { apiKeyEnv: preset.apiKeyEnv } + : {}), + ...(result.data.capabilities !== undefined || preset + ? { capabilities: { ...preset?.capabilities, ...result.data.capabilities } } + : {}), + }); }); if (def !== undefined && !seen.has(def)) { @@ -263,7 +319,7 @@ export function resolveProviderConfig( opts: { env?: Record } = {}, ): Config { const env = opts.env ?? process.env; - const baseUrl = entry.baseUrl ?? DEFAULT_BASE_URL; + const { baseUrl } = resolveProviderEndpoint(entry); if (!isValidUrl(baseUrl)) { throw new Error(`Provider error: provider "${entry.id}" base URL is not a valid URL`); } @@ -286,7 +342,7 @@ export interface ProviderContractReport { contractVersion: number; providerId: string; endpoint: string; - endpointSource: "settings" | "default"; + endpointSource: ProviderEndpointSource; model: string; modelCatalog: string[]; credentialVariable: string; @@ -301,7 +357,7 @@ export function buildProviderContractReport(facts: { contractVersion: number; entry: ProviderEntry; baseUrl: string; - endpointSource: "settings" | "default"; + endpointSource: ProviderEndpointSource; credentialVariable: string; credentialFromSettings: boolean; credentialAvailable: boolean; @@ -375,7 +431,7 @@ export interface ProviderReadiness { state: ProviderReadinessState; reason: string; baseUrl: string; - endpointSource: "settings" | "default"; + endpointSource: ProviderEndpointSource; endpointValid: boolean; credentialVariable: string; credentialFromSettings: boolean; @@ -387,7 +443,7 @@ export function resolveProviderReadiness( opts: { env?: Record } = {}, ): ProviderReadiness { const env = opts.env ?? process.env; - const baseUrl = entry.baseUrl ?? DEFAULT_BASE_URL; + const { baseUrl, endpointSource } = resolveProviderEndpoint(entry); const endpointValid = isValidUrl(baseUrl); const credentialVariable = entry.apiKeyEnv ?? "OPENAI_API_KEY"; const credentialAvailable = envValue(env, credentialVariable) !== undefined; @@ -404,7 +460,7 @@ export function resolveProviderReadiness( state, reason, baseUrl, - endpointSource: entry.baseUrl ? "settings" : "default", + endpointSource, endpointValid, credentialVariable, credentialFromSettings: entry.apiKeyEnv !== undefined, @@ -412,6 +468,20 @@ export function resolveProviderReadiness( }; } +function resolveProviderEndpoint(entry: ProviderEntry): { + baseUrl: string; + endpointSource: ProviderEndpointSource; +} { + if (entry.baseUrl !== undefined) { + return { baseUrl: entry.baseUrl, endpointSource: "settings" }; + } + const preset = builtinProviderPreset(entry.id); + if (preset !== undefined) { + return { baseUrl: preset.baseUrl, endpointSource: "builtin" }; + } + return { baseUrl: DEFAULT_BASE_URL, endpointSource: "default" }; +} + // Read the user settings file, negotiate the provider contract, select one // provider, and build the redacted report. Throws a redacted error when no // `providers` section exists or the contract is invalid. Credential availability diff --git a/src/provider-invocation.ts b/src/provider-invocation.ts index 8d4e736..286386d 100644 --- a/src/provider-invocation.ts +++ b/src/provider-invocation.ts @@ -36,6 +36,7 @@ import { resolveProviderReadiness, resolveProviderConfig, type ProviderEntry, + type ProviderEndpointSource, } from "./provider-contract.js"; import { clampInvokeTimeout, @@ -308,7 +309,7 @@ export interface ProviderInvocationReport { contractVersion: number; providerId: string; endpoint: string; - endpointSource: "settings" | "default"; + endpointSource: ProviderEndpointSource; model: string; credentialVariable: string; credentialFromSettings: boolean; diff --git a/tests/unit/cost.test.ts b/tests/unit/cost.test.ts index 83d1cd7..9991c83 100644 --- a/tests/unit/cost.test.ts +++ b/tests/unit/cost.test.ts @@ -15,6 +15,23 @@ describe("lookupModelPrice", () => { expect(price).toEqual(MODEL_PRICES["gpt-4o"]); }); + it("matches the current MiniMax models", () => { + expect(lookupModelPrice("MiniMax-M3")).toEqual({ + known: true, + price: { + promptPerM: 0.6, + completionPerM: 2.4, + }, + }); + expect(lookupModelPrice("MiniMax-M2.7")).toEqual({ + known: true, + price: { + promptPerM: 0.3, + completionPerM: 1.2, + }, + }); + }); + it("matches a dated variant by longest known prefix", () => { const dated = lookupModelPrice("gpt-4o-2024-08-06"); expect(dated.known).toBe(true); diff --git a/tests/unit/provider-contract.test.ts b/tests/unit/provider-contract.test.ts index 5034d90..b7fb8dc 100644 --- a/tests/unit/provider-contract.test.ts +++ b/tests/unit/provider-contract.test.ts @@ -6,6 +6,7 @@ import { PROVIDER_CONTRACT_SCHEMA, PROVIDER_CONTRACT_VERSION, SUPPORTED_PROVIDER_CONTRACT_VERSIONS, + BUILTIN_PROVIDER_PRESETS, parseProviderContract, selectProviderEntry, resolveProviderConfig, @@ -139,6 +140,67 @@ describe("parseProviderContract: trust boundary and validation", () => { expect(() => parseProviderContract({ contractVersion: 1, entries: [{ id: "a", model: "" }] }), ).toThrow(/model is required/); + expect(() => + parseProviderContract({ contractVersion: 1, entries: [{ id: "a" }] }), + ).toThrow(/model is required/); + }); +}); + +describe("built-in MiniMax provider entries", () => { + it("resolves the global endpoint and current model catalog from the provider id", () => { + const contract = parseProviderContract({ + contractVersion: 1, + entries: [{ id: "minimax" }], + }); + const entry = contract.entries[0]; + expect(entry.model).toBe("MiniMax-M3"); + expect(entry.models).toEqual(["MiniMax-M3", "MiniMax-M2.7"]); + expect(entry.apiKeyEnv).toBe("MINIMAX_API_KEY"); + expect(entry.capabilities).toEqual({ vision: true, tools: true, streaming: true }); + + const config = resolveProviderConfig(entry, { + env: { MINIMAX_API_KEY: "test-key" }, + }); + expect(config.baseUrl).toBe("https://api.minimax.io/v1"); + expect(config.model).toBe("MiniMax-M3"); + }); + + it("resolves the China endpoint from the regional provider id", () => { + const contract = parseProviderContract({ + contractVersion: 1, + entries: [{ id: "minimax-cn" }], + }); + const entry = contract.entries[0]; + const config = resolveProviderConfig(entry, { + env: { MINIMAX_API_KEY: "test-key" }, + }); + expect(config.baseUrl).toBe("https://api.minimaxi.com/v1"); + expect(BUILTIN_PROVIDER_PRESETS["minimax-cn"].model).toBe("MiniMax-M3"); + }); + + it("keeps explicit entry fields above the built-in defaults", () => { + const contract = parseProviderContract({ + contractVersion: 1, + entries: [{ id: "minimax", baseUrl: "https://override.example/v1", model: "custom-model" }], + }); + const config = resolveProviderConfig(contract.entries[0], { + env: { MINIMAX_API_KEY: "test-key" }, + }); + expect(config.baseUrl).toBe("https://override.example/v1"); + expect(config.model).toBe("custom-model"); + }); + + it("reports built-in endpoint provenance without exposing its path", () => { + const settings = writeSettings({ + providers: { contractVersion: 1, entries: [{ id: "minimax" }] }, + }); + const report = collectProviderContract({ + settingsPath: settings, + env: { MINIMAX_API_KEY: "test-key" }, + }); + expect(report.endpoint).toBe("api.minimax.io"); + expect(report.endpointSource).toBe("builtin"); + expect(report.modelCatalog).toEqual(["MiniMax-M3", "MiniMax-M2.7"]); }); }); From 40374e895cf609dbb12b9e0025f177fa136798a7 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 20 Oct 2026 21:35:20 +0800 Subject: [PATCH 2/3] feat(minimax): default to M3.1 Flash and preserve reasoning history --- README.md | 15 ++- src/agent.ts | 12 +- src/cost.ts | 7 ++ src/provider-contract.ts | 10 +- src/provider.ts | 16 ++- src/session.ts | 2 + tests/unit/cost.test.ts | 6 +- tests/unit/minimax-request.test.ts | 165 +++++++++++++++++++++++++++ tests/unit/provider-contract.test.ts | 31 ++++- 9 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 tests/unit/minimax-request.test.ts diff --git a/README.md b/README.md index 5fe0ef0..3b67504 100644 --- a/README.md +++ b/README.md @@ -1598,12 +1598,17 @@ availability is reported (`credentialAvailable`) but not gated, so the contract stays inspectable even when the credential is not currently exported. Exit code: `0` on success, `2` on a contract/usage error. -MiniMax has built-in global and China entries, including -the current `MiniMax-M3` and `MiniMax-M2.7` catalog. Declare -`{ "id": "minimax" }` for the global endpoint or -`{ "id": "minimax-cn" }` for the China endpoint, then export the +MiniMax has built-in global and China entries, defaulting to +`MiniMax-M3.1-Flash` with `MiniMax-M3` as the other catalog choice. Declare +`{ "id": "minimax" }` for `https://api.minimax.io/v1` or +`{ "id": "minimax-cn" }` for `https://api.minimax.cn/v1`, then export the credential as `MINIMAX_API_KEY`. An explicit `baseUrl`, `model`, -`models`, or `apiKeyEnv` in the entry still overrides the corresponding preset. +`models`, or `apiKeyEnv` in the entry still overrides the corresponding preset, +including saved legacy model IDs. Requests use the existing Chat Completions +transport with the API's default thinking behavior. Separate streamed reasoning +is retained in assistant history for subsequent tool turns. Cost estimates use Flash's +standard input/output rates; M3 uses its upper standard tier conservatively +because run totals do not retain per-request context sizes or cache usage. ```text Provider: alt diff --git a/src/agent.ts b/src/agent.ts index 2c52c5e..6b1bd2c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -629,6 +629,7 @@ export async function runAgent( } let assistantText = ""; + let assistantReasoning = ""; let assistantToolCalls: Array<{ id: string; name: string; arguments: string }> = []; // Per-message timing (#241): the wall-clock just before the request, the @@ -649,6 +650,7 @@ export async function runAgent( let exhaustedEmpty = false; while (true) { assistantText = ""; + assistantReasoning = ""; assistantToolCalls = []; roundStart = now(); firstTokenAt = null; @@ -660,6 +662,8 @@ export async function runAgent( if (firstTokenAt === null) firstTokenAt = now(); assistantText += event.delta; sink.assistantDelta(event.delta); + } else if (event.type === "reasoning") { + assistantReasoning += event.delta; } else if (event.type === "tool_call") { assistantToolCalls.push({ id: event.id, name: event.name, arguments: event.arguments }); } else if (event.type === "usage") { @@ -725,6 +729,7 @@ export async function runAgent( !fellBack && transient !== null && assistantText.length === 0 && + assistantReasoning.length === 0 && assistantToolCalls.length === 0 ) { if (hasUsage) { @@ -891,7 +896,11 @@ export async function runAgent( if (final) { // Final answer finalText = assistantText; - const assistantMsg: SessionMessage = { role: "assistant", content: assistantText }; + const assistantMsg: SessionMessage = { + role: "assistant", + content: assistantText, + ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), + }; messages.push(assistantMsg); opts.onMessage(assistantMsg); return { @@ -914,6 +923,7 @@ export async function runAgent( const assistantMsg: SessionMessage = { role: "assistant", content: assistantText || null, + ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), tool_calls: assistantToolCalls.map((tc) => ({ id: tc.id, type: "function", diff --git a/src/cost.ts b/src/cost.ts index a58033b..4c257c4 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -23,10 +23,17 @@ export const MODEL_PRICES: Record = { "gpt-4o-mini": { promptPerM: 0.15, completionPerM: 0.6 }, "gpt-4.1": { promptPerM: 2, completionPerM: 8 }, "gpt-4.1-mini": { promptPerM: 0.4, completionPerM: 1.6 }, + "MiniMax-M3.1-Flash": { + promptPerM: 0.15, + completionPerM: 0.6, + }, + // M3 uses the upper standard tier as a conservative estimate: the run tally + // does not retain per-request context sizes or cache usage for tiered billing. "MiniMax-M3": { promptPerM: 0.6, completionPerM: 2.4, }, + // Keep pricing for explicitly configured legacy models. "MiniMax-M2.7": { promptPerM: 0.3, completionPerM: 1.2, diff --git a/src/provider-contract.ts b/src/provider-contract.ts index cbc3230..612b266 100644 --- a/src/provider-contract.ts +++ b/src/provider-contract.ts @@ -80,15 +80,15 @@ export interface ProviderPreset { export const BUILTIN_PROVIDER_PRESETS = { "minimax": { baseUrl: "https://api.minimax.io/v1", - model: "MiniMax-M3", - models: ["MiniMax-M3", "MiniMax-M2.7"], + model: "MiniMax-M3.1-Flash", + models: ["MiniMax-M3.1-Flash", "MiniMax-M3"], apiKeyEnv: "MINIMAX_API_KEY", capabilities: { vision: true, tools: true, streaming: true }, }, "minimax-cn": { - baseUrl: "https://api.minimaxi.com/v1", - model: "MiniMax-M3", - models: ["MiniMax-M3", "MiniMax-M2.7"], + baseUrl: "https://api.minimax.cn/v1", + model: "MiniMax-M3.1-Flash", + models: ["MiniMax-M3.1-Flash", "MiniMax-M3"], apiKeyEnv: "MINIMAX_API_KEY", capabilities: { vision: true, tools: true, streaming: true }, }, diff --git a/src/provider.ts b/src/provider.ts index 142e0d1..e8e0929 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -12,6 +12,11 @@ export interface StreamedText { delta: string; } +export interface StreamedReasoning { + type: "reasoning"; + delta: string; +} + export interface StreamedToolCall { type: "tool_call"; id: string; @@ -44,7 +49,7 @@ export interface StreamedRetry { delayMs: number; } -export type StreamEvent = StreamedText | StreamedToolCall | StreamedUsage | StreamedRetry; +export type StreamEvent = StreamedText | StreamedReasoning | StreamedToolCall | StreamedUsage | StreamedRetry; // A provider call as a stream of events. `streamChat` is the network // implementation; tests and the deterministic task-fixture replay (#224) supply @@ -300,6 +305,11 @@ async function* streamOnce( if (!choice) continue; const delta = choice.delta; + // Flash returns thinking separately; retain it for subsequent tool turns. + const reasoning = (delta as { reasoning_content?: unknown })?.reasoning_content; + if (typeof reasoning === "string" && reasoning) { + yield { type: "reasoning", delta: reasoning }; + } if (delta?.content) { yield { type: "text", delta: delta.content }; } @@ -350,6 +360,7 @@ export function toOpenAIMessage(msg: SessionMessage): OpenAI.ChatCompletionMessa return { role: "assistant", content: msg.content ?? null, + ...(msg.reasoning_content !== undefined ? { reasoning_content: msg.reasoning_content } : {}), tool_calls: msg.tool_calls.map((tc) => ({ id: tc.id, type: "function" as const, @@ -378,5 +389,8 @@ export function toOpenAIMessage(msg: SessionMessage): OpenAI.ChatCompletionMessa return { role: msg.role as "system" | "user" | "assistant", content: msg.content ?? "", + ...(msg.role === "assistant" && msg.reasoning_content !== undefined + ? { reasoning_content: msg.reasoning_content } + : {}), }; } diff --git a/src/session.ts b/src/session.ts index 465c908..ea7311c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,6 +9,8 @@ import type { SessionLock } from "./session-lock.js"; export interface SessionMessage { role: "system" | "user" | "assistant" | "tool"; content?: string | null; + // Separate assistant thinking, retained for MiniMax tool-call continuity. + reasoning_content?: string; tool_calls?: Array<{ id: string; type: "function"; diff --git a/tests/unit/cost.test.ts b/tests/unit/cost.test.ts index 9991c83..f63eb5b 100644 --- a/tests/unit/cost.test.ts +++ b/tests/unit/cost.test.ts @@ -15,7 +15,11 @@ describe("lookupModelPrice", () => { expect(price).toEqual(MODEL_PRICES["gpt-4o"]); }); - it("matches the current MiniMax models", () => { + it("matches current MiniMax models and retains legacy estimates", () => { + expect(lookupModelPrice("MiniMax-M3.1-Flash")).toEqual({ + known: true, + price: { promptPerM: 0.15, completionPerM: 0.6 }, + }); expect(lookupModelPrice("MiniMax-M3")).toEqual({ known: true, price: { diff --git a/tests/unit/minimax-request.test.ts b/tests/unit/minimax-request.test.ts new file mode 100644 index 0000000..0a4f018 --- /dev/null +++ b/tests/unit/minimax-request.test.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +// Use the SDK's supported web shim so only the HTTP boundary is mocked. The +// real SDK still serializes requests; no credentials or network are needed. +import "openai/shims/web"; +import { + parseProviderContract, + resolveProviderConfig, + selectProviderEntry, +} from "../../src/provider-contract.js"; +import { invokeProvider } from "../../src/provider-invocation.js"; +import { streamChat } from "../../src/provider.js"; +import { runAgent } from "../../src/agent.js"; +import { Workspace } from "../../src/workspace.js"; +import type { SessionMessage } from "../../src/session.js"; + +const fetchMock = vi.hoisted(() => { + const mock = vi.fn(); + vi.stubGlobal("fetch", mock); + return mock; +}); + +const cases = ["minimax", "minimax-cn"].flatMap((id) => [ + { id, model: undefined, expectedModel: "MiniMax-M3.1-Flash", baseUrl: undefined }, + { id, model: "MiniMax-M3", expectedModel: "MiniMax-M3", baseUrl: undefined }, + { id, model: "MiniMax-M2.7", expectedModel: "MiniMax-M2.7", baseUrl: undefined }, + { id, model: "custom-model", expectedModel: "custom-model", baseUrl: "https://custom.example/v1" }, +]); + +function response(stream: boolean): Response { + return new Response(stream + ? 'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' + : JSON.stringify({ choices: [{ message: { content: "hello" }, finish_reason: "stop" }] }), { + headers: { "content-type": stream ? "text/event-stream" : "application/json" }, + }); +} + +function capturedRequest() { + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(init.method).toBe("POST"); + return { url: String(url), body: JSON.parse(init.body as string) }; +} + +function expectedUrl(id: string, baseUrl?: string): string { + return `${baseUrl ?? (id === "minimax" ? "https://api.minimax.io/v1" : "https://api.minimax.cn/v1")}/chat/completions`; +} + +describe("MiniMax serialized requests", () => { + let workspace: string; + beforeAll(() => { workspace = fs.mkdtempSync(path.resolve(".minimax-request-")); }); + beforeEach(() => { fetchMock.mockReset(); }); + afterAll(() => { + vi.unstubAllGlobals(); + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + it("retains streamed reasoning across a real agent tool round and resumed history", async () => { + const contract = parseProviderContract({ contractVersion: 1, entries: [{ id: "minimax" }] }); + const config = resolveProviderConfig(selectProviderEntry(contract), { env: { MINIMAX_API_KEY: "test-key" } }); + const chunks = [ + { choices: [{ delta: { reasoning_content: "Inspect " } }] }, + { choices: [{ delta: { reasoning_content: "the file.", tool_calls: [{ index: 0, id: "call-read", type: "function", function: { name: "read", arguments: '{"path":"sample.txt"}' } }] }, finish_reason: "tool_calls" }] }, + ]; + fs.writeFileSync(path.join(workspace, "sample.txt"), "example"); + fetchMock.mockResolvedValueOnce(new Response( + chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }, + )).mockResolvedValueOnce(response(true)); + const persisted: SessionMessage[] = []; + const assistantDelta = vi.fn(); + const result = await runAgent("Read sample.txt", [], { + config, + workspace: new Workspace(workspace), + approvalMode: "yolo", + sessionId: "minimax-test", + onMessage: (message) => persisted.push(message), + sink: { + assistantDelta, + assistantTurn: () => {}, toolStart: () => {}, toolResult: () => {}, + providerError: () => {}, usage: () => {}, retry: () => {}, + }, + }); + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + const assistant = persisted.find((message) => message.tool_calls); + expect(assistant?.reasoning_content).toBe("Inspect the file."); + const followup = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(followup.messages).toContainEqual(assistant); + expect(followup.messages).toContainEqual(expect.objectContaining({ role: "tool", tool_call_id: "call-read" })); + expect(assistantDelta.mock.calls).toEqual([["hello"]]); + + // JSON round-tripping models saved-session reload; the wire field survives. + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(response(true)); + for await (const _event of streamChat(config, JSON.parse(JSON.stringify(persisted)))) { /* drain */ } + expect(capturedRequest().body.messages).toContainEqual(assistant); + }); + + it.each(cases)("invokes $id with $expectedModel through the default runner", async ({ id, model, expectedModel, baseUrl }) => { + const entry = { id, ...(model ? { model } : {}), ...(baseUrl ? { baseUrl } : {}) }; + const settingsPath = path.join(workspace, "settings.json"); + fs.writeFileSync(settingsPath, JSON.stringify({ providers: { contractVersion: 1, entries: [entry] } })); + fetchMock.mockResolvedValueOnce(response(false)); + + const report = await invokeProvider({ + settingsPath, + env: { MINIMAX_API_KEY: "test-key" }, + workspace, + approvalMode: "yolo", + prompt: "hello", + }); + + expect(report.outcome).toBe("called"); + expect(report.model).toBe(expectedModel); + const request = capturedRequest(); + expect(request.url).toBe(expectedUrl(id, baseUrl)); + // Exact equality also guards against sending unsupported thinking, effort, + // service tier or reasoning-split overrides with the default request. + expect(request.body).toEqual({ + model: expectedModel, + messages: [{ role: "user", content: "hello" }], + max_tokens: 256, + stream: false, + }); + }); + + it.each(cases)("streams $id with $expectedModel and preserves tools and images", async ({ id, model, expectedModel, baseUrl }) => { + const contract = parseProviderContract({ + contractVersion: 1, + entries: [{ id, ...(model ? { model } : {}), ...(baseUrl ? { baseUrl } : {}) }], + }); + const config = resolveProviderConfig(selectProviderEntry(contract), { env: { MINIMAX_API_KEY: "test-key" } }); + const toolCall = { id: "call-1", type: "function" as const, function: { name: "inspect", arguments: "{}" } }; + const image = "data:image/png;base64,aGVsbG8="; + const messages: SessionMessage[] = [ + { role: "system", content: "Be helpful" }, + { role: "assistant", content: null, tool_calls: [toolCall] }, + { role: "tool", content: "done", tool_call_id: "call-1" }, + { role: "user", content: "Describe", images: [{ name: "test.png", mediaType: "image/png", bytes: 5, dataUrl: image }] }, + ]; + const tools = [{ type: "function" as const, function: { name: "inspect", description: "Inspect", parameters: { type: "object" } } }]; + fetchMock.mockResolvedValueOnce(response(true)); + + const events = []; + for await (const event of streamChat(config, messages, { tools })) events.push(event); + + expect(events).toEqual([{ type: "text", delta: "hello" }]); + const request = capturedRequest(); + expect(request.url).toBe(expectedUrl(id, baseUrl)); + expect(request.body).toEqual({ + model: expectedModel, + messages: [ + { role: "system", content: "Be helpful" }, + { role: "assistant", content: null, tool_calls: [toolCall] }, + { role: "tool", content: "done", tool_call_id: "call-1" }, + { role: "user", content: [{ type: "text", text: "Describe" }, { type: "image_url", image_url: { url: image } }] }, + ], + stream: true, + stream_options: { include_usage: true }, + tools, + }); + }); +}); diff --git a/tests/unit/provider-contract.test.ts b/tests/unit/provider-contract.test.ts index b7fb8dc..cc166a1 100644 --- a/tests/unit/provider-contract.test.ts +++ b/tests/unit/provider-contract.test.ts @@ -153,8 +153,8 @@ describe("built-in MiniMax provider entries", () => { entries: [{ id: "minimax" }], }); const entry = contract.entries[0]; - expect(entry.model).toBe("MiniMax-M3"); - expect(entry.models).toEqual(["MiniMax-M3", "MiniMax-M2.7"]); + expect(entry.model).toBe("MiniMax-M3.1-Flash"); + expect(entry.models).toEqual(["MiniMax-M3.1-Flash", "MiniMax-M3"]); expect(entry.apiKeyEnv).toBe("MINIMAX_API_KEY"); expect(entry.capabilities).toEqual({ vision: true, tools: true, streaming: true }); @@ -162,7 +162,7 @@ describe("built-in MiniMax provider entries", () => { env: { MINIMAX_API_KEY: "test-key" }, }); expect(config.baseUrl).toBe("https://api.minimax.io/v1"); - expect(config.model).toBe("MiniMax-M3"); + expect(config.model).toBe("MiniMax-M3.1-Flash"); }); it("resolves the China endpoint from the regional provider id", () => { @@ -174,8 +174,8 @@ describe("built-in MiniMax provider entries", () => { const config = resolveProviderConfig(entry, { env: { MINIMAX_API_KEY: "test-key" }, }); - expect(config.baseUrl).toBe("https://api.minimaxi.com/v1"); - expect(BUILTIN_PROVIDER_PRESETS["minimax-cn"].model).toBe("MiniMax-M3"); + expect(config.baseUrl).toBe("https://api.minimax.cn/v1"); + expect(BUILTIN_PROVIDER_PRESETS["minimax-cn"].model).toBe("MiniMax-M3.1-Flash"); }); it("keeps explicit entry fields above the built-in defaults", () => { @@ -190,6 +190,25 @@ describe("built-in MiniMax provider entries", () => { expect(config.model).toBe("custom-model"); }); + it.each(["minimax", "minimax-cn"])("preserves saved models and custom catalogs for %s", (id) => { + const contract = parseProviderContract({ + contractVersion: 1, + entries: [{ + id, + model: "MiniMax-M2.7", + models: ["custom-model", "MiniMax-M2.7"], + apiKeyEnv: "CUSTOM_KEY", + capabilities: { vision: false }, + }], + }); + const entry = contract.entries[0]; + expect(entry.model).toBe("MiniMax-M2.7"); + expect(entry.models).toEqual(["custom-model", "MiniMax-M2.7"]); + expect(entry.apiKeyEnv).toBe("CUSTOM_KEY"); + expect(entry.capabilities).toEqual({ vision: false, tools: true, streaming: true }); + expect(resolveProviderConfig(entry, { env: { CUSTOM_KEY: "test-key" } }).model).toBe("MiniMax-M2.7"); + }); + it("reports built-in endpoint provenance without exposing its path", () => { const settings = writeSettings({ providers: { contractVersion: 1, entries: [{ id: "minimax" }] }, @@ -200,7 +219,7 @@ describe("built-in MiniMax provider entries", () => { }); expect(report.endpoint).toBe("api.minimax.io"); expect(report.endpointSource).toBe("builtin"); - expect(report.modelCatalog).toEqual(["MiniMax-M3", "MiniMax-M2.7"]); + expect(report.modelCatalog).toEqual(["MiniMax-M3.1-Flash", "MiniMax-M3"]); }); }); From c4fe18d568502cede38d828fef0d47a3f75b5544 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:31:03 +0800 Subject: [PATCH 3/3] fix(minimax): use documented China endpoint --- README.md | 2 +- src/provider-contract.ts | 2 +- tests/unit/minimax-request.test.ts | 2 +- tests/unit/provider-contract.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b67504..092432b 100644 --- a/README.md +++ b/README.md @@ -1601,7 +1601,7 @@ stays inspectable even when the credential is not currently exported. Exit code: MiniMax has built-in global and China entries, defaulting to `MiniMax-M3.1-Flash` with `MiniMax-M3` as the other catalog choice. Declare `{ "id": "minimax" }` for `https://api.minimax.io/v1` or -`{ "id": "minimax-cn" }` for `https://api.minimax.cn/v1`, then export the +`{ "id": "minimax-cn" }` for `https://api.minimaxi.com/v1`, then export the credential as `MINIMAX_API_KEY`. An explicit `baseUrl`, `model`, `models`, or `apiKeyEnv` in the entry still overrides the corresponding preset, including saved legacy model IDs. Requests use the existing Chat Completions diff --git a/src/provider-contract.ts b/src/provider-contract.ts index 612b266..22d95cb 100644 --- a/src/provider-contract.ts +++ b/src/provider-contract.ts @@ -86,7 +86,7 @@ export const BUILTIN_PROVIDER_PRESETS = { capabilities: { vision: true, tools: true, streaming: true }, }, "minimax-cn": { - baseUrl: "https://api.minimax.cn/v1", + baseUrl: "https://api.minimaxi.com/v1", model: "MiniMax-M3.1-Flash", models: ["MiniMax-M3.1-Flash", "MiniMax-M3"], apiKeyEnv: "MINIMAX_API_KEY", diff --git a/tests/unit/minimax-request.test.ts b/tests/unit/minimax-request.test.ts index 0a4f018..9270137 100644 --- a/tests/unit/minimax-request.test.ts +++ b/tests/unit/minimax-request.test.ts @@ -44,7 +44,7 @@ function capturedRequest() { } function expectedUrl(id: string, baseUrl?: string): string { - return `${baseUrl ?? (id === "minimax" ? "https://api.minimax.io/v1" : "https://api.minimax.cn/v1")}/chat/completions`; + return `${baseUrl ?? (id === "minimax" ? "https://api.minimax.io/v1" : "https://api.minimaxi.com/v1")}/chat/completions`; } describe("MiniMax serialized requests", () => { diff --git a/tests/unit/provider-contract.test.ts b/tests/unit/provider-contract.test.ts index cc166a1..147d466 100644 --- a/tests/unit/provider-contract.test.ts +++ b/tests/unit/provider-contract.test.ts @@ -174,7 +174,7 @@ describe("built-in MiniMax provider entries", () => { const config = resolveProviderConfig(entry, { env: { MINIMAX_API_KEY: "test-key" }, }); - expect(config.baseUrl).toBe("https://api.minimax.cn/v1"); + expect(config.baseUrl).toBe("https://api.minimaxi.com/v1"); expect(BUILTIN_PROVIDER_PRESETS["minimax-cn"].model).toBe("MiniMax-M3.1-Flash"); });