diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md index b09247dabf..03a837406e 100644 --- a/docs-site/src/content/docs/guides/minimax.md +++ b/docs-site/src/content/docs/guides/minimax.md @@ -12,6 +12,11 @@ the protocol boundary it actually exposes: ## MiniMax Code +In the OpenCodex Models page, both MiniMax Coding Plan regions advertise image input for +`MiniMax-M3` and `MiniMax-M3.1-Flash-Preview`. A Combo's **Image / multimodal** switch is +available only when every selected model supports image input. The switch controls image +attachments; it does not enable video uploads. + Install and sign in to MiniMax Code using MiniMax's instructions first. Then start OpenCodex and connect the reversible file integration: diff --git a/docs-site/src/content/docs/zh-cn/guides/minimax.md b/docs-site/src/content/docs/zh-cn/guides/minimax.md index 48e633e63e..4ab74ee3a0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/minimax.md +++ b/docs-site/src/content/docs/zh-cn/guides/minimax.md @@ -10,6 +10,10 @@ MiniMax 发布两种不同的命令行产品。OpenCodex 在它们实际提供 ## MiniMax Code +在 OpenCodex 的模型管理页面,MiniMax Coding Plan 两个区域的 `MiniMax-M3` 和 +`MiniMax-M3.1-Flash-Preview` 均声明支持图片输入。只有所有选定模型均支持图片输入时, +Combo 的“图片 / 多模态”开关才可用。该开关控制图片附件,不提供视频上传能力。 + 先按照 MiniMax 的说明安装并登录 MiniMax Code,然后启动 OpenCodex 并连接可逆的文件集成: ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dce0e7d90d..b3b675a52e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,6 +11,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 21e0b8593f..b7870b9ade 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -35,6 +35,7 @@ import { ZAI_GLM_5X_REASONING_EFFORTS, MINIMAX_MODELS, MINIMAX_MODEL_CONTEXT_WINDOWS, + MINIMAX_MODEL_INPUT_MODALITIES, MINIMAX_M3_REASONING_EFFORTS, MINIMAX_M3_REASONING_EFFORT_MAP, MINIMAX_M31_FLASH_PREVIEW, @@ -1024,6 +1025,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, @@ -1049,6 +1051,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 9b480828a4..b5004e81e5 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -172,6 +172,11 @@ export const MINIMAX_REASONING_SPLIT_MODELS = MINIMAX_MODELS_BEFORE_M31; export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH_PREVIEW ? 1_000_000 : 204_800]), ); +/** MiniMax's OpenAI-compatible M3 endpoints accept image_url; keep video off Codex's input enum. */ +export const MINIMAX_MODEL_INPUT_MODALITIES: Record = { + "MiniMax-M3": ["text", "image"], + [MINIMAX_M31_FLASH_PREVIEW]: ["text", "image"], +}; export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** Identity efforts on the wire; no map, so none omits the field instead of disabling thinking. */ export const MINIMAX_M31_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 529c10f51f..1118cc262e 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -354,10 +354,9 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. -Roster additions share the blind spot when the vendor's `/models` omits the new id (MiniMax-M3.1-Flash-Preview): -`src/providers/stale-model-roster-migration.ts` replaces a saved roster only while it is byte-for-byte the previous -seed, filling the added id's window and default effort only inside records the row already has, in the same startup -pass; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) keeps it in the live catalog. +When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. + +Both MiniMax Coding Plan presets declare `text` and `image` for M3 and M3.1 in `src/providers/registry/model-seeds.ts`; registry enrichment fills missing saved declarations, so id-only live rows enable the Combo image switch when every target supports images. Explicit overrides still win; video is outside Codex's catalog input enum. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 66db2637f2..a7c988f6b4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/tests/providers/minimax-combo-image-input.test.ts b/tests/providers/minimax-combo-image-input.test.ts new file mode 100644 index 0000000000..9949fbd37c --- /dev/null +++ b/tests/providers/minimax-combo-image-input.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { comboImagesSupported } from "../../gui/src/combo-capabilities"; +import { applyProviderConfigHints, deriveComboCatalogModel, gatherRoutedModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; +import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; +import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; + +const M3 = "MiniMax-M3"; +const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +const REGIONS = ["minimax", "minimax-cn"] as const; +const originalFetch = globalThis.fetch; + +function seeded(region: typeof REGIONS[number]): OcxProviderConfig { + const entry = getProviderRegistryEntry(region); + if (!entry) throw new Error(`missing ${region} registry entry`); + return { ...providerConfigSeed(entry), apiKey: "test-key" }; +} + +function config(region: typeof REGIONS[number], provider = seeded(region)): OcxConfig { + return { port: 10100, defaultProvider: region, providers: { [region]: provider } } as OcxConfig; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); +}); + +describe("MiniMax Coding Plan Combo image input", () => { + test("both presets seed M3 image input and enrich missing saved declarations without replacing overrides", () => { + for (const region of REGIONS) { + const fresh = seeded(region); + expect(fresh.modelInputModalities?.[M3]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.[PREVIEW]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.["MiniMax-M2.7"]).toBeUndefined(); + + const saved: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: fresh.baseUrl, + modelInputModalities: { "MiniMax-M2.7": ["text"] }, + modelCapabilities: { [M3]: { inputModalities: ["text"] } }, + }; + enrichProviderFromRegistry(region, saved); + expect(saved.modelInputModalities).toMatchObject({ + [M3]: ["text", "image"], [PREVIEW]: ["text", "image"], "MiniMax-M2.7": ["text"], + }); + expect(saved.modelCapabilities?.[M3]?.inputModalities).toEqual(["text"]); + expect(isModelVisionSidecarConsumer(saved, M3)).toBe(true); + expect(applyProviderConfigHints(region, saved, { provider: region, id: M3 }).inputModalities) + .toEqual(["text", "image"]); // explicit text-only uses the existing vision sidecar + expect(isModelVisionSidecarConsumer(fresh, M3)).toBe(false); + } + }); + + test("id-only discovery and a warm cache yield management rows accepted by the Combo picker", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (!String(input).includes("/models")) throw new Error("unexpected request"); + return Response.json({ data: [{ id: M3 }] }); + }) as typeof fetch; + + for (const region of REGIONS) { + const live = config(region); + const first = await gatherRoutedModels(live); + const cached = await gatherRoutedModels(live); + for (const models of [first, cached]) { + const m3 = models.find(model => model.provider === region && model.id === M3); + expect(m3?.inputModalities).toEqual(["text", "image"]); + const rows = await listManagementModelRows(live, { models }); + const row = rows.find(model => model.provider === region && model.id === M3); + expect(row?.inputModalities).toEqual(["text", "image"]); + expect(comboImagesSupported([{ provider: region, model: M3 }], rows)).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "vision" }], + [...rows, { provider: "other", id: "vision", inputModalities: ["text", "image"] }], + )).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "blind" }], + [...rows, { provider: "other", id: "blind", inputModalities: ["text"] }], + )).toBe(false); + } + } + }); + + test("static fallback and Combo catalog retain image unless the operator disables it", async () => { + for (const region of REGIONS) { + const provider = seeded(region); + provider.liveModels = false; + const models = await gatherRoutedModels(config(region, provider)); + const member = models.find(model => model.provider === region && model.id === M3); + expect(member?.inputModalities).toEqual(["text", "image"]); + const combo = (imageInput: "auto" | "disabled") => ({ + targets: [{ provider: region, model: M3 }], imageInput, defaultEffort: "medium", + }) as never; + expect(deriveComboCatalogModel("minimax-image", combo("auto"), [member as CatalogModel])?.inputModalities) + .toContain("image"); + expect(deriveComboCatalogModel("minimax-image", combo("disabled"), [member as CatalogModel])?.inputModalities) + .toEqual(["text"]); + } + }); +});