diff --git a/.env.example b/.env.example index 8bc82e75..472aadd6 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,14 @@ # ── Secrets (not merged from config.json, env only) ──────────────────── # OPENAI_API_KEY=sk-... +# XAI_API_KEY=xai-... +# GEMINI_API_KEY=AI... +# ATLASCLOUD_API_KEY=apikey-... +# MINIMAX_API_KEY=... # MiniMax image generation (image-01 / image-01-live) + +# ── MiniMax image provider ───────────────────────────────────────────── +# IMA2_MINIMAX_REGION=global_en # global_en (default) | cn_zh +# IMA2_MINIMAX_GLOBAL_BASE_URL=https://api.minimax.io/v1 +# IMA2_MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1 +# IMA2_MINIMAX_IMAGE_MODEL_DEFAULT=image-01 +# IMA2_MINIMAX_GENERATION_TIMEOUT_MS=120000 diff --git a/bin/commands/edit.ts b/bin/commands/edit.ts index 2f340993..5ddb5d56 100644 --- a/bin/commands/edit.ts +++ b/bin/commands/edit.ts @@ -10,8 +10,8 @@ import { join } from "node:path"; import { errInfo } from "../../lib/errInfo.js"; const VALID_MODES = new Set(["auto", "direct"]); const VALID_MODERATION = new Set(["auto", "low"]); -const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"]); -const KNOWN_IMAGE_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark", "grok-imagine-image", "grok-imagine-image-quality", "nano-banana-2", "nano-banana-pro"]); +const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"]); +const KNOWN_IMAGE_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark", "grok-imagine-image", "grok-imagine-image-quality", "nano-banana-2", "nano-banana-pro", "image-01", "image-01-live"]); const SPEC = { flags: { @@ -45,9 +45,9 @@ const HELP = ` -s, --size -o, --out --json - --model Default: gpt-5.6-luna + --model Default: gpt-5.6-luna Aliases: luna, sol, terra, spark - --provider + --provider Provider (oauth = GPT OAuth; grok = xAI Grok; agy/gemini-api = Gemini) --mode Prompt handling mode. Default: auto --moderation Default: low @@ -67,11 +67,11 @@ export default async function editCmd(argv: string[]) { if (!VALID_MODES.has(String(args.mode))) die(2, "--mode must be one of: auto, direct"); if (!VALID_MODERATION.has(String(args.moderation))) die(2, "--moderation must be one of: auto, low"); if (args.provider && !VALID_PROVIDERS.has(String(args.provider))) { - die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud"); + die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud, minimax"); } const model = canonicalizeImageModel(args.model); if (model && !KNOWN_IMAGE_MODELS.has(model)) { - die(2, "--model must be one of: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.3-codex-spark, grok-imagine-image, grok-imagine-image-quality, nano-banana-2, nano-banana-pro"); + die(2, "--model must be one of: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.3-codex-spark, grok-imagine-image, grok-imagine-image-quality, nano-banana-2, nano-banana-pro, image-01, image-01-live"); } const VALID_REASONING = new Set(["none", "low", "medium", "high", "xhigh", "max"]); if (args["reasoning-effort"] && !VALID_REASONING.has(String(args["reasoning-effort"]))) { diff --git a/bin/commands/gen.ts b/bin/commands/gen.ts index 4b26574d..28faad47 100644 --- a/bin/commands/gen.ts +++ b/bin/commands/gen.ts @@ -64,7 +64,7 @@ const HELP = ` --server Override server URL --model Bare IDs must be unique across lanes Core aliases: luna, sol, terra, spark - --provider + --provider 'auto' was removed; choose a lane explicitly --mode Core lanes only. Default: auto --moderation Core lanes only. Default: low diff --git a/bin/commands/multimode.ts b/bin/commands/multimode.ts index f9c62773..17691dda 100644 --- a/bin/commands/multimode.ts +++ b/bin/commands/multimode.ts @@ -50,7 +50,7 @@ const HELP = ` --json --model Default: gpt-5.6-luna Aliases: luna, sol, terra, spark - --provider + --provider Provider (oauth = GPT OAuth; grok = xAI Grok; agy/gemini-api = Gemini) --mode Prompt handling mode. Default: auto --ref Attach reference image (repeatable, max ${MAX_REFERENCE_COUNT}) @@ -72,11 +72,11 @@ export default async function multimodeCmd(argv: string[]) { const prompt = args.positional.join(" "); if (!prompt) die(2, "prompt required"); - const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"]); + const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"]); const VALID_MODES = new Set(["auto", "direct"]); const VALID_REASONING = new Set(["none", "low", "medium", "high", "xhigh", "max"]); if (args.provider && !VALID_PROVIDERS.has(String(args.provider))) { - die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud"); + die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud, minimax"); } if (!VALID_MODES.has(String(args.mode))) die(2, "--mode must be one of: auto, direct"); if (args["reasoning-effort"] && !VALID_REASONING.has(String(args["reasoning-effort"]))) { diff --git a/bin/commands/node.ts b/bin/commands/node.ts index 30d083a4..49ffc8aa 100644 --- a/bin/commands/node.ts +++ b/bin/commands/node.ts @@ -12,11 +12,11 @@ const HELP = ` ima2 node [options] Subcommands: - generate [--parent ] [--ref ...] [--provider ] [--no-stream] [...gen-style flags] + generate [--parent ] [--ref ...] [--provider ] [--no-stream] [...gen-style flags] show [--json] Generate options: - --provider Provider for this request + --provider Provider for this request `; const GEN_FLAGS = { @@ -57,10 +57,10 @@ async function generateSub(argv: string[]) { const prompt = args.positional.join(" "); if (!prompt) die(2, "prompt required"); const refs = (Array.isArray(args.ref) ? args.ref : []) as string[]; - const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"]); + const VALID_PROVIDERS = new Set(["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"]); const VALID_REASONING = new Set(["none", "low", "medium", "high", "xhigh", "max"]); if (args.provider && !VALID_PROVIDERS.has(String(args.provider))) { - die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud"); + die(2, "--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud, minimax"); } if (args["reasoning-effort"] && !VALID_REASONING.has(String(args["reasoning-effort"]))) { die(2, "--reasoning-effort must be one of: none, low, medium, high, xhigh, max"); diff --git a/bin/lib/modelResolver.ts b/bin/lib/modelResolver.ts index 5060f2df..2e31f476 100644 --- a/bin/lib/modelResolver.ts +++ b/bin/lib/modelResolver.ts @@ -1,6 +1,6 @@ import { canonicalizeImageModel } from "./model-aliases.js"; -export type Lane = "oauth" | "api" | "grok" | "grok-api" | "agy" | "gemini-api" | "atlascloud" | "runway" | "higgsfield"; +export type Lane = "oauth" | "api" | "grok" | "grok-api" | "agy" | "gemini-api" | "atlascloud" | "minimax" | "runway" | "higgsfield"; export type LaneStatus = "ready" | "locked" | "disconnected" | "key-missing"; export interface ModelEntry { @@ -23,7 +23,7 @@ export type ResolveResult = | { ok: false; code: string; message: string; extra?: Record }; const LANES: readonly Lane[] = [ - "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "runway", "higgsfield", + "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax", "runway", "higgsfield", ]; function failure(code: string, message: string, extra?: Record): ResolveResult { diff --git a/config.js b/config.js index 8bfde244..4d7a2ded 100644 --- a/config.js +++ b/config.js @@ -203,6 +203,16 @@ export const config = { videoTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_TIMEOUT_MS, fileCfg.grokProvider?.videoTimeoutMs, 900_000), videoDownloadTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_DOWNLOAD_TIMEOUT_MS, fileCfg.grokProvider?.videoDownloadTimeoutMs, 120_000), }, + // Direct MiniMax image-generation provider (text-to-image / image-to-image). + // Region selects the global (.io) or China (.minimaxi.com) OpenAI-compatible + // base URL; the regional fields are shared with the MiniMax text endpoints. + minimaxProvider: { + defaultImageModel: pickStr(env.IMA2_MINIMAX_IMAGE_MODEL_DEFAULT, fileCfg.minimaxProvider?.defaultImageModel, "image-01"), + region: pickStr(env.IMA2_MINIMAX_REGION, fileCfg.minimaxProvider?.region, "global_en"), + globalBaseUrl: pickStr(env.IMA2_MINIMAX_GLOBAL_BASE_URL, fileCfg.minimaxProvider?.globalBaseUrl, "https://api.minimax.io/v1"), + cnBaseUrl: pickStr(env.IMA2_MINIMAX_CN_BASE_URL, fileCfg.minimaxProvider?.cnBaseUrl, "https://api.minimaxi.com/v1"), + generationTimeoutMs: pickInt(env.IMA2_MINIMAX_GENERATION_TIMEOUT_MS, fileCfg.minimaxProvider?.generationTimeoutMs, 120_000), + }, log: { level: pickStr(env.IMA2_LOG_LEVEL, fileCfg.log?.level, defaultLogLevelForEnv(env)), pretty: env.NODE_ENV !== "production", diff --git a/config.ts b/config.ts index 88a9cd0a..74d26f9b 100644 --- a/config.ts +++ b/config.ts @@ -321,6 +321,16 @@ export const config = { videoTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_TIMEOUT_MS, fileCfg.grokProvider?.videoTimeoutMs, 900_000), videoDownloadTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_DOWNLOAD_TIMEOUT_MS, fileCfg.grokProvider?.videoDownloadTimeoutMs, 120_000), }, + // Direct MiniMax image-generation provider (text-to-image / image-to-image). + // Region selects the global (.io) or China (.minimaxi.com) OpenAI-compatible + // base URL; the regional fields are shared with the MiniMax text endpoints. + minimaxProvider: { + defaultImageModel: pickStr(env.IMA2_MINIMAX_IMAGE_MODEL_DEFAULT, fileCfg.minimaxProvider?.defaultImageModel, "image-01"), + region: pickStr(env.IMA2_MINIMAX_REGION, fileCfg.minimaxProvider?.region, "global_en"), + globalBaseUrl: pickStr(env.IMA2_MINIMAX_GLOBAL_BASE_URL, fileCfg.minimaxProvider?.globalBaseUrl, "https://api.minimax.io/v1"), + cnBaseUrl: pickStr(env.IMA2_MINIMAX_CN_BASE_URL, fileCfg.minimaxProvider?.cnBaseUrl, "https://api.minimaxi.com/v1"), + generationTimeoutMs: pickInt(env.IMA2_MINIMAX_GENERATION_TIMEOUT_MS, fileCfg.minimaxProvider?.generationTimeoutMs, 120_000), + }, log: { level: pickStr(env.IMA2_LOG_LEVEL, fileCfg.log?.level, defaultLogLevelForEnv(env)), pretty: env.NODE_ENV !== "production", diff --git a/docs/CLI.md b/docs/CLI.md index 8af00c86..a389cec1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -73,10 +73,10 @@ Since 3.0.0, `ima2 gen` and generate-mode `ima2 video` are **fail-closed**: they target through the lane catalog (`GET /api/models`) and exit 2 with `NO_DEFAULT_MODEL` when no `--model /`, `--provider `, or persisted `ima2 defaults set image|video` target applies. Their `--provider` accepts explicit lanes only -(`oauth|api|grok|grok-api|agy|gemini-api|atlascloud|runway|higgsfield`); `--provider auto` exits 2 with +(`oauth|api|grok|grok-api|agy|gemini-api|atlascloud|minimax|runway|higgsfield`); `--provider auto` exits 2 with `PROVIDER_AUTO_REMOVED`. Inspect lanes and models with `ima2 models [--kind image|video] [--lane ] [--json]`. -`edit`, `multimode`, and `node generate` keep the legacy surface for now: `--provider `, `--reasoning-effort {none\|low\|medium\|high\|xhigh\|max}`, `--web-search` / `--no-web-search`, `--model`, `--mode`, `--moderation`, `--ref ` (repeatable, up to 5 where supported), `-q low|medium|high`, `-n `, `-o `. +`edit`, `multimode`, and `node generate` keep the legacy surface for now: `--provider `, `--reasoning-effort {none\|low\|medium\|high\|xhigh\|max}`, `--web-search` / `--no-web-search`, `--model`, `--mode`, `--moderation`, `--ref ` (repeatable, up to 5 where supported), `-q low|medium|high`, `-n `, `-o `. Provider override semantics: @@ -86,6 +86,7 @@ Provider override semantics: - `agy` spawns the Antigravity CLI to generate via Google Gemini (`nano-banana-2`). Fixed 1024×1024 JPEG output, max 3 refs. No web search, quality, size, or mask controls. If `agy` is not on the server process PATH, ima2 also checks common user-local installs such as `~/.local/bin/agy`; set `IMA2_AGY_BIN=/absolute/path/to/agy` to force a specific binary. - `gemini-api` calls the Google Generative Language API directly. Models: `nano-banana-2` (Gemini 3.1 Flash Image) and `nano-banana-pro` (Gemini 3 Pro Image). Use `--model nano-banana-2` or `--model nano-banana-pro` to select. Supports `--size` for aspect ratio and resolution (512px–4K) on the direct API path; Vertex AI ignores aspect/size. Requires `GEMINI_API_KEY` or a Vertex AI service account (`VERTEX_SERVICE_ACCOUNT_JSON`). Switching from `agy` or `gemini-api` provider auto-selects the corresponding Gemini model; switching away resets to the GPT default. - `atlascloud` calls Atlas Cloud's Media API directly. Models: `openai/gpt-image-2/text-to-image` for text-to-image and `openai/gpt-image-2/edit` when references are attached. Requires `ATLASCLOUD_API_KEY`; web search, reasoning, mask, and video controls are ignored. +- `minimax` calls the MiniMax image-generation API directly at `POST /v1/image_generation`. Models: `image-01` for text-to-image and `image-01-live` when a reference image is attached (mapped to the `subject_reference` field). Region selects the global (`https://api.minimax.io/v1`, default) or China (`https://api.minimaxi.com/v1`, `IMA2_MINIMAX_REGION=cn_zh`) base URL. `--size` maps to the closest supported `aspect_ratio`; responses are returned as URLs or base64. Requires `MINIMAX_API_KEY`; web search, reasoning, mask, and video controls are ignored, and image-to-image supports at most one subject reference. - `runway` / `higgsfield` (gen/video only) route through the MCP async pipeline (`POST /api/mcp/generate` + SSE wait). Runway requires an MCP connection; Higgsfield stays catalog-only (`locked`) until a paid plan. MCP lanes accept `-n 1` only, gallery filenames for `--ref`, and reject core-only flags with `FLAG_NOT_SUPPORTED`. - `auto` preserves route default behavior and currently resolves to GPT OAuth unless server routing changes (edit/multimode/node only; removed from gen/video in 3.0.0). @@ -135,7 +136,7 @@ mockup`. For dense or critical text, keep the text large and explicit. Exact placement, small text, and pixel-perfect typography can still need iteration or post-editing. -Multimode-specific flags include `--max-images <1..24>` by default (configurable through `IMA2_MAX_GENERATED_IMAGES`), `--ref ` (repeatable, max 5), `--mode `, `--provider `, and `--show-partial`. `ima2 edit --mask` remains intentionally deferred to #31 because current mask plumbing is guided edit rather than guaranteed true masked/inpaint semantics. +Multimode-specific flags include `--max-images <1..24>` by default (configurable through `IMA2_MAX_GENERATED_IMAGES`), `--ref ` (repeatable, max 5), `--mode `, `--provider `, and `--show-partial`. `ima2 edit --mask` remains intentionally deferred to #31 because current mask plumbing is guided edit rather than guaranteed true masked/inpaint semantics. ## Video diff --git a/docs/migration/runtime-test-inventory.md b/docs/migration/runtime-test-inventory.md index 5926f180..a6b6a6fe 100644 --- a/docs/migration/runtime-test-inventory.md +++ b/docs/migration/runtime-test-inventory.md @@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`). _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._ -Total: 336 (runtime: 142, contract: 194) +Total: 340 (runtime: 144, contract: 196) ## Runtime-importing tests - `tests/agent-mode-auto-planner-contract.test.ts` @@ -94,6 +94,8 @@ Total: 336 (runtime: 142, contract: 194) - `tests/mcp-temp-references.test.ts` - `tests/mcp-token-store.test.ts` - `tests/mcp-upscale-params.test.ts` +- `tests/minimax-key-validation-route.test.ts` +- `tests/minimax-provider-contract.test.ts` - `tests/model-default-projection-contract.test.ts` - `tests/models-endpoint-contract.test.ts` - `tests/node-parent-source-contract.test.ts` @@ -271,6 +273,7 @@ Total: 336 (runtime: 142, contract: 194) - `tests/mcp-schema-spike-contract.test.ts` - `tests/mcp-selection-helpers.test.ts` - `tests/mcp-settings-states-contract.test.ts` +- `tests/minimax-ui-registration-contract.test.ts` - `tests/mobile-compose-sheet-accessibility-contract.test.js` - `tests/mobile-composer-tray-contract.test.js` - `tests/mobile-generate-entry-contract.test.js` @@ -302,6 +305,7 @@ Total: 336 (runtime: 142, contract: 194) - `tests/oauth-proxy-edit-mask-contract.test.js` - `tests/package-smoke.test.js` - `tests/png-info-contract.test.js` +- `tests/portal-dropdown-scroll-dismiss-contract.test.ts` - `tests/prompt-curated-search-contract.test.js` - `tests/prompt-discovery-ui-contract.test.js` - `tests/prompt-import-dialog-ui-contract.test.js` diff --git a/lib/agentImageVideoGen.ts b/lib/agentImageVideoGen.ts index b72ecbed..eda2088b 100644 --- a/lib/agentImageVideoGen.ts +++ b/lib/agentImageVideoGen.ts @@ -13,6 +13,7 @@ import { generateViaResponses } from "./responsesImageAdapter.js"; import { generateViaGrok, type GrokReferenceImage } from "./grokImageAdapter.js"; import { generateViaAgy } from "./agyImageAdapter.js"; import { generateViaAtlasCloud } from "./atlasCloudImageAdapter.js"; +import { generateViaMinimax } from "./minimaxImageAdapter.js"; import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js"; import { generateVideoViaGrok, type GrokVideoGenerateResult } from "./grokVideoAdapter.js"; import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_BASE } from "./imageModels.js"; @@ -106,6 +107,14 @@ async function generateAgentImage( signal: options.signal ?? undefined, references: await loadAgentCurrentImageReferences(ctx, sessionId, options.sourceImagePolicy ?? "none"), }) + : activeProvider === "minimax" + ? await generateViaMinimax(`${manifest}\n\nUser request:\n${prompt}`, ctx, { + model: effectiveModel, + size: providerOptions.size, + requestId, + signal: options.signal ?? undefined, + references: await loadAgentCurrentImageReferences(ctx, sessionId, options.sourceImagePolicy ?? "none"), + }) : activeProvider === "grok" ? await generateViaGrok(`${manifest}\n\nUser request:\n${prompt}`, ctx, { model: effectiveModel, @@ -132,7 +141,7 @@ async function generateAgentImage( signal: options.signal, }, ); - const format = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "atlascloud" + const format = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(("mime" in response ? response.mime : undefined) || detectImageMimeFromB64(response.b64) || "image/jpeg") : options.format ?? "png"; const image = await persistAgentImage(ctx, sessionId, prompt, format, providerOptions.size, requestId, response, { diff --git a/lib/agentSettings.ts b/lib/agentSettings.ts index 730d3a23..d7b121f5 100644 --- a/lib/agentSettings.ts +++ b/lib/agentSettings.ts @@ -1,7 +1,7 @@ import { config } from "../config.js"; import type { AgentGenerationSettings } from "./agentTypes.js"; -const PROVIDERS = new Set(["oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"]); +const PROVIDERS = new Set(["oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"]); const QUALITIES = new Set(["low", "medium", "high"]); const FORMATS = new Set(["png", "jpeg", "webp"]); const MODERATIONS = new Set(["auto", "low"]); diff --git a/lib/capabilities.js b/lib/capabilities.js index 331b1fee..420354ad 100644 --- a/lib/capabilities.js +++ b/lib/capabilities.js @@ -6,7 +6,7 @@ import { loadAllBundledSnapshots } from "./mcp/snapshotStore.js"; import { KEY_TO_ENV, WRITABLE_CONFIG_KEYS } from "./configKeys.js"; import { DEFAULT_IMAGE_QUALITY, VALID_IMAGE_QUALITIES } from "./oauthNormalize.js"; const VALID_MODES = ["auto", "direct"]; -const VALID_PROVIDERS = ["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"]; +const VALID_PROVIDERS = ["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"]; const AGENT_COMMANDS = [ "skill", "capabilities", @@ -60,6 +60,7 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag grokSupported: ["grok-imagine-image", "grok-imagine-image-quality"], geminiSupported: ["nano-banana-2", "nano-banana-pro"], atlasCloudSupported: ["openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit"], + minimaxSupported: ["image-01", "image-01-live"], }, videoModels: { supported: ["grok-imagine-video", "grok-imagine-video-1.5"], diff --git a/lib/capabilities.ts b/lib/capabilities.ts index be3deebb..8509f8fe 100644 --- a/lib/capabilities.ts +++ b/lib/capabilities.ts @@ -10,7 +10,7 @@ import type { AppConfig } from "./runtimeContext.js"; type CapabilitySource = "local" | "server"; const VALID_MODES = ["auto", "direct"] as const; -const VALID_PROVIDERS = ["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"] as const; +const VALID_PROVIDERS = ["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"] as const; const AGENT_COMMANDS = [ "skill", "capabilities", @@ -74,6 +74,7 @@ export function buildIma2Capabilities({ grokSupported: ["grok-imagine-image", "grok-imagine-image-quality"], geminiSupported: ["nano-banana-2", "nano-banana-pro"], atlasCloudSupported: ["openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit"], + minimaxSupported: ["image-01", "image-01-live"], }, videoModels: { supported: ["grok-imagine-video", "grok-imagine-video-1.5"], diff --git a/lib/generatePipeline.ts b/lib/generatePipeline.ts index aeab44a1..a3b4acf0 100644 --- a/lib/generatePipeline.ts +++ b/lib/generatePipeline.ts @@ -15,6 +15,7 @@ import { generateViaGrok, planGrokImage } from "./grokImageAdapter.js"; import { generateViaAgy } from "./agyImageAdapter.js"; import { generateViaGeminiApi } from "./geminiApiImageAdapter.js"; import { generateViaAtlasCloud } from "./atlasCloudImageAdapter.js"; +import { generateViaMinimax } from "./minimaxImageAdapter.js"; import { isNonRetryableGenerationError, normalizeGenerationFailure, type UpstreamErr } from "./generationErrors.js"; import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, setJobPhase, INFLIGHT_RETRY_AFTER_SECONDS, } from "./inflight.js"; import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "./generationCancel.js"; @@ -215,6 +216,13 @@ export async function runGeneratePipeline(req: Request, res: Response, ctx: Runt requestId, }); } + if (activeProvider === "minimax" && providerRefCount > 1) { + return fail(400, { + error: "MiniMax image editing supports up to 1 subject reference", + code: "MINIMAX_REF_TOO_MANY", + requestId, + }); + } const started = startJob({ requestId, kind: "classic", @@ -280,7 +288,7 @@ export async function runGeneratePipeline(req: Request, res: Response, ctx: Runt }); const startTime = Date.now(); const mimeMap: Record = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" }; - const effectiveFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? "jpeg" : String(format); + const effectiveFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? "jpeg" : String(format); const mime = mimeMap[effectiveFormat] || "image/png"; await mkdir(ctx.config.storage.generatedDir, { recursive: true }); const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined; @@ -330,6 +338,17 @@ export async function runGeneratePipeline(req: Request, res: Response, ctx: Runt throwIfJobCanceled(requestId); return r; } + if (activeProvider === "minimax") { + const r = await generateViaMinimax(generationPrompt, requireRuntimeContext(ctx), { + model: imageModel, + size: effectiveSize, + signal: cancelController.signal, + requestId, + references: refCheck.refDetails, + }); + throwIfJobCanceled(requestId); + return r; + } if (activeProvider === "grok" || activeProvider === "grok-api") { const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel; const r = await generateViaGrok(generationPrompt, ctx, { @@ -401,10 +420,10 @@ export async function runGeneratePipeline(req: Request, res: Response, ctx: Runt if (r.status === "fulfilled" && r.value.b64) { throwIfJobCanceled(requestId); const valueWithMime = r.value as typeof r.value & { mime?: string }; - const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" + const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? (valueWithMime.mime || detectImageMimeFromB64(r.value.b64) || mime) : mime; - const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? imageFormatFromMime(resultMime) : effectiveFormat; + const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(resultMime) : effectiveFormat; const retryValue = r.value as typeof r.value & { retryKind?: string; initialEventCount?: number; diff --git a/lib/imageModels.js b/lib/imageModels.js index cee0729c..8e16c43c 100644 --- a/lib/imageModels.js +++ b/lib/imageModels.js @@ -12,6 +12,8 @@ const VALID_ATLASCLOUD_IMAGE_MODELS = new Set([ "openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit", ]); +const MINIMAX_FALLBACK_IMAGE_MODEL = "image-01"; +const VALID_MINIMAX_IMAGE_MODELS = new Set(["image-01", "image-01-live"]); export function normalizeReasoningEffort(ctx, rawEffort) { const configured = ctx?.config?.imageModels; const fallback = configured?.reasoningEffort ?? FALLBACK_REASONING_EFFORT; @@ -91,6 +93,19 @@ export function normalizeAtlasCloudImageModel(rawModel) { } return { model: rawModel }; } +export function normalizeMinimaxImageModel(rawModel) { + if (typeof rawModel !== "string" || rawModel.length === 0) { + return { model: MINIMAX_FALLBACK_IMAGE_MODEL }; + } + if (!VALID_MINIMAX_IMAGE_MODELS.has(rawModel)) { + return { + error: `MiniMax image model must be one of: ${[...VALID_MINIMAX_IMAGE_MODELS].join(", ")}`, + code: "INVALID_MINIMAX_IMAGE_MODEL", + status: 400, + }; + } + return { model: rawModel }; +} // ── Grok video (T2V/I2V) ───────────────────────────────────────────────── // Video is a separate generation kind, not an image model. Keep it out of the // image model unions/helpers above so `grok-` image classification is unaffected. diff --git a/lib/imageModels.ts b/lib/imageModels.ts index 0744b53d..9eb54ea2 100644 --- a/lib/imageModels.ts +++ b/lib/imageModels.ts @@ -16,6 +16,8 @@ const VALID_ATLASCLOUD_IMAGE_MODELS = new Set([ "openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit", ]); +const MINIMAX_FALLBACK_IMAGE_MODEL = "image-01"; +const VALID_MINIMAX_IMAGE_MODELS = new Set(["image-01", "image-01-live"]); export function normalizeReasoningEffort(ctx: RouteRuntimeContext | null | undefined, rawEffort: unknown) { const configured = (ctx?.config as { imageModels?: { reasoningEffort?: string; validReasoningEfforts?: Set } } | undefined)?.imageModels; @@ -106,6 +108,20 @@ export function normalizeAtlasCloudImageModel(rawModel: unknown) { return { model: rawModel }; } +export function normalizeMinimaxImageModel(rawModel: unknown) { + if (typeof rawModel !== "string" || rawModel.length === 0) { + return { model: MINIMAX_FALLBACK_IMAGE_MODEL }; + } + if (!VALID_MINIMAX_IMAGE_MODELS.has(rawModel)) { + return { + error: `MiniMax image model must be one of: ${[...VALID_MINIMAX_IMAGE_MODELS].join(", ")}`, + code: "INVALID_MINIMAX_IMAGE_MODEL" as const, + status: 400 as const, + }; + } + return { model: rawModel }; +} + // ── Grok video (T2V/I2V) ───────────────────────────────────────────────── // Video is a separate generation kind, not an image model. Keep it out of the // image model unions/helpers above so `grok-` image classification is unaffected. diff --git a/lib/minimaxImageAdapter.ts b/lib/minimaxImageAdapter.ts new file mode 100644 index 00000000..20950a8b --- /dev/null +++ b/lib/minimaxImageAdapter.ts @@ -0,0 +1,347 @@ +// lib/minimaxImageAdapter.ts — MiniMax image-generation adapter. +// +// Calls the MiniMax /v1/image_generation endpoint directly with a Bearer API +// key. Supports text-to-image and image-to-image: when reference images are +// attached they are mapped to the `subject_reference` array (character subject +// type, data URL or public URL). Responses are returned as `url` or `base64`; +// both shapes are parsed into a single base64 payload for the shared pipeline. +// +// Region selects the global (.io) or China (.minimaxi.com) OpenAI-compatible +// base URL. The endpoint, models, request fields, output formats, and response +// fields follow the MiniMax image-generation API reference. + +import type { RuntimeContext } from "./runtimeContext.js"; +import { detectImageMimeFromB64 } from "./refs.js"; +import { logEvent } from "./logger.js"; + +export const MINIMAX_TEXT_TO_IMAGE_MODEL = "image-01"; +export const MINIMAX_IMAGE_TO_IMAGE_MODEL = "image-01-live"; + +const MINIMAX_TIMEOUT_MS = 120_000; + +// Mirrors lib/grokImageCore.ts: a provider URL is untrusted input, so the +// download is capped instead of being buffered in full. +const MAX_IMAGE_DOWNLOAD_BYTES = 50 * 1024 * 1024; + +const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp"]); + +// Aspect ratios accepted by the MiniMax image-generation API. +const VALID_ASPECT_RATIOS = new Set([ + "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9", +]); + +type MinimaxReference = { + b64: string; + declaredMime?: string | null; + detectedMime?: string | null; +}; + +type MinimaxGenerateOptions = { + model?: string; + size?: string; + signal?: AbortSignal; + requestId?: string; + references?: MinimaxReference[]; +}; + +type MinimaxImageResult = { + b64: string; + revisedPrompt?: string | null; + usage: Record | null; + webSearchCalls: number; + mime?: string; + providerUrl?: string | null; + /** Model actually sent upstream. */ + effectiveModel: string; +}; + +function minimaxError(message: string, status: number, code: string): Error { + const err = new Error(message) as Error & { status?: number; code?: string; isOperational?: boolean }; + err.status = status; + err.code = code; + err.isOperational = true; + return err; +} + +// MiniMax documents metadata counts as strings in its response samples, so a +// number-only check would miss a real content-safety block. +function toCount(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +/** + * The detected magic bytes are authoritative: a Content-Type header (or the + * absence of one) never overrides what the payload actually is. Downstream + * storage falls back to PNG for unknown MIME types, so an HTML error body + * would otherwise be saved as a broken .png. + */ +function validateMinimaxImageBytes(b64: string, headerMime?: string | null): string { + if (!b64) { + throw minimaxError("MiniMax returned an empty image payload", 502, "MINIMAX_IMAGE_INVALID"); + } + // base64 inflates by 4/3, so this bounds the decoded size without decoding. + if (b64.length > MAX_IMAGE_DOWNLOAD_BYTES * 1.4) { + throw minimaxError( + "MiniMax image payload exceeds 50MB limit", + 502, + "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE", + ); + } + const detected = detectImageMimeFromB64(b64); + if (detected && ALLOWED_IMAGE_MIMES.has(detected)) return detected; + const header = headerMime?.split(";")[0]?.trim(); + if (header && ALLOWED_IMAGE_MIMES.has(header)) { + throw minimaxError( + `MiniMax returned non-image bytes for ${header}`, + 502, + "MINIMAX_IMAGE_INVALID", + ); + } + throw minimaxError("MiniMax returned a non-image payload", 502, "MINIMAX_IMAGE_INVALID"); +} + +async function downloadMinimaxImage( + url: string, + signal: AbortSignal, +): Promise<{ b64: string; mime: string }> { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw minimaxError("MiniMax image URL must be HTTP(S)", 502, "MINIMAX_IMAGE_DOWNLOAD_FAILED"); + } + const res = await fetch(url, { signal }); + if (!res.ok) { + throw minimaxError(`MiniMax image download failed (${res.status})`, 502, "MINIMAX_IMAGE_DOWNLOAD_FAILED"); + } + const declared = Number(res.headers.get("content-length") || "0"); + if (declared > MAX_IMAGE_DOWNLOAD_BYTES) { + throw minimaxError("MiniMax image download exceeds 50MB limit", 502, "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE"); + } + if (!res.body) { + throw minimaxError("MiniMax image download had no response body", 502, "MINIMAX_IMAGE_DOWNLOAD_FAILED"); + } + const chunks: Buffer[] = []; + let total = 0; + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + // A declared content-length can lie, so the stream is capped as it arrives. + if (total > MAX_IMAGE_DOWNLOAD_BYTES) { + await reader.cancel("download size limit exceeded").catch(() => {}); + throw minimaxError("MiniMax image download exceeds 50MB limit", 502, "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE"); + } + chunks.push(Buffer.from(value)); + } + const buffer = Buffer.concat(chunks, total); + if (buffer.length === 0) { + throw minimaxError("MiniMax image download was empty", 502, "MINIMAX_IMAGE_DOWNLOAD_FAILED"); + } + const b64 = buffer.toString("base64"); + return { b64, mime: validateMinimaxImageBytes(b64, res.headers.get("content-type")) }; +} + +async function readJson(res: Response): Promise { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +function resolveBaseUrl(ctx: RuntimeContext): string { + const cfg = ctx.config.minimaxProvider; + return cfg.region === "cn_zh" ? cfg.cnBaseUrl : cfg.globalBaseUrl; +} + +// MiniMax accepts a WxH size string; map it to the closest supported +// aspect_ratio when it is not already one. "auto" / unset leaves the choice to +// the API default (1:1). +function sizeToAspectRatio(size?: string): string | null { + if (!size || size === "auto") return null; + if (VALID_ASPECT_RATIOS.has(size)) return size; + const match = size.match(/^(\d+)x(\d+)$/); + if (!match) return null; + const ratio = Number(match[1]) / Number(match[2]); + const table: Array<[string, number]> = [ + ["1:1", 1], ["16:9", 16 / 9], ["4:3", 4 / 3], ["3:2", 3 / 2], + ["2:3", 2 / 3], ["3:4", 3 / 4], ["9:16", 9 / 16], ["21:9", 21 / 9], + ]; + let best = "1:1"; + let bestDist = Infinity; + for (const [label, val] of table) { + const dist = Math.abs(ratio - val); + if (dist < bestDist) { bestDist = dist; best = label; } + } + return best; +} + +function refToDataUrl(ref: MinimaxReference): string { + const mime = ref.detectedMime || ref.declaredMime || detectImageMimeFromB64(ref.b64) || "image/png"; + return `data:${mime};base64,${ref.b64}`; +} + +export async function generateViaMinimax( + prompt: string, + ctx: RuntimeContext, + options: MinimaxGenerateOptions = {}, +): Promise { + const apiKey = ctx.minimaxApiKey; + if (!apiKey) { + throw minimaxError("MiniMax API key not configured", 401, "MINIMAX_API_KEY_MISSING"); + } + const references = (options.references || []).filter((ref) => ref.b64); + if (references.length > 1) { + throw minimaxError("MiniMax image-to-image supports up to 1 subject reference", 400, "MINIMAX_REF_TOO_MANY"); + } + // Both image-01 and image-01-live accept `subject_reference`, so an attached + // reference never overrides the caller's model choice; swapping it silently + // would also make the stored provenance disagree with what was sent. + const model = options.model || MINIMAX_TEXT_TO_IMAGE_MODEL; + // Global MiniMax lists only image-01 for text-to-image; image-01-live is a + // reference-driven model there. Reject that combination locally with an + // actionable message instead of surfacing a bare upstream bad-request. + if ( + ctx.config.minimaxProvider.region !== "cn_zh" + && model === MINIMAX_IMAGE_TO_IMAGE_MODEL + && references.length === 0 + ) { + throw minimaxError( + "MiniMax image-01-live requires a reference image outside the China region. " + + "Attach a reference or switch to image-01.", + 400, + "MINIMAX_MODEL_REQUIRES_REFERENCE", + ); + } + const baseUrl = resolveBaseUrl(ctx); + const url = `${baseUrl.replace(/\/$/, "")}/image_generation`; + + const body: Record = { + model, + prompt, + response_format: "url", + }; + const aspectRatio = sizeToAspectRatio(options.size); + if (aspectRatio) body.aspect_ratio = aspectRatio; + + if (references.length > 0) { + body.subject_reference = references.map((ref) => ({ + type: "character", + image_file: refToDataUrl(ref), + })); + } + + logEvent("minimax", "generate:start", { + requestId: options.requestId, + model, + aspectRatio: aspectRatio ?? null, + refs: references.length, + }); + + const timeoutSignal = AbortSignal.timeout(ctx.config.minimaxProvider.generationTimeoutMs || MINIMAX_TIMEOUT_MS); + const combinedSignal = options.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + + try { + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: combinedSignal, + }); + + const json = await readJson(res); + const statusCode = json?.base_resp?.status_code; + const statusMsg = json?.base_resp?.status_msg; + + if (!res.ok || (typeof statusCode === "number" && statusCode !== 0)) { + const detail = statusMsg || JSON.stringify(json).slice(0, 200); + if (res.status === 429 || statusCode === 1002) { + throw minimaxError(`MiniMax rate limited: ${detail}`, 429, "MINIMAX_RATE_LIMITED"); + } + if (res.status === 401 || statusCode === 1004 || statusCode === 2049) { + throw minimaxError(`MiniMax authentication failed: ${detail}`, 401, "MINIMAX_AUTH_FAILED"); + } + if (statusCode === 1008) { + throw minimaxError(`MiniMax insufficient balance: ${detail}`, 402, "MINIMAX_INSUFFICIENT_BALANCE"); + } + if (statusCode === 1026) { + throw minimaxError(`MiniMax sensitive content detected: ${detail}`, 400, "MINIMAX_SAFETY_BLOCKED"); + } + if (res.status === 400 || res.status === 403 || statusCode === 2013) { + throw minimaxError(`MiniMax bad request: ${detail}`, res.status || 400, "MINIMAX_BAD_REQUEST"); + } + throw minimaxError(`MiniMax image generation failed (${res.status}): ${detail}`, 502, "MINIMAX_UPSTREAM_ERROR"); + } + + const data = json?.data || {}; + const imageUrls: string[] = Array.isArray(data.image_urls) ? data.image_urls : []; + const imageBase64: string[] = Array.isArray(data.image_base64) ? data.image_base64 : []; + const successCount = toCount(json?.metadata?.success_count); + const failedCount = toCount(json?.metadata?.failed_count); + + let b64: string | null = null; + let mime = "image/png"; + let providerUrl: string | null = null; + + if (imageBase64.length > 0) { + b64 = typeof imageBase64[0] === "string" ? imageBase64[0] : ""; + mime = validateMinimaxImageBytes(b64); + } else if (imageUrls.length > 0) { + providerUrl = imageUrls[0]; + const downloaded = await downloadMinimaxImage(providerUrl, combinedSignal); + b64 = downloaded.b64; + mime = downloaded.mime; + } + + if (!b64) { + if (failedCount !== null && failedCount > 0 && (successCount === null || successCount === 0)) { + throw minimaxError("MiniMax image generation blocked by content safety", 400, "MINIMAX_SAFETY_BLOCKED"); + } + throw minimaxError("MiniMax image generation did not return an image", 502, "MINIMAX_NO_IMAGE"); + } + + logEvent("minimax", "generate:done", { + requestId: options.requestId, + model, + b64Len: b64.length, + mime, + successCount: successCount ?? null, + failedCount: failedCount ?? null, + }); + + return { + b64, + revisedPrompt: null, + usage: null, + webSearchCalls: 0, + mime, + providerUrl, + effectiveModel: model, + }; + } catch (e: any) { + // AbortSignal.timeout() rejects with a TimeoutError, not an AbortError. + if (e.name === "TimeoutError") { + throw minimaxError("MiniMax image generation timed out", 504, "GENERATION_TIMEOUT"); + } + if (e.name === "AbortError") { + if (options.signal?.aborted) { + throw minimaxError("Generation canceled", 499, "GENERATION_CANCELED"); + } + throw minimaxError("MiniMax image generation timed out", 504, "GENERATION_TIMEOUT"); + } + if (e.code && e.status) throw e; + throw minimaxError(`MiniMax request failed: ${e.message}`, 502, "MINIMAX_NETWORK_FAILED"); + } +} diff --git a/lib/multimodePipeline.ts b/lib/multimodePipeline.ts index d011de88..f5c8eaa1 100644 --- a/lib/multimodePipeline.ts +++ b/lib/multimodePipeline.ts @@ -14,6 +14,7 @@ import { generateMultimodeViaGrok } from "./grokMultimodeAdapter.js"; import { generateViaAgy } from "./agyImageAdapter.js"; import { generateViaGeminiApi } from "./geminiApiImageAdapter.js"; import { generateViaAtlasCloud } from "./atlasCloudImageAdapter.js"; +import { generateViaMinimax } from "./minimaxImageAdapter.js"; import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "./inflight.js"; import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "./generationCancel.js"; import { logEvent, logError } from "./logger.js"; @@ -248,7 +249,7 @@ export async function runMultimodePipeline(req: Request, res: Response, ctx: Run logEvent("multimode", "request", { requestId, quality, model: imageModel, size: effectiveSize, moderation, maxImages, refs: refCheck.refs.length, referenceBytes: referencePayload.referenceBytes, promptChars: typeof prompt === "string" ? prompt.length : 0, webSearchEnabled, }); const startTime = Date.now(); const mimeMap: Record = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" }; - const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? "jpeg" : String(format); + const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? "jpeg" : String(format); const mime = mimeMap[mmFormat] || "image/png"; const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`; routeMaxImages = maxImages; @@ -268,10 +269,10 @@ export async function runMultimodePipeline(req: Request, res: Response, ctx: Run const persistAndSendImage = async ( image: MultimodeImage, index: number, totalReturned: number, status: ReturnType, ) => { if (persistedIndexes.has(index)) return; throwIfJobCanceled(requestId); - const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" + const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? (image.mime || detectImageMimeFromB64(image.b64) || mime) : mime; - const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? imageFormatFromMime(resultMime) : mmFormat; + const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(resultMime) : mmFormat; const createdAt = Date.now(); const baseName = buildFilename({ model: (activeProvider === "grok" || activeProvider === "grok-api") && quality === "high" ? "grok-imagine-image-quality" : imageModel, @@ -377,6 +378,19 @@ export async function runMultimodePipeline(req: Request, res: Response, ctx: Run usage: r.usage, webSearchCalls: r.webSearchCalls, }; + } else if (activeProvider === "minimax") { + const r = await generateViaMinimax(prompt, requireRuntimeContext(ctx), { + model: imageModel, + size: effectiveSize, + signal: cancelController.signal, + requestId, + references: refCheck.refDetails, + }); + generated = { + images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }], + usage: r.usage, + webSearchCalls: r.webSearchCalls, + }; } else if (activeProvider === "grok" || activeProvider === "grok-api") { const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined; const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel; diff --git a/lib/nodeGeneration.ts b/lib/nodeGeneration.ts index 185a080c..2ab1d692 100644 --- a/lib/nodeGeneration.ts +++ b/lib/nodeGeneration.ts @@ -12,6 +12,7 @@ import { generateViaGrok } from "./grokImageAdapter.js"; import { generateViaAgy } from "./agyImageAdapter.js"; import { generateViaGeminiApi } from "./geminiApiImageAdapter.js"; import { generateViaAtlasCloud } from "./atlasCloudImageAdapter.js"; +import { generateViaMinimax } from "./minimaxImageAdapter.js"; import { isNonRetryableGenerationError, normalizeGenerationFailure, type UpstreamErr } from "./generationErrors.js"; import { logEvent, logError } from "./logger.js"; import { errInfo } from "./errInfo.js"; @@ -155,6 +156,18 @@ export async function runNodeGeneration(req: Request, res: Response, ctx: Runtim parentNodeId, }); } + if (activeProvider === "minimax" && inputImageCount > 1) { + finishStatus = "error"; + finishHttpStatus = 400; + return res.status(400).json({ + error: { + code: "MINIMAX_REF_TOO_MANY", + message: "MiniMax image editing supports up to 1 subject reference.", + }, + code: "MINIMAX_REF_TOO_MANY", + parentNodeId, + }); + } const started = startJob({ requestId, kind: "node", @@ -228,7 +241,7 @@ export async function runNodeGeneration(req: Request, res: Response, ctx: Runtim } let b64: string | undefined, usage: unknown, webSearchCalls = 0, revisedPrompt: string | null = null; const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined; - let resultFormat: "png" | "jpeg" | "webp" = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? "jpeg" : format as "png" | "jpeg" | "webp"; + let resultFormat: "png" | "jpeg" | "webp" = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? "jpeg" : format as "png" | "jpeg" | "webp"; const maxAttempts = inputImageCount > 0 ? 1 : 2; let lastErr: UpstreamErr | null = null; for (let attempt = 0; attempt < maxAttempts; attempt++) { @@ -280,6 +293,16 @@ export async function runNodeGeneration(req: Request, res: Response, ctx: Runtim ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...((refCheck.refDetails || []) as any[])] : refCheck.refDetails, }) + : activeProvider === "minimax" + ? await generateViaMinimax(parentB64 ? `Edit this image: ${prompt}` : prompt, requireRuntimeContext(ctx), { + model: effectiveImageModel, + size: effectiveSize, + signal: cancelController.signal, + requestId, + references: parentB64 + ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...((refCheck.refDetails || []) as any[])] + : refCheck.refDetails, + }) : activeProvider === "grok" || activeProvider === "grok-api" ? await generateViaGrok(generationPrompt, ctx, { model: effectiveImageModel, @@ -330,7 +353,7 @@ export async function runNodeGeneration(req: Request, res: Response, ctx: Runtim usage = r.usage; webSearchCalls = r.webSearchCalls || 0; revisedPrompt = r.revisedPrompt || null; - if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud") { + if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax") { resultFormat = imageFormatFromMime(("mime" in r ? r.mime : undefined) || detectImageMimeFromB64(r.b64) || "image/jpeg"); } break; diff --git a/lib/providerOptions.ts b/lib/providerOptions.ts index 67e175da..7aaee68a 100644 --- a/lib/providerOptions.ts +++ b/lib/providerOptions.ts @@ -1,6 +1,6 @@ import type { RuntimeContext } from "./runtimeContext.js"; import { ATLASCLOUD_TEXT_TO_IMAGE_MODEL } from "./atlasCloudImageAdapter.js"; -import { FALLBACK_IMAGE_MODEL, normalizeImageModel, normalizeReasoningEffort, normalizeGrokImageModel, normalizeGeminiApiModel } from "./imageModels.js"; +import { FALLBACK_IMAGE_MODEL, normalizeImageModel, normalizeReasoningEffort, normalizeGrokImageModel, normalizeGeminiApiModel, normalizeMinimaxImageModel } from "./imageModels.js"; export function resolveProviderOptions(ctx: RuntimeContext | null | undefined, { provider = "oauth", @@ -42,6 +42,20 @@ export function resolveProviderOptions(ctx: RuntimeContext | null | undefined, { }; } + if (provider === "minimax") { + const minimaxCfg: { defaultImageModel?: string } = (ctx?.config as any)?.minimaxProvider || {}; + const modelInput = rawModel || minimaxCfg.defaultImageModel; + const minimaxModelCheck = normalizeMinimaxImageModel(modelInput); + if (minimaxModelCheck.error) return { error: minimaxModelCheck.error, code: minimaxModelCheck.code, status: minimaxModelCheck.status }; + return { + provider: "minimax" as const, + model: minimaxModelCheck.model, + reasoningEffort: "none", + size: rawSize || "1024x1024", + webSearchEnabled: false, + }; + } + if (provider === "grok") { const grokCfg: { defaultImageModel?: string } = (ctx?.config as any)?.grokProvider || {}; const modelInput = rawModel || grokCfg.defaultImageModel; diff --git a/lib/runtimeContext.ts b/lib/runtimeContext.ts index 4dccbf65..17ed0292 100644 --- a/lib/runtimeContext.ts +++ b/lib/runtimeContext.ts @@ -35,6 +35,9 @@ export interface RuntimeContext { atlasCloudApiKey: string | undefined; atlasCloudApiKeySource: ApiKeySource; hasAtlasCloudApiKey: boolean; + minimaxApiKey: string | undefined; + minimaxApiKeySource: ApiKeySource; + hasMinimaxApiKey: boolean; vertexServiceAccountJson: string | undefined; vertexProjectId: string | undefined; hasVertexKey: boolean; @@ -115,6 +118,9 @@ export function requireRuntimeContext(ctx: RouteRuntimeContext | undefined): Run if (target.atlasCloudApiKey === undefined && !Object.prototype.hasOwnProperty.call(target, 'atlasCloudApiKey')) target.atlasCloudApiKey = undefined; if (target.hasAtlasCloudApiKey === undefined) target.hasAtlasCloudApiKey = false; if (target.atlasCloudApiKeySource === undefined) target.atlasCloudApiKeySource = undefined; + if (target.minimaxApiKey === undefined && !Object.prototype.hasOwnProperty.call(target, 'minimaxApiKey')) target.minimaxApiKey = undefined; + if (target.hasMinimaxApiKey === undefined) target.hasMinimaxApiKey = false; + if (target.minimaxApiKeySource === undefined) target.minimaxApiKeySource = undefined; if (target.vertexServiceAccountJson === undefined && !Object.prototype.hasOwnProperty.call(target, 'vertexServiceAccountJson')) target.vertexServiceAccountJson = undefined; if (target.vertexProjectId === undefined) target.vertexProjectId = undefined; if (target.hasVertexKey === undefined) target.hasVertexKey = false; @@ -178,6 +184,9 @@ export function createTestRuntimeContext(over: RuntimeContextOverrides = {}): Ru atlasCloudApiKey: undefined, atlasCloudApiKeySource: undefined, hasAtlasCloudApiKey: false, + minimaxApiKey: undefined, + minimaxApiKeySource: undefined, + hasMinimaxApiKey: false, vertexServiceAccountJson: undefined, vertexProjectId: undefined, hasVertexKey: false, diff --git a/package-lock.json b/package-lock.json index 0e2a4de6..040d7bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -545,9 +545,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1640,20 +1640,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1663,6 +1663,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2333,9 +2346,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -2690,9 +2703,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2791,9 +2804,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4544,17 +4557,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { diff --git a/routes/auth.ts b/routes/auth.ts index bf157112..8720c575 100644 --- a/routes/auth.ts +++ b/routes/auth.ts @@ -138,7 +138,7 @@ function startCodexDeviceCode(): Promise<{ sessionId: string; userCode: string; // Don't hand other providers' secrets to the codex child — it only needs // PATH/HOME/codex config to run the ChatGPT device-code login. const childEnv = { ...process.env }; - for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON"]) { + for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON", "ATLASCLOUD_API_KEY", "MINIMAX_API_KEY"]) { delete childEnv[k]; } const codex = packageCliCommand( diff --git a/routes/edit.ts b/routes/edit.ts index a318de18..7cba9654 100644 --- a/routes/edit.ts +++ b/routes/edit.ts @@ -13,6 +13,7 @@ import { editViaGrok } from "../lib/grokImageAdapter.js"; import { generateViaAgy } from "../lib/agyImageAdapter.js"; import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js"; import { generateViaAtlasCloud } from "../lib/atlasCloudImageAdapter.js"; +import { generateViaMinimax } from "../lib/minimaxImageAdapter.js"; import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js"; import { isGenerationCanceledError, @@ -177,11 +178,11 @@ export function registerEditRoutes(app: Express, ctxRaw: RouteRuntimeContext) { finishErrorCode = "INVALID_EDIT_INPUT"; return res.status(400).json({ error: "Prompt and image are required" }); } - if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud") && rawMask) { + if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax") && rawMask) { finishStatus = "error"; finishHttpStatus = 400; - const code = activeProvider === "agy" ? "AGY_MASK_UNSUPPORTED" : activeProvider === "gemini-api" ? "GEMINI_API_MASK_UNSUPPORTED" : activeProvider === "atlascloud" ? "ATLASCLOUD_MASK_UNSUPPORTED" : "GROK_MASK_UNSUPPORTED"; - const label = activeProvider === "agy" ? "Agy" : activeProvider === "gemini-api" ? "Gemini API" : activeProvider === "atlascloud" ? "Atlas Cloud" : "Grok"; + const code = activeProvider === "agy" ? "AGY_MASK_UNSUPPORTED" : activeProvider === "gemini-api" ? "GEMINI_API_MASK_UNSUPPORTED" : activeProvider === "atlascloud" ? "ATLASCLOUD_MASK_UNSUPPORTED" : activeProvider === "minimax" ? "MINIMAX_MASK_UNSUPPORTED" : "GROK_MASK_UNSUPPORTED"; + const label = activeProvider === "agy" ? "Agy" : activeProvider === "gemini-api" ? "Gemini API" : activeProvider === "atlascloud" ? "Atlas Cloud" : activeProvider === "minimax" ? "MiniMax" : "Grok"; return res.status(400).json({ error: `${label} provider does not support mask editing`, code }); } const maskCheck: any = validateEditMask(imageB64, rawMask); @@ -262,6 +263,20 @@ export function registerEditRoutes(app: Express, ctxRaw: RouteRuntimeContext) { revisedPrompt = r.revisedPrompt ?? undefined; webSearchCalls = r.webSearchCalls; resultMimeFromProvider = r.mime; + } else if (activeProvider === "minimax") { + const r = await generateViaMinimax(`Edit this image: ${prompt}`, requireRuntimeContext(ctx), { + model: imageModel, + size: effectiveSize, + signal: cancelController.signal, + requestId, + references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }], + }); + resultB64 = r.b64; + providerUrl = r.providerUrl ?? null; + usage = r.usage; + revisedPrompt = r.revisedPrompt ?? undefined; + webSearchCalls = r.webSearchCalls; + resultMimeFromProvider = r.mime; } else if (activeProvider === "grok" || activeProvider === "grok-api") { const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined; const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel; @@ -307,10 +322,10 @@ export function registerEditRoutes(app: Express, ctxRaw: RouteRuntimeContext) { const elapsed = +((Date.now() - startTime) / 1000).toFixed(1); await mkdir(ctx.config.storage.generatedDir, { recursive: true }); throwIfJobCanceled(requestId); - const editMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" + const editMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png") : "image/png"; - const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" ? imageFormatFromMime(editMime) : "png"; + const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(editMime) : "png"; const editBuffer = Buffer.from(resultB64, "base64"); const createdAt = Date.now(); const filename = await writeFileUnique( diff --git a/routes/keys.ts b/routes/keys.ts index 25c7d262..d2ca23b8 100644 --- a/routes/keys.ts +++ b/routes/keys.ts @@ -33,13 +33,14 @@ async function updateConfigFile( }); } -type KeyProvider = "openai" | "xai" | "gemini" | "atlascloud"; +type KeyProvider = "openai" | "xai" | "gemini" | "atlascloud" | "minimax"; const KEY_PREFIX_MAP: Record = { openai: ["sk-"], xai: ["xai-"], gemini: ["AI"], atlascloud: ["apikey-"], + minimax: [], }; const VALIDATE_URL_MAP: Record = { @@ -47,17 +48,37 @@ const VALIDATE_URL_MAP: Record = { xai: "https://api.x.ai/v1/models", gemini: "https://generativelanguage.googleapis.com/v1beta/models", atlascloud: "https://api.atlascloud.ai/api/v1/models", + // Fallback only. The MiniMax branch resolves a region-aware URL at call time + // via resolveMinimaxValidateUrl so a cn_zh workspace validates against the CN host. + minimax: "https://api.minimax.io/v1/models", }; +// Same region rule as lib/minimaxImageAdapter.ts resolveBaseUrl. +function resolveMinimaxValidateUrl(ctx: RuntimeContext): string { + const cfg = ctx.config.minimaxProvider; + const base = cfg.region === "cn_zh" ? cfg.cnBaseUrl : cfg.globalBaseUrl; + return `${base.replace(/\/$/, "")}/models`; +} + +async function readJsonOrNull(res: globalThis.Response): Promise | null> { + try { + const parsed = await res.json(); + return parsed && typeof parsed === "object" ? parsed as Record : null; + } catch { + return null; + } +} + const CONFIG_KEY_MAP: Record = { openai: "apiKey", xai: "xaiApiKey", gemini: "geminiApiKey", atlascloud: "atlasCloudApiKey", + minimax: "minimaxApiKey", }; function isKeyProvider(v: string): v is KeyProvider { - return v === "openai" || v === "xai" || v === "gemini" || v === "atlascloud"; + return v === "openai" || v === "xai" || v === "gemini" || v === "atlascloud" || v === "minimax"; } function maskKey(key: string): string { @@ -70,13 +91,14 @@ function keySourceForProvider(ctx: RuntimeContext, provider: KeyProvider): { key if (provider === "xai") return { key: ctx.xaiApiKey, source: ctx.xaiApiKeySource || "none" }; if (provider === "gemini") return { key: ctx.geminiApiKey, source: ctx.geminiApiKeySource || "none" }; if (provider === "atlascloud") return { key: ctx.atlasCloudApiKey, source: ctx.atlasCloudApiKeySource || "none" }; + if (provider === "minimax") return { key: ctx.minimaxApiKey, source: ctx.minimaxApiKeySource || "none" }; return { key: undefined, source: "none" }; } export function mountKeyRoutes(app: Express, ctx: RuntimeContext) { app.get("/api/keys/status", (_req: Request, res: Response) => { const status: Record = {}; - for (const provider of ["openai", "xai", "gemini", "atlascloud"] as const) { + for (const provider of ["openai", "xai", "gemini", "atlascloud", "minimax"] as const) { const { key, source } = keySourceForProvider(ctx, provider); status[provider] = { configured: !!key, @@ -194,12 +216,13 @@ export function mountKeyRoutes(app: Express, ctx: RuntimeContext) { return res.status(400).json({ ok: false, error: "API key too large", code: "KEY_TOO_LARGE" }); } - // Format check - const validPrefix = KEY_PREFIX_MAP[provider].some((p) => trimmed.startsWith(p)); + // Format check (providers with an empty prefix list accept any non-empty key) + const prefixes = KEY_PREFIX_MAP[provider]; + const validPrefix = prefixes.length === 0 || prefixes.some((p) => trimmed.startsWith(p)); if (!validPrefix) { return res.status(400).json({ ok: false, - error: `Invalid key format for ${provider}: expected prefix ${KEY_PREFIX_MAP[provider].join(" or ")}`, + error: `Invalid key format for ${provider}: expected prefix ${prefixes.join(" or ")}`, code: "INVALID_KEY_FORMAT", }); } @@ -212,6 +235,28 @@ export function mountKeyRoutes(app: Express, ctx: RuntimeContext) { opts.headers = { "x-goog-api-key": trimmed }; const validateRes = await fetch(url, opts); if (!validateRes.ok) throw new Error(`HTTP ${validateRes.status}`); + } else if (provider === "minimax") { + // List models instead of generating one: listing costs nothing, while + // probing the image endpoint would bill a real image on every save. + opts.method = "GET"; + opts.headers = { Authorization: `Bearer ${trimmed}` }; + const validateRes = await fetch(resolveMinimaxValidateUrl(ctx), opts); + if (!validateRes.ok) throw new Error(`HTTP ${validateRes.status}`); + // Fail closed: a 2xx alone is not proof, because MiniMax also reports + // errors inside a 200 body. Require the documented list shape. + const parsed = await readJsonOrNull(validateRes); + if (!parsed || !Array.isArray(parsed.data)) { + throw new Error("unexpected model list response"); + } + const baseResp = parsed.base_resp as { status_code?: unknown } | undefined; + // Accept only an explicit success code. A non-numeric status_code is + // type drift, not permission to store the key. + if (baseResp && baseResp.status_code !== undefined) { + const status = Number(baseResp.status_code); + if (!Number.isFinite(status) || status !== 0) { + throw new Error(`MiniMax status ${String(baseResp.status_code)}`); + } + } } else { opts.headers = { Authorization: `Bearer ${trimmed}` }; const validateRes = await fetch(url, opts); @@ -254,6 +299,10 @@ export function mountKeyRoutes(app: Express, ctx: RuntimeContext) { (ctx as any).atlasCloudApiKey = trimmed; (ctx as any).atlasCloudApiKeySource = "config"; (ctx as any).hasAtlasCloudApiKey = true; + } else if (provider === "minimax") { + (ctx as any).minimaxApiKey = trimmed; + (ctx as any).minimaxApiKeySource = "config"; + (ctx as any).hasMinimaxApiKey = true; } return res.json({ ok: true, provider, source: "config", valid: true }); @@ -291,6 +340,10 @@ export function mountKeyRoutes(app: Express, ctx: RuntimeContext) { (ctx as any).atlasCloudApiKey = undefined; (ctx as any).atlasCloudApiKeySource = "none"; (ctx as any).hasAtlasCloudApiKey = false; + } else if (provider === "minimax") { + (ctx as any).minimaxApiKey = undefined; + (ctx as any).minimaxApiKeySource = "none"; + (ctx as any).hasMinimaxApiKey = false; } return res.json({ ok: true, provider, removed: true }); diff --git a/routes/models.ts b/routes/models.ts index 270d48cf..77ef0c2e 100644 --- a/routes/models.ts +++ b/routes/models.ts @@ -5,6 +5,10 @@ import { ATLASCLOUD_EDIT_MODEL, ATLASCLOUD_TEXT_TO_IMAGE_MODEL, } from "../lib/atlasCloudImageAdapter.js"; +import { + MINIMAX_IMAGE_TO_IMAGE_MODEL, + MINIMAX_TEXT_TO_IMAGE_MODEL, +} from "../lib/minimaxImageAdapter.js"; import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_BASE, @@ -34,7 +38,7 @@ import { export type ModelLaneStatus = "ready" | "locked" | "disconnected" | "key-missing"; export type ModelLaneId = | "oauth" | "api" | "grok" | "grok-api" | "agy" | "gemini-api" - | "atlascloud" | "runway" | "higgsfield"; + | "atlascloud" | "minimax" | "runway" | "higgsfield"; export interface ModelLaneDto { status: ModelLaneStatus; @@ -169,6 +173,15 @@ function atlasCloudLane(ctx: RuntimeContext): ModelLaneDto { }); } +function minimaxLane(ctx: RuntimeContext): ModelLaneDto { + const state: LaneState = ctx.minimaxApiKey + ? { status: "ready" } + : { status: "key-missing", reason: "MiniMax API key missing" }; + return lane(state, { image: MINIMAX_TEXT_TO_IMAGE_MODEL }, { + image: entries([MINIMAX_TEXT_TO_IMAGE_MODEL, MINIMAX_IMAGE_TO_IMAGE_MODEL]), video: [], + }); +} + function buildCoreLanes(ctx: RuntimeContext, agyInstalled: boolean) { const gptModels = entries(ctx.config.imageModels.valid); return { @@ -179,6 +192,7 @@ function buildCoreLanes(ctx: RuntimeContext, agyInstalled: boolean) { agy: agyLane(agyInstalled), "gemini-api": geminiLane(ctx), atlascloud: atlasCloudLane(ctx), + minimax: minimaxLane(ctx), }; } diff --git a/server.ts b/server.ts index 7fdf60bd..4da86c48 100644 --- a/server.ts +++ b/server.ts @@ -120,6 +120,24 @@ async function loadAtlasCloudApiKey(): Promise { return { apiKey: null, apiKeySource: "none" }; } +async function loadMinimaxApiKey(): Promise { + if (process.env.MINIMAX_API_KEY) { + return { apiKey: process.env.MINIMAX_API_KEY, apiKeySource: "env" }; + } + const candidates = [ + config.storage.configFile, + join(rootDir, ".ima2", "config.json"), + ]; + for (const cfgPath of candidates) { + if (!existsSync(cfgPath)) continue; + try { + const cfg = JSON.parse(await readFile(cfgPath, "utf-8")) as { minimaxApiKey?: string }; + if (cfg.minimaxApiKey) return { apiKey: cfg.minimaxApiKey, apiKeySource: "config" }; + } catch {} + } + return { apiKey: null, apiKeySource: "none" }; +} + type VertexKeyLoadResult = { json: string | null; projectId: string | null; source: ApiKeySource }; async function loadVertexKey(): Promise { @@ -335,6 +353,7 @@ export async function createRuntimeContext(overrides: StartServerOverrides = {}) const loadedXaiKey = await loadXaiApiKey(); const loadedGeminiKey = await loadGeminiApiKey(); const loadedAtlasCloudKey = await loadAtlasCloudApiKey(); + const loadedMinimaxKey = await loadMinimaxApiKey(); const loadedVertexKey = await loadVertexKey(); const geminiAuthMode = await loadGeminiAuthMode(); const apiKey = loadedKey.apiKey; @@ -373,6 +392,9 @@ export async function createRuntimeContext(overrides: StartServerOverrides = {}) atlasCloudApiKey: loadedAtlasCloudKey.apiKey ?? undefined, atlasCloudApiKeySource: loadedAtlasCloudKey.apiKeySource as ApiKeySource, hasAtlasCloudApiKey: !!loadedAtlasCloudKey.apiKey, + minimaxApiKey: loadedMinimaxKey.apiKey ?? undefined, + minimaxApiKeySource: loadedMinimaxKey.apiKeySource as ApiKeySource, + hasMinimaxApiKey: !!loadedMinimaxKey.apiKey, vertexServiceAccountJson: loadedVertexKey.json ?? undefined, vertexProjectId: loadedVertexKey.projectId ?? undefined, hasVertexKey: !!loadedVertexKey.json, diff --git a/structure/01-file-function-map.md b/structure/01-file-function-map.md index 67b29de3..41ff4bb0 100644 --- a/structure/01-file-function-map.md +++ b/structure/01-file-function-map.md @@ -72,13 +72,13 @@ routes/ | File | Lines | Responsibility | |---|---:|---| -| `server.ts` | 545 | Express bootstrap, middleware wiring, OAuth startup, runtime advertisement, port fallback, post-listen MCP restore, coordinated shutdown, route registration, static serving | -| `config.ts` | 388 | Centralized runtime config (env > `~/.ima2/config.json` > defaults), prompt import/index caps, web-search/reasoning-effort defaults, API-provider defaults, and backward-compatible flat re-exports | +| `server.ts` | 567 | Express bootstrap, middleware wiring, OAuth startup, runtime advertisement, port fallback, post-listen MCP restore, coordinated shutdown, route registration, static serving | +| `config.ts` | 398 | Centralized runtime config (env > `~/.ima2/config.json` > defaults), prompt import/index caps, web-search/reasoning-effort defaults, API-provider defaults, and backward-compatible flat re-exports | | `routes/index.ts` | 91 | Route registration hub: health, capabilities, events, storage, metadata, history, imageImport, sessions, edit, nodes, multimode, generate, agent, prompt builder, generationRequestLog, annotations, canvasVersions, comfy, prompts, prompt import, keys, auth, quota, grok, agy, video, videoExtended, mcpMultishot, and (when `features.cardNews`) cardNews | | `routes/mcpMultishot.ts` | 112 | Multishot (multi-scene) video generation route via Runway MCP | | `routes/capabilities.ts` | 34 | `GET /api/capabilities` — agent-facing runtime defaults; `GET/PATCH /api/config/grok-planner` — Grok planner model query/update | | `routes/generate.ts` | 13 | Classic generation API route wiring | -| `routes/edit.ts` | 433 | Edit API, mask validation, cancellation, OAuth/API edit response save, provider/web-search/reasoning-effort plumbing | +| `routes/edit.ts` | 448 | Edit API, mask validation, cancellation, OAuth/API edit response save, provider/web-search/reasoning-effort plumbing | | `routes/multimode.ts` | 10 | `POST /api/generate/multimode` route wiring | | `routes/video.ts` | 513 | `POST /api/video/generate` SSE: Grok video T2V/I2V/Ref2V, active prompt guard, continuation lineage, sidecar persistence | | `routes/videoExtended.ts` | 488 | Video edit, extension, frame extraction, and configured-planner first/last-frame analysis (Grok 4.5 default) | @@ -177,12 +177,12 @@ routes/ | `lib/oauthProxy/index.ts` | 29 | Public surface — re-exports generators, streams, prompts, references, runtime, and shared types | | `lib/oauthProxy/generators.ts` | 229 | OAuth Responses single-image generation and stable generator exports | | `lib/oauthProxy/multimodeGenerators.ts` | 304 | OAuth Responses multimode and edit generators, masked-edit guard | -| `lib/generatePipeline.ts` | 619 | Classic generation pipeline, provider retry, persistence, background-preset prompt shaping, and event publication | +| `lib/generatePipeline.ts` | 638 | Classic generation pipeline, provider retry, persistence, background-preset prompt shaping, and event publication | | `lib/backgroundPresets.ts` | 47 | Background preset contract for asset generation: enum parse, prompt suffixes, planner constraint | -| `lib/multimodePipeline.ts` | 557 | Multimode streaming pipeline, persistence, cancellation, and partial timeout | +| `lib/multimodePipeline.ts` | 571 | Multimode streaming pipeline, persistence, cancellation, and partial timeout | | `lib/comparisonMatrix.ts` | 77 | Prompt-locked comparison axes: deterministic cartesian expansion, 9-cell cost cap, varying-axis labels | | `lib/comparisonRunner.ts` | 111 | Per-cell generation orchestrator with bounded concurrency, isolated failures, single-cell retry, and two-level cancel | -| `lib/nodeGeneration.ts` | 509 | Node provider routing, retry, persistence, and SSE publication | +| `lib/nodeGeneration.ts` | 532 | Node provider routing, retry, persistence, and SSE publication | | `lib/nodeValidation.ts` | 44 | Node prompt, references, and moderation validation | | `lib/oauthProxy/streams.ts` | 233 | SSE/event-stream helpers and safe stream diagnostics | | `lib/oauthProxy/prompts.ts` | 158 | Prompt assembly with injected `SAFETY_INTENT_POLICY` from `lib/promptSafetyPolicy.ts` | @@ -192,14 +192,14 @@ routes/ | `lib/oauthProxy/types.ts` | 10 | Shared OAuth proxy types (re-exported from `index`) | | `lib/promptSafetyPolicy.ts` | 3 | `SAFETY_INTENT_POLICY` constant: 3-line intent policy injected by oauthProxy/prompts and the API-key Responses adapter | | `lib/responsesImageAdapter.ts` | 478 | API-key provider Responses adapter — parity with OAuth path for generate/edit/multimode/node, including multimode final-image callbacks | -| `lib/providerOptions.ts` | 106 | Per-provider option assembly (provider, model, size, reasoning effort, web search) | -| `lib/runtimeContext.ts` | 187 | Per-request runtime context plumbing for routes and lib helpers | +| `lib/providerOptions.ts` | 120 | Per-provider option assembly (provider, model, size, reasoning effort, web search) | +| `lib/runtimeContext.ts` | 196 | Per-request runtime context plumbing for routes and lib helpers | | `lib/errInfo.ts` | 44 | Error info shape and helpers shared across routes/lib | | `lib/oauthNormalize.ts` | 31 | Upstream OAuth response field normalization | | `lib/openDirectory.ts` | 48 | Cross-platform open of the generated directory (used by `/api/storage/open-generated-dir`) | | `lib/refs.ts` | 134 | Reference image validation, count/size limits | | `lib/referenceImageCompress.ts` | 85 | Sharp-based reference image compression below the configured byte cap | -| `lib/imageModels.ts` | 235 | Image model allowlist and `normalizeImageModel(ctx, raw)` helper | +| `lib/imageModels.ts` | 251 | Image model allowlist and `normalizeImageModel(ctx, raw)` helper | | `lib/imageMetadata.ts` | 124 | `ima2.generation.v1` payload schema, XMP build/parse, embed limits | | `lib/imageMetadataStore.ts` | 68 | Sharp-based embed/read of XMP metadata into PNG/JPEG/WebP | | `lib/canvasVersionStore.ts` | 331 | Canvas version snapshot storage, list, restore, and pruning | @@ -221,7 +221,7 @@ routes/ | `lib/assetsStore.ts` | 533 | Generated asset indexing, lookup, and persistence helpers | | `lib/assetRef.ts` | 57 | Asset-id-first reference resolution with legacy filename fallback and `via` provenance for generate requests | | `lib/atomicWrite.ts` | 16 | Atomic file-write helper | -| `lib/capabilities.ts` | 139 | Runtime provider and feature capability resolution | +| `lib/capabilities.ts` | 140 | Runtime provider and feature capability resolution | | `lib/characterBindings.ts` | 112 | Character provider binding validation, refs preservation guard, and drift detection | | `lib/composerSnapshot.ts` | 34 | Composer state snapshot normalization | | `lib/configKeys.ts` | 69 | Runtime configuration key definitions and validation | @@ -286,7 +286,7 @@ Backed by `routes/agent.ts`; no CLI wrapper. Session/turn/queue persistence and | `lib/agentToolManifest.ts` | 31 | Tool metadata for `/api/agent/tools` | | `lib/agentPlannerModel.ts` | 201 | Planner model selection | | `lib/agentGenerationPlanner.ts` | 353 | Generation plan assembly | -| `lib/agentImageVideoGen.ts` | 407 | Image/video generation caller for agent turns | +| `lib/agentImageVideoGen.ts` | 416 | Image/video generation caller for agent turns | | `lib/agentQuestionResponder.ts` | 274 | `/question` responder | ## UI File Map @@ -295,7 +295,7 @@ Backed by `routes/agent.ts`; no CLI wrapper. Session/turn/queue persistence and |---|---|---:|---| | App shell | `ui/src/App.tsx` | 194 | Initial hydration, polling, classic/node/card-news canvas switch, Canvas Mode workspace mount, prompt library overlay, mobile shell (dark-only since Phase 010) | | Entry | `ui/src/main.tsx` | 46 | React mount | -| Types | `ui/src/types.ts` | 266 | Provider, quality, size, image model, embedded metadata, response types, web-search, reasoning effort, multimode | +| Types | `ui/src/types.ts` | 267 | Provider, quality, size, image model, embedded metadata, response types, web-search, reasoning effort, multimode | | Canvas types | `ui/src/types/canvas.ts` | 98 | Canvas Mode shared types (annotations, versions, masks, brushes) | | Store | `ui/src/store/useAppStore.ts` | 653 | Zustand facade; classic/node/video/multimode/inflight/history/asset-gen logic split into `ui/src/store/store*Impl.ts` modules | | Persistence registry | `ui/src/store/persistenceRegistry.ts` | 84 | Single source of truth for `ima2.*` localStorage key names — covers gallery scope, gallery default scope, and settings keys (theme keys removed in Phase 010); prevents drift between hydration helpers and setters (#43) | @@ -317,9 +317,9 @@ Backed by `routes/agent.ts`; no CLI wrapper. Session/turn/queue persistence and | Image helpers | `ui/src/lib/image.ts` | 42 | Browser image utilities | | Compression | `ui/src/lib/compress.ts` | 159 | Browser-side image compression for references and uploads | | Cost | `ui/src/lib/cost.ts` | 91 | Quality/size cost estimation | -| Error codes | `ui/src/lib/errorCodes.ts` | 178 | Stable error code → translation key mapping | +| Error codes | `ui/src/lib/errorCodes.ts` | 180 | Stable error code → translation key mapping | | Error handler | `ui/src/lib/errorHandler.ts` | 24 | Routes errors to toast or persistent `ErrorCard` | -| Image models | `ui/src/lib/imageModels.ts` | 124 | UI-side image model labels | +| Image models | `ui/src/lib/imageModels.ts` | 141 | UI-side image model labels | | Video source count | `ui/src/lib/videoSourceCount.ts` | 52 | Effective video source counter for 1080p UI enablement; treats provider URL and node parent still/video sources as single I2V anchors | | Storage | `ui/src/lib/storage.ts` | 26 | localStorage helpers | | Gallery utils | `ui/src/lib/galleryUtils.ts` | 18 | Gallery navigation helpers | diff --git a/tests/cli-capabilities-contract.test.js b/tests/cli-capabilities-contract.test.js index a7cabdec..da499a84 100644 --- a/tests/cli-capabilities-contract.test.js +++ b/tests/cli-capabilities-contract.test.js @@ -27,7 +27,7 @@ describe("CLI capabilities contract", () => { assert.match(src, /moderation:\s*toArray\(appConfig\.oauth\.validModeration\)/); assert.match(src, /modes:\s*\[\.\.\.VALID_MODES\]/); assert.match(src, /providers:\s*\[\.\.\.VALID_PROVIDERS\]/); - assert.match(src, /const VALID_PROVIDERS = \["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"\]/); + assert.match(src, /const VALID_PROVIDERS = \["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"\]/); assert.match(src, /"grok status"/); assert.match(src, /"prompt build"/); assert.match(src, /configKeys:/); diff --git a/tests/cli-feature-parity-contract.test.js b/tests/cli-feature-parity-contract.test.js index f2a3b0e3..30eb0bb0 100644 --- a/tests/cli-feature-parity-contract.test.js +++ b/tests/cli-feature-parity-contract.test.js @@ -15,7 +15,7 @@ describe("CLI feature parity contract", () => { // resolver + GET /api/models catalog instead of a local enum, and lanes // now include the MCP providers. `--provider auto` is removed (v3). assert.match(src, /resolveTarget\(\s*"image"/); - assert.match(src, /--provider /); + assert.match(src, /--provider /); assert.match(src, /'auto' was removed/); assert.match(src, /body\.webSearchEnabled = false/); assert.match(src, /body\.webSearchEnabled = true/); @@ -26,10 +26,10 @@ describe("CLI feature parity contract", () => { const docs = readSource("docs/CLI.md"); assert.match(src, /provider:\s*\{\s*type:\s*"string"\s*\}/); - assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"\]\)/); - assert.match(src, /--provider /); + assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"\]\)/); + assert.match(src, /--provider /); assert.match(src, /nano-banana-2\|nano-banana-pro/); - assert.match(src, /--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud/); + assert.match(src, /--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud, minimax/); assert.match(src, /if \(args\.provider\) editBody\.provider = args\.provider/); assert.match(src, /editBody\.webSearchEnabled = false/); assert.match(src, /editBody\.webSearchEnabled = true/); @@ -43,11 +43,11 @@ describe("CLI feature parity contract", () => { assert.match(src, /fileToDataUri/); assert.match(src, /provider:\s*\{\s*type:\s*"string"\s*\}/); - assert.match(src, /--provider /); + assert.match(src, /--provider /); assert.match(src, /nano-banana-2\|nano-banana-pro/); assert.match(src, /mode:\s*\{\s*type:\s*"string",\s*default:\s*"auto"\s*\}/); assert.match(src, /ref:\s*\{\s*type:\s*"string",\s*repeatable:\s*true\s*\}/); - assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"\]\)/); + assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"\]\)/); assert.match(src, /VALID_MODES = new Set\(\["auto", "direct"\]\)/); assert.match(src, /MAX_REFERENCE_COUNT/); assert.match(src, /refs\.length > MAX_REFERENCE_COUNT/); @@ -63,8 +63,8 @@ describe("CLI feature parity contract", () => { const src = readSource("bin/commands/node.ts"); assert.match(src, /provider:\s*\{\s*type:\s*"string"\s*\}/); - assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud"\]\)/); - assert.match(src, /--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud/); + assert.match(src, /VALID_PROVIDERS = new Set\(\["auto", "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax"\]\)/); + assert.match(src, /--provider must be one of: auto, oauth, api, grok, grok-api, agy, gemini-api, atlascloud, minimax/); assert.match(src, /if \(args\.provider\) body\.provider = args\.provider/); assert.match(src, /body\.webSearchEnabled = false/); assert.match(src, /body\.webSearchEnabled = true/); @@ -90,7 +90,7 @@ describe("CLI feature parity contract", () => { it("public CLI docs describe provider semantics and multimode parity", () => { const docs = readSource("docs/CLI.md"); - assert.match(docs, /--provider /); + assert.match(docs, /--provider /); assert.match(docs, /api` forces the API-key Responses path/); assert.match(docs, /oauth` forces the local OAuth proxy path/); assert.match(docs, /auto` preserves route default behavior/); diff --git a/tests/cli-model-resolver.test.ts b/tests/cli-model-resolver.test.ts index 7cfb8ac8..427f8f7c 100644 --- a/tests/cli-model-resolver.test.ts +++ b/tests/cli-model-resolver.test.ts @@ -34,6 +34,10 @@ function makeCatalog(): ModelCatalog { { image: [{ id: "openai/gpt-image-2/text-to-image" }] }, { image: "openai/gpt-image-2/text-to-image" }, ), + minimax: ready( + { image: [{ id: "image-01" }] }, + { image: "image-01" }, + ), runway: ready( { image: [{ id: "gen-4" }], video: [{ id: "veo-3.1" }] }, { image: "gen-4", video: "veo-3.1" }, diff --git a/tests/i18n-dictionary-contract.test.ts b/tests/i18n-dictionary-contract.test.ts index 33a5ca64..8ad24fb3 100644 --- a/tests/i18n-dictionary-contract.test.ts +++ b/tests/i18n-dictionary-contract.test.ts @@ -70,6 +70,7 @@ const DYNAMIC_T_IDENTIFIERS = new Map([ "settings.imageModel.grokImagineQuality", "settings.imageModel.grokImagine", "settings.imageModel.nanoBanana2", "settings.imageModel.nanoBanana2Api", "settings.imageModel.nanoBananaPro", "settings.imageModel.gpt53CodexSpark", + "settings.imageModel.minimaxImage01", "settings.imageModel.minimaxImage01Live", "settings.videoModel.grokImagine", "settings.videoModel.grokImagine15", "settings.reasoning.none", "settings.reasoning.low", "settings.reasoning.medium", "settings.reasoning.high", "settings.reasoning.xhigh", "settings.reasoning.max", @@ -85,6 +86,7 @@ const DYNAMIC_T_IDENTIFIERS = new Map([ "settings.imageModel.grokImagineQuality", "settings.imageModel.grokImagine", "settings.imageModel.nanoBanana2", "settings.imageModel.nanoBanana2Api", "settings.imageModel.nanoBananaPro", + "settings.imageModel.minimaxImage01", "settings.imageModel.minimaxImage01Live", ]], // REASONING_EFFORT_OPTIONS fullLabelKey literals in ui/src/lib/reasoning.ts. ["ui/src/components/ReasoningEffortSelect.tsx :: option.fullLabelKey", reasoningKeys()], @@ -157,6 +159,7 @@ const DYNAMIC_T_IDENTIFIERS = new Map([ ["ui/src/lib/errorHandler.ts :: spec.toastKey", [ "toast.refTooLarge", "toast.refNotBase64", "toast.refEmpty", "toast.refLimitExceeded", "toast.generateFailed", + "toast.minimaxModelRequiresReference", ]], // toastKey's local complete/partial literals in storeGenImpl.ts. ["ui/src/store/storeGenImpl.ts :: toastKey", ["multimode.complete", "multimode.partial"]], diff --git a/tests/mcp-provider-ui-contract.test.js b/tests/mcp-provider-ui-contract.test.js index 45b703f9..cb8232ed 100644 --- a/tests/mcp-provider-ui-contract.test.js +++ b/tests/mcp-provider-ui-contract.test.js @@ -150,7 +150,10 @@ describe("MCP provider UI contract", () => { assert.match(kit, /groups\?: ReadonlyArray>/); assert.match(kit, /createPortal\(list, document\.body\)/); assert.match(kit, /listRef\.current\?\.contains\(target\)/); - assert.match(kit, /window\.addEventListener\("scroll", close, true\)/); + // Issue #119: the capture-phase scroll listener stays, but it now runs a + // guarded handler so scrolling the portaled list itself does not dismiss it. + assert.match(kit, /window\.addEventListener\("scroll", closeOnScroll, true\)/); + assert.match(kit, /shouldDismissOnScroll\(event, listRef\.current\)/); assert.match(kit, /triggerRef\.current\?\.focus\(\)/); }); diff --git a/tests/minimax-key-validation-route.test.ts b/tests/minimax-key-validation-route.test.ts new file mode 100644 index 00000000..7c3d4a71 --- /dev/null +++ b/tests/minimax-key-validation-route.test.ts @@ -0,0 +1,153 @@ +// Key validation is a C4 boundary: a wrong verdict either stores a dead key or +// bills the user. These cases drive the real Express route, not a helper, so +// the request shape and the "do not persist on failure" rule are both observed. +import test from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { config } from "../config.ts"; +import { mountKeyRoutes } from "../routes/keys.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type UpstreamCall = { url: string; method?: string }; + +function stubUpstream(respond: () => Response, calls: UpstreamCall[]) { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + // The test client talks to our own server; only outbound calls are stubbed. + if (url.startsWith("http://127.0.0.1:")) return originalFetch(input, init); + calls.push({ url, method: init?.method }); + return respond(); + }) as typeof fetch; +} + +async function withKeyRoutes( + region: string, + fn: (args: { baseUrl: string; configFile: string }) => Promise, +) { + const rootDir = await mkdtemp(join(tmpdir(), "ima2-minimax-keys-")); + const configFile = join(rootDir, "config.json"); + const ctx = { + rootDir, + packageVersion: "test", + config: { + ...config, + storage: { ...config.storage, configFile }, + minimaxProvider: { ...config.minimaxProvider, region }, + log: { ...config.log, level: "silent" }, + }, + }; + const app = express(); + app.use(express.json({ limit: "1mb" })); + mountKeyRoutes(app, ctx as never); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const addr = server.address() as import("node:net").AddressInfo; + try { + await fn({ baseUrl: `http://127.0.0.1:${addr.port}`, configFile }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await rm(rootDir, { recursive: true, force: true }); + } +} + +async function savedKey(configFile: string): Promise { + try { + const raw = await readFile(configFile, "utf8"); + return JSON.parse(raw).minimaxApiKey; + } catch { + return undefined; + } +} + +const modelList = () => Response.json({ + object: "list", + data: [{ id: "image-01", object: "model" }], +}); + +test("minimax key validation lists models instead of generating an image", async () => { + const calls: UpstreamCall[] = []; + stubUpstream(modelList, calls); + + await withKeyRoutes("global_en", async ({ baseUrl, configFile }) => { + const res = await fetch(`${baseUrl}/api/keys/minimax`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "mm-valid-key" }), + }); + assert.equal(res.status, 200); + assert.equal(await savedKey(configFile), "mm-valid-key"); + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.minimax.io/v1/models"); + assert.equal(calls[0].method, "GET"); + // Generating an image would bill the user on every key save. + assert.doesNotMatch(calls[0].url, /image_generation/); +}); + +test("minimax key validation targets the China host for the cn_zh region", async () => { + const calls: UpstreamCall[] = []; + stubUpstream(modelList, calls); + + await withKeyRoutes("cn_zh", async ({ baseUrl }) => { + await fetch(`${baseUrl}/api/keys/minimax`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "mm-cn-key" }), + }); + }); + + assert.equal(calls[0].url, "https://api.minimaxi.com/v1/models"); +}); + +const rejectionCases: Array<{ name: string; respond: () => Response }> = [ + { name: "401 auth failure", respond: () => new Response("unauthorized", { status: 401 }) }, + { name: "429 rate limit", respond: () => new Response("slow down", { status: 429 }) }, + { name: "500 upstream error", respond: () => new Response("boom", { status: 500 }) }, + { name: "unparseable body", respond: () => new Response("nope", { status: 200 }) }, + { + name: "200 carrying an error status_code", + respond: () => Response.json({ data: [], base_resp: { status_code: 1008, status_msg: "insufficient balance" } }), + }, + { + name: "200 without a model list", + respond: () => Response.json({ object: "list" }), + }, + { + // Type drift is not permission to store the key. + name: "200 carrying a string error status_code", + respond: () => Response.json({ data: [], base_resp: { status_code: "1008" } }), + }, + { + name: "200 with an unreadable status_code", + respond: () => Response.json({ data: [], base_resp: { status_code: {} } }), + }, +]; + +for (const testCase of rejectionCases) { + test(`minimax key validation fails closed on ${testCase.name}`, async () => { + stubUpstream(testCase.respond, []); + + await withKeyRoutes("global_en", async ({ baseUrl, configFile }) => { + const res = await fetch(`${baseUrl}/api/keys/minimax`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "mm-suspect-key" }), + }); + assert.equal(res.status, 400); + const body = await res.json() as { code?: string }; + assert.equal(body.code, "KEY_VALIDATION_FAILED"); + // The key must never reach disk when validation did not clearly succeed. + assert.equal(await savedKey(configFile), undefined); + }); + }); +} diff --git a/tests/minimax-provider-contract.test.ts b/tests/minimax-provider-contract.test.ts new file mode 100644 index 00000000..4976b41b --- /dev/null +++ b/tests/minimax-provider-contract.test.ts @@ -0,0 +1,409 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateViaMinimax, MINIMAX_TEXT_TO_IMAGE_MODEL, MINIMAX_IMAGE_TO_IMAGE_MODEL } from "../lib/minimaxImageAdapter.ts"; +import { resolveProviderOptions } from "../lib/providerOptions.ts"; +import { createTestRuntimeContext } from "../lib/runtimeContext.ts"; + +const originalFetch = globalThis.fetch; + +// Real magic bytes: the adapter validates payloads against the detected image +// signature, so placeholder text would (correctly) be rejected as non-image. +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]); +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]); +const JPEG_B64 = JPEG_BYTES.toString("base64"); +const PNG_B64 = PNG_BYTES.toString("base64"); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function minimaxCtx(over: Record = {}) { + return createTestRuntimeContext({ + minimaxApiKey: "mm-test-key", + config: { + minimaxProvider: { + defaultImageModel: "image-01", + region: "global_en", + globalBaseUrl: "https://api.minimax.io/v1", + cnBaseUrl: "https://api.minimaxi.com/v1", + generationTimeoutMs: 120_000, + }, + }, + ...over, + } as never); +} + +test("minimax provider options normalize model and disable unsupported controls", () => { + const resolved = resolveProviderOptions(minimaxCtx(), { + provider: "minimax", + rawModel: "image-01", + rawReasoningEffort: "high", + rawWebSearchEnabled: true, + rawSize: "1024x1024", + }); + + assert.equal(resolved.provider, "minimax"); + assert.equal(resolved.model, "image-01"); + assert.equal(resolved.reasoningEffort, "none"); + assert.equal(resolved.webSearchEnabled, false); + assert.equal(resolved.size, "1024x1024"); +}); + +test("minimax provider options reject an unknown model", () => { + const resolved = resolveProviderOptions(minimaxCtx(), { + provider: "minimax", + rawModel: "image-99", + }); + assert.equal(resolved.code, "INVALID_MINIMAX_IMAGE_MODEL"); + assert.equal(resolved.status, 400); +}); + +test("minimax adapter requires MINIMAX_API_KEY", async () => { + await assert.rejects( + () => generateViaMinimax("city skyline", createTestRuntimeContext()), + (err: any) => err?.code === "MINIMAX_API_KEY_MISSING" && err?.status === 401, + ); +}); + +test("minimax adapter submits a text-to-image request and parses a url response", async () => { + const calls: Array<{ url: string; body?: any; headers?: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : undefined; + const headers = init?.headers as Record | undefined; + calls.push({ url, body, headers }); + + if (url === "https://api.minimax.io/v1/image_generation") { + return Response.json({ + data: { image_urls: ["https://cdn.example/out.jpg"] }, + metadata: { success_count: 1, failed_count: 0 }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + if (url === "https://cdn.example/out.jpg") { + return new Response(JPEG_BYTES, { headers: { "content-type": "image/jpeg" } }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + const result = await generateViaMinimax("city skyline", minimaxCtx(), { + model: "image-01", + size: "1024x1024", + }); + + assert.equal(calls[0].url, "https://api.minimax.io/v1/image_generation"); + assert.equal(calls[0].headers?.Authorization, "Bearer mm-test-key"); + assert.equal(calls[0].headers?.["Content-Type"], "application/json"); + assert.deepEqual(calls[0].body, { + model: "image-01", + prompt: "city skyline", + response_format: "url", + aspect_ratio: "1:1", + }); + assert.equal(result.b64, JPEG_B64); + assert.equal(result.mime, "image/jpeg"); + assert.equal(result.providerUrl, "https://cdn.example/out.jpg"); +}); + +test("minimax adapter maps references to subject_reference and keeps the requested model", async () => { + const calls: Array<{ body?: any }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : undefined; + if (url === "https://api.minimax.io/v1/image_generation") { + calls.push({ body }); + return Response.json({ + data: { image_base64: [PNG_B64] }, + metadata: { success_count: 1, failed_count: 0 }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + const result = await generateViaMinimax("same character", minimaxCtx(), { + model: MINIMAX_TEXT_TO_IMAGE_MODEL, + references: [{ b64: Buffer.from("ref").toString("base64"), declaredMime: "image/png" }], + }); + + // image-01 accepts subject_reference, so attaching one must not silently + // swap the model the user picked (the stored provenance would then lie). + assert.equal(calls[0].body.model, MINIMAX_TEXT_TO_IMAGE_MODEL); + assert.equal(result.effectiveModel, MINIMAX_TEXT_TO_IMAGE_MODEL); + assert.ok(Array.isArray(calls[0].body.subject_reference)); + assert.equal(calls[0].body.subject_reference[0].type, "character"); + assert.match(calls[0].body.subject_reference[0].image_file, /^data:image\/png;base64,/); + assert.equal(result.b64, PNG_B64); +}); + +test("minimax adapter routes to the China base url for the cn_zh region", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input); + urls.push(url); + if (url === "https://api.minimaxi.com/v1/image_generation") { + return Response.json({ + data: { image_base64: [PNG_B64] }, + metadata: { success_count: 1, failed_count: 0 }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + const cnCtx = minimaxCtx({ + config: { + minimaxProvider: { + defaultImageModel: "image-01", + region: "cn_zh", + globalBaseUrl: "https://api.minimax.io/v1", + cnBaseUrl: "https://api.minimaxi.com/v1", + generationTimeoutMs: 120_000, + }, + }, + }); + await generateViaMinimax("city skyline", cnCtx, { model: MINIMAX_TEXT_TO_IMAGE_MODEL }); + assert.ok(urls.includes("https://api.minimaxi.com/v1/image_generation")); +}); + +test("minimax adapter rejects more than one subject reference", async () => { + await assert.rejects( + () => generateViaMinimax("two refs", minimaxCtx(), { + references: [ + { b64: "AAAA", declaredMime: "image/png" }, + { b64: "BBBB", declaredMime: "image/png" }, + ], + }), + (err: any) => err?.code === "MINIMAX_REF_TOO_MANY" && err?.status === 400, + ); +}); + +test("minimax adapter surfaces content-safety blocks as a safety error", async () => { + globalThis.fetch = (async () => { + return Response.json({ + data: { image_urls: [] }, + metadata: { success_count: 0, failed_count: 1 }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("blocked", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_SAFETY_BLOCKED" && err?.status === 400, + ); +}); + +test("minimax adapter surfaces upstream base_resp auth failures", async () => { + globalThis.fetch = (async () => { + return Response.json({ + base_resp: { status_code: 2049, status_msg: "invalid api key" }, + }); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_AUTH_FAILED" && err?.status === 401, + ); +}); + +// ── Repair coverage: each case drives the branch it guards ──────────────── + +test("minimax adapter keeps image-01-live when a reference is attached", async () => { + const calls: Array<{ body?: any }> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : undefined; + calls.push({ body }); + return Response.json({ + data: { image_base64: [PNG_B64] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + }) as typeof fetch; + + const result = await generateViaMinimax("same character", minimaxCtx(), { + model: MINIMAX_IMAGE_TO_IMAGE_MODEL, + references: [{ b64: Buffer.from("ref").toString("base64"), declaredMime: "image/png" }], + }); + + assert.equal(calls[0].body.model, MINIMAX_IMAGE_TO_IMAGE_MODEL); + assert.equal(result.effectiveModel, MINIMAX_IMAGE_TO_IMAGE_MODEL); +}); + +test("minimax adapter rejects image-01-live without a reference outside China", async () => { + globalThis.fetch = (async () => { + throw new Error("must not reach the API"); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx(), { model: MINIMAX_IMAGE_TO_IMAGE_MODEL }), + (err: any) => err?.code === "MINIMAX_MODEL_REQUIRES_REFERENCE" && err?.status === 400, + ); +}); + +test("minimax adapter allows image-01-live text-to-image in the cn_zh region", async () => { + const calls: Array<{ url: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push({ url: String(input) }); + return Response.json({ + data: { image_base64: [PNG_B64] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + }) as typeof fetch; + + const cnCtx = minimaxCtx({ + config: { + minimaxProvider: { + defaultImageModel: "image-01", + region: "cn_zh", + globalBaseUrl: "https://api.minimax.io/v1", + cnBaseUrl: "https://api.minimaxi.com/v1", + generationTimeoutMs: 120_000, + }, + }, + }); + const result = await generateViaMinimax("city skyline", cnCtx, { + model: MINIMAX_IMAGE_TO_IMAGE_MODEL, + }); + + assert.equal(calls[0].url, "https://api.minimaxi.com/v1/image_generation"); + assert.equal(result.effectiveModel, MINIMAX_IMAGE_TO_IMAGE_MODEL); +}); + +test("minimax adapter maps a request timeout to 504, not a network failure", async () => { + globalThis.fetch = (async () => { + // AbortSignal.timeout() rejects with TimeoutError, not AbortError. + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "GENERATION_TIMEOUT" && err?.status === 504, + ); +}); + +test("minimax adapter reads string safety counters as a content block", async () => { + globalThis.fetch = (async () => Response.json({ + data: {}, + // MiniMax documents these counters as strings in its response samples. + metadata: { success_count: "0", failed_count: "1" }, + base_resp: { status_code: 0, status_msg: "success" }, + })) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("blocked prompt", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_SAFETY_BLOCKED" && err?.status === 400, + ); +}); + +test("minimax adapter rejects a download that declares more than 50MB", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/image_generation")) { + return Response.json({ + data: { image_urls: ["https://cdn.example/huge.png"] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + return new Response(PNG_BYTES, { + headers: { "content-type": "image/png", "content-length": String(64 * 1024 * 1024) }, + }); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE", + ); +}); + +test("minimax adapter caps a stream that lies about its length", async () => { + const chunk = new Uint8Array(1024 * 1024); + chunk.set(PNG_BYTES); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/image_generation")) { + return Response.json({ + data: { image_urls: ["https://cdn.example/endless.png"] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + // No content-length: the cap has to hold while the bytes arrive. + return new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }), + { headers: { "content-type": "image/png" } }, + ); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE", + ); +}); + +test("minimax adapter rejects a non-HTTP provider url", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/image_generation")) { + return Response.json({ + data: { image_urls: ["file:///etc/passwd"] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_DOWNLOAD_FAILED", + ); +}); + +test("minimax adapter rejects a downloaded body that is not an image", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/image_generation")) { + return Response.json({ + data: { image_urls: ["https://cdn.example/error.html"] }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + } + // A CDN error page served with a lying image content-type. + return new Response(Buffer.from("gateway error"), { + headers: { "content-type": "image/png" }, + }); + }) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_INVALID" && err?.status === 502, + ); +}); + +test("minimax adapter rejects inline base64 that is not an image", async () => { + globalThis.fetch = (async () => Response.json({ + data: { image_base64: [Buffer.from("nope").toString("base64")] }, + base_resp: { status_code: 0, status_msg: "success" }, + })) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_INVALID", + ); +}); + +test("minimax adapter rejects an oversized inline base64 payload", async () => { + // MiniMax could ignore response_format:"url" and inline a huge payload; the + // URL path is capped, so the inline path has to be capped too. + const huge = PNG_B64 + "A".repeat(80 * 1024 * 1024); + globalThis.fetch = (async () => Response.json({ + data: { image_base64: [huge] }, + base_resp: { status_code: 0, status_msg: "success" }, + })) as typeof fetch; + + await assert.rejects( + () => generateViaMinimax("city skyline", minimaxCtx()), + (err: any) => err?.code === "MINIMAX_IMAGE_DOWNLOAD_TOO_LARGE", + ); +}); diff --git a/tests/minimax-ui-registration-contract.test.ts b/tests/minimax-ui-registration-contract.test.ts new file mode 100644 index 00000000..0b18ff63 --- /dev/null +++ b/tests/minimax-ui-registration-contract.test.ts @@ -0,0 +1,49 @@ +// MiniMax web-UI registration. The adapter can produce a good error message, +// but the user only sees it if the code survives SSE parsing and the error +// registry — an unregistered code collapses to a generic "Generation failed". +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSseErrorPayload } from "../ui/src/lib/sseStreamError.ts"; +import { resolveErrorSpec } from "../ui/src/lib/errorCodes.ts"; +import { + MINIMAX_IMAGE_MODEL_OPTIONS, + OPENAI_IMAGE_MODEL_OPTIONS, + getImageModelOptionsForProvider, + isMinimaxImageModel, +} from "../ui/src/lib/imageModels.ts"; + +test("a MiniMax model-requires-reference error reaches the user as its own toast", () => { + // Exactly what routes/events.ts publishes for a failed job. + const parsed = parseSseErrorPayload({ + error: { + message: "MiniMax image-01-live requires a reference image outside the China region. " + + "Attach a reference or switch to image-01.", + code: "MINIMAX_MODEL_REQUIRES_REFERENCE", + }, + code: "MINIMAX_MODEL_REQUIRES_REFERENCE", + status: 400, + }); + + assert.equal(parsed.code, "MINIMAX_MODEL_REQUIRES_REFERENCE"); + + const { code, spec } = resolveErrorSpec(parsed); + // Collapsing to UNKNOWN would replace the actionable text with a generic one. + assert.notEqual(code, "UNKNOWN"); + assert.equal(code, "MINIMAX_MODEL_REQUIRES_REFERENCE"); + assert.equal(spec.surface, "toast"); + assert.equal(spec.toastKey, "toast.minimaxModelRequiresReference"); +}); + +test("minimax models are offered for the minimax provider only", () => { + const values = MINIMAX_IMAGE_MODEL_OPTIONS.map((option) => option.value); + assert.deepEqual(values, ["image-01", "image-01-live"]); + + assert.deepEqual( + getImageModelOptionsForProvider("minimax").map((option) => option.value), + values, + ); + // They must not leak into the default GPT list. + for (const option of OPENAI_IMAGE_MODEL_OPTIONS) { + assert.equal(isMinimaxImageModel(option.value), false); + } +}); diff --git a/tests/models-endpoint-contract.test.ts b/tests/models-endpoint-contract.test.ts index fe0f49e7..893c7999 100644 --- a/tests/models-endpoint-contract.test.ts +++ b/tests/models-endpoint-contract.test.ts @@ -107,7 +107,7 @@ test("GET /api/models returns every canonical lane with deterministic statuses a const body = await response.json() as ModelsBody; assert.equal(body.ok, true); assert.deepEqual(Object.keys(body.lanes), [ - "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "runway", "higgsfield", + "oauth", "api", "grok", "grok-api", "agy", "gemini-api", "atlascloud", "minimax", "runway", "higgsfield", ]); assert.equal(body.lanes.oauth.status, "ready"); @@ -122,6 +122,10 @@ test("GET /api/models returns every canonical lane with deterministic statuses a assert.deepEqual(body.lanes.atlascloud.models.image.map((model) => model.id), [ "openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit", ]); + assert.equal(body.lanes.minimax.status, "key-missing"); + assert.deepEqual(body.lanes.minimax.models.image.map((model) => model.id), [ + "image-01", "image-01-live", + ]); assert.equal(body.lanes.runway.status, "disconnected"); assert.equal(body.lanes.higgsfield.status, "disconnected"); assert.match(body.lanes.higgsfield.reason ?? "", /MCP connection disconnected/); diff --git a/tests/node-error-info-contract.test.ts b/tests/node-error-info-contract.test.ts index 76d3f65d..15cb9d70 100644 --- a/tests/node-error-info-contract.test.ts +++ b/tests/node-error-info-contract.test.ts @@ -14,6 +14,7 @@ const EXPECTED: Record = { REF_NOT_BASE64: "fix-input", REF_EMPTY: "fix-input", REF_TOO_MANY: "fix-input", + MINIMAX_MODEL_REQUIRES_REFERENCE: "fix-input", MODERATION_REFUSED: "fix-input", SAFETY_REFUSAL: "fix-input", EMPTY_RESPONSE: "retry", diff --git a/tests/portal-dropdown-scroll-dismiss-contract.test.ts b/tests/portal-dropdown-scroll-dismiss-contract.test.ts new file mode 100644 index 00000000..9e410fb5 --- /dev/null +++ b/tests/portal-dropdown-scroll-dismiss-contract.test.ts @@ -0,0 +1,70 @@ +// Issue #119: a portaled Select closed as soon as the user scrolled its own +// option list, because the capture-phase `scroll` listener on `window` also +// receives scrolls raised inside the portaled list. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { shouldDismissOnScroll } from "../ui/src/lib/portalDismiss.ts"; + +type FakeNode = { nodeType: number }; + +const makeMenu = (owned: FakeNode[]) => ({ + contains: (node: Node) => owned.includes(node as unknown as FakeNode), +}); + +const scrollFrom = (target: unknown) => ({ target }) as Pick; + +test("scroll raised inside the portaled list keeps the menu open", () => { + const inner: FakeNode = { nodeType: 1 }; + assert.equal(shouldDismissOnScroll(scrollFrom(inner), makeMenu([inner])), false); +}); + +test("scroll raised on a descendant of the list also keeps the menu open", () => { + // `contains()` is true for descendants, so an option row scrolling its own + // container must not dismiss either. + const list: FakeNode = { nodeType: 1 }; + const option: FakeNode = { nodeType: 1 }; + const menu = makeMenu([list, option]); + assert.equal(shouldDismissOnScroll(scrollFrom(option), menu), false); +}); + +test("scroll raised outside the menu still dismisses it", () => { + const outside: FakeNode = { nodeType: 1 }; + assert.equal(shouldDismissOnScroll(scrollFrom(outside), makeMenu([{ nodeType: 1 }])), true); +}); + +test("document-level scroll dismisses the menu", () => { + const documentLike: FakeNode = { nodeType: 9 }; + assert.equal(shouldDismissOnScroll(scrollFrom(documentLike), makeMenu([{ nodeType: 1 }])), true); +}); + +test("a window target without nodeType dismisses instead of throwing", () => { + const windowLike = { innerWidth: 1280 }; + const menu = { + contains: () => { + throw new Error("contains() must not be called for non-Node targets"); + }, + }; + assert.equal(shouldDismissOnScroll(scrollFrom(windowLike), menu), true); +}); + +test("a missing menu ref or missing event falls back to dismissing", () => { + assert.equal(shouldDismissOnScroll(undefined, null), true); + assert.equal(shouldDismissOnScroll(scrollFrom({ nodeType: 1 }), null), true); + assert.equal(shouldDismissOnScroll(scrollFrom(null), makeMenu([{ nodeType: 1 }])), true); +}); + +// Wiring: the guard existing is not enough — the portaled Select must actually +// register the guarded handler on the capture phase and clean it up. +const select = readFileSync("ui/src/components/controls/Select.tsx", "utf8"); + +test("the portaled Select registers the guarded scroll handler", () => { + assert.match(select, /import \{ shouldDismissOnScroll \} from "\.\.\/\.\.\/lib\/portalDismiss"/); + assert.match(select, /shouldDismissOnScroll\(event, listRef\.current\)/); + assert.match(select, /window\.addEventListener\("scroll", closeOnScroll, true\)/); + assert.match(select, /window\.removeEventListener\("scroll", closeOnScroll, true\)/); + // The unguarded handler must not come back. + assert.doesNotMatch(select, /window\.addEventListener\("scroll", close, true\)/); + // resize keeps dismissing unconditionally: it always invalidates the position. + assert.match(select, /window\.addEventListener\("resize", close\)/); +}); diff --git a/tests/reference-limits.test.ts b/tests/reference-limits.test.ts index fc734f90..eedbcf63 100644 --- a/tests/reference-limits.test.ts +++ b/tests/reference-limits.test.ts @@ -1,7 +1,7 @@ // Provider-aware composer reference caps (mirrors server hard limits). import { test } from "node:test"; import assert from "node:assert/strict"; -import { effectiveReferenceLimit, GROK_FAMILY_IMAGE_REF_LIMIT, GROK_VIDEO_REF_LIMIT } from "../ui/src/lib/referenceLimits.ts"; +import { effectiveReferenceLimit, GROK_FAMILY_IMAGE_REF_LIMIT, GROK_VIDEO_REF_LIMIT, MINIMAX_IMAGE_REF_LIMIT } from "../ui/src/lib/referenceLimits.ts"; const base = { serverLimit: 5, videoModelSelected: false, mcpProvider: null }; @@ -28,3 +28,11 @@ test("MCP lane caps at the 3-reference tool contract (temp uploads enabled)", () test("the effective limit never exceeds the server limit", () => { assert.equal(effectiveReferenceLimit({ ...base, provider: "grok", serverLimit: 2 }), 2); }); + +test("minimax caps at a single subject reference", () => { + // The adapter rejects a second reference with MINIMAX_REF_TOO_MANY, so the + // tray has to stop the user at attach time rather than at generate time. + assert.equal(effectiveReferenceLimit({ ...base, provider: "minimax" }), MINIMAX_IMAGE_REF_LIMIT); + // A lower server capability still wins. + assert.equal(effectiveReferenceLimit({ ...base, provider: "minimax", serverLimit: 0 }), 0); +}); diff --git a/ui/src/components/AccountSettings.tsx b/ui/src/components/AccountSettings.tsx index cbce11c0..f36caaf1 100644 --- a/ui/src/components/AccountSettings.tsx +++ b/ui/src/components/AccountSettings.tsx @@ -189,6 +189,15 @@ export function AccountSettings() { configured={keyStatus.atlascloud?.configured ?? false} onSaved={mutateKeys} /> + = { value: "agy", label: "agy" }, { value: "gemini-api", label: "Gem API" }, { value: "atlascloud", label: "Atlas" }, + { value: "minimax", label: "MiniMax" }, ]; const MCP_PREFIX = "mcp:"; diff --git a/ui/src/components/ResultMetadataModal.tsx b/ui/src/components/ResultMetadataModal.tsx index 30cd6e83..fd7c26da 100644 --- a/ui/src/components/ResultMetadataModal.tsx +++ b/ui/src/components/ResultMetadataModal.tsx @@ -24,6 +24,7 @@ const PROVIDER_LABELS: Record = { agy: "Antigravity Gemini CLI", "gemini-api": "Gemini API / Vertex", atlascloud: "Atlas Cloud API", + minimax: "MiniMax API", }; function present(value: unknown): value is string | number | boolean { diff --git a/ui/src/components/controls/Select.tsx b/ui/src/components/controls/Select.tsx index 67378b7d..b40010a5 100644 --- a/ui/src/components/controls/Select.tsx +++ b/ui/src/components/controls/Select.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; +import { shouldDismissOnScroll } from "../../lib/portalDismiss"; export type SelectItem = { value: V; @@ -181,11 +182,18 @@ export function Select({ }); } const close = () => setOpen(false); + const closeOnScroll = (event: Event) => { + // Issue #119: the capture-phase listener also sees scrolls raised inside + // the portaled list, which is itself a scroll container. Only outside + // scrolls detach the fixed panel from its trigger. + if (!shouldDismissOnScroll(event, listRef.current)) return; + setOpen(false); + }; window.addEventListener("resize", close); - window.addEventListener("scroll", close, true); + window.addEventListener("scroll", closeOnScroll, true); return () => { window.removeEventListener("resize", close); - window.removeEventListener("scroll", close, true); + window.removeEventListener("scroll", closeOnScroll, true); }; }, [portal, open]); diff --git a/ui/src/components/home/HomePromptComposer.tsx b/ui/src/components/home/HomePromptComposer.tsx index cf565313..c74f653e 100644 --- a/ui/src/components/home/HomePromptComposer.tsx +++ b/ui/src/components/home/HomePromptComposer.tsx @@ -15,6 +15,7 @@ const PROVIDER_LABELS: Record = { agy: "Antigravity", "gemini-api": "Gemini API", atlascloud: "Atlas Cloud", + minimax: "MiniMax", }; function homeReferenceThumbnail(item: TrayItem): string | undefined { diff --git a/ui/src/components/settings/ProviderStatusSelect.tsx b/ui/src/components/settings/ProviderStatusSelect.tsx index 5ddc0587..4d4f118d 100644 --- a/ui/src/components/settings/ProviderStatusSelect.tsx +++ b/ui/src/components/settings/ProviderStatusSelect.tsx @@ -25,6 +25,7 @@ const CORE_ENTRIES: ReadonlyArray = [ { value: "agy", provider: "Gemini", method: "agy" }, { value: "gemini-api", provider: "Gemini", method: "API" }, { value: "atlascloud", provider: "Atlas Cloud", method: "API" }, + { value: "minimax", provider: "MiniMax", method: "API" }, ]; function displayProviderId(id: string): string { diff --git a/ui/src/hooks/useKeyStatus.ts b/ui/src/hooks/useKeyStatus.ts index c0ea9a94..763f429c 100644 --- a/ui/src/hooks/useKeyStatus.ts +++ b/ui/src/hooks/useKeyStatus.ts @@ -7,7 +7,7 @@ interface KeyStatusEntry { maskedKey: string | null; } -export type KeyStatus = Record<"openai" | "xai" | "gemini" | "atlascloud" | "vertex", KeyStatusEntry> & { +export type KeyStatus = Record<"openai" | "xai" | "gemini" | "atlascloud" | "minimax" | "vertex", KeyStatusEntry> & { geminiAuthMode?: "apikey" | "vertex"; }; diff --git a/ui/src/hooks/useProviderAvailability.ts b/ui/src/hooks/useProviderAvailability.ts index dfac505b..e1c384cb 100644 --- a/ui/src/hooks/useProviderAvailability.ts +++ b/ui/src/hooks/useProviderAvailability.ts @@ -49,6 +49,7 @@ export function useProviderAvailability(): Record = { REF_NOT_BASE64: { surface: "toast", toastKey: "toast.refNotBase64" }, REF_EMPTY: { surface: "toast", toastKey: "toast.refEmpty" }, REF_TOO_MANY: { surface: "toast", toastKey: "toast.refLimitExceeded" }, + MINIMAX_MODEL_REQUIRES_REFERENCE: { surface: "toast", toastKey: "toast.minimaxModelRequiresReference" }, MODERATION_REFUSED: { surface: "card", cardKey: "errorCard.moderationRefused", cta: "dismiss" }, SAFETY_REFUSAL: { surface: "card", cardKey: "errorCard.moderationRefused", cta: "dismiss" }, EMPTY_RESPONSE: { surface: "card", cardKey: "errorCard.emptyResponse", cta: "dismiss" }, diff --git a/ui/src/lib/imageModels.ts b/ui/src/lib/imageModels.ts index 567c3074..2836ed65 100644 --- a/ui/src/lib/imageModels.ts +++ b/ui/src/lib/imageModels.ts @@ -1,4 +1,4 @@ -import type { ImageModel, OpenAIImageModel, GeminiImageModel, AtlasCloudImageModel, Provider, UnsupportedImageModel, VideoModel } from "../types"; +import type { ImageModel, OpenAIImageModel, GeminiImageModel, AtlasCloudImageModel, MinimaxImageModel, Provider, UnsupportedImageModel, VideoModel } from "../types"; export const DEFAULT_IMAGE_MODEL: ImageModel = "gpt-5.6-luna"; export const IMAGE_MODEL_STORAGE_KEY = "ima2.imageModel"; @@ -22,14 +22,20 @@ export const IMAGE_MODEL_OPTIONS: Array<{ { value: "nano-banana-pro", shortLabel: "nbp api", fullLabelKey: "settings.imageModel.nanoBananaPro", providerHint: "gemini-api" }, { value: "openai/gpt-image-2/text-to-image", shortLabel: "atlas", fullLabelKey: "settings.imageModel.atlasCloudGptImage2", providerHint: "atlascloud" }, { value: "openai/gpt-image-2/edit", shortLabel: "atlas edit", fullLabelKey: "settings.imageModel.atlasCloudGptImage2Edit", providerHint: "atlascloud" }, + { value: "image-01", shortLabel: "minimax", fullLabelKey: "settings.imageModel.minimaxImage01", providerHint: "minimax" }, + { value: "image-01-live", shortLabel: "minimax live", fullLabelKey: "settings.imageModel.minimaxImage01Live", providerHint: "minimax" }, ]; const GEMINI_MODEL_VALUES = new Set(["nano-banana-2", "nano-banana-pro"]); const ATLASCLOUD_MODEL_VALUES = new Set(["openai/gpt-image-2/text-to-image", "openai/gpt-image-2/edit"]); +const MINIMAX_MODEL_VALUES = new Set(["image-01", "image-01-live"]); export const OPENAI_IMAGE_MODEL_OPTIONS = IMAGE_MODEL_OPTIONS.filter( (option): option is { value: OpenAIImageModel; shortLabel: string; fullLabelKey: string } => - !option.value.startsWith("grok-") && !GEMINI_MODEL_VALUES.has(option.value) && !ATLASCLOUD_MODEL_VALUES.has(option.value), + !option.value.startsWith("grok-") + && !GEMINI_MODEL_VALUES.has(option.value) + && !ATLASCLOUD_MODEL_VALUES.has(option.value) + && !MINIMAX_MODEL_VALUES.has(option.value), ); export const GROK_IMAGE_MODEL_OPTIONS = IMAGE_MODEL_OPTIONS.filter((option) => @@ -46,6 +52,11 @@ export const ATLASCLOUD_IMAGE_MODEL_OPTIONS = IMAGE_MODEL_OPTIONS.filter( ATLASCLOUD_MODEL_VALUES.has(option.value), ); +export const MINIMAX_IMAGE_MODEL_OPTIONS = IMAGE_MODEL_OPTIONS.filter( + (option): option is { value: MinimaxImageModel; shortLabel: string; fullLabelKey: string; providerHint?: Provider } => + MINIMAX_MODEL_VALUES.has(option.value), +); + export const UNSUPPORTED_IMAGE_MODELS: Array<{ value: UnsupportedImageModel; fullLabelKey: string; @@ -69,10 +80,15 @@ export function isAtlasCloudImageModel(value: unknown): boolean { return typeof value === "string" && ATLASCLOUD_MODEL_VALUES.has(value); } +export function isMinimaxImageModel(value: unknown): boolean { + return typeof value === "string" && MINIMAX_MODEL_VALUES.has(value); +} + export function getImageModelOptionsForProvider(provider: Provider) { if (provider === "grok" || provider === "grok-api") return GROK_IMAGE_MODEL_OPTIONS; if (provider === "agy" || provider === "gemini-api") return GEMINI_IMAGE_MODEL_OPTIONS; if (provider === "atlascloud") return ATLASCLOUD_IMAGE_MODEL_OPTIONS; + if (provider === "minimax") return MINIMAX_IMAGE_MODEL_OPTIONS; return OPENAI_IMAGE_MODEL_OPTIONS; } @@ -83,6 +99,7 @@ export function getImageModelShortLabel(value: string | null | undefined, provid return `${value} ${suffix}`; } if (ATLASCLOUD_MODEL_VALUES.has(value)) return provider === "atlascloud" ? "gpt-image-2 atlas" : value; + if (MINIMAX_MODEL_VALUES.has(value)) return provider === "minimax" ? `${value} minimax` : value; return IMAGE_MODEL_OPTIONS.find((option) => option.value === value)?.shortLabel ?? value; } diff --git a/ui/src/lib/portalDismiss.ts b/ui/src/lib/portalDismiss.ts new file mode 100644 index 00000000..0b65f98e --- /dev/null +++ b/ui/src/lib/portalDismiss.ts @@ -0,0 +1,25 @@ +/** + * Dismiss policy for portaled overlays that listen to scroll on `window` + * during the capture phase. + * + * A capture-phase listener on `window` also receives scroll events that + * originated inside the portaled menu, because capture runs window -> document + * -> target regardless of the fact that `scroll` does not bubble. Dismissing on + * those closes the menu the moment the user scrolls its own list (issue #119). + * + * Only scrolls from OUTSIDE the menu move the trigger and detach a + * fixed-position panel from it, so only those should dismiss. + */ +export function shouldDismissOnScroll( + event: Pick | undefined, + menu: { contains(node: Node): boolean } | null | undefined, +): boolean { + if (!event) return true; + if (!menu) return true; + const target = event.target; + // `window` targets have no nodeType and cannot be passed to contains(). + if (target && typeof target === "object" && "nodeType" in target) { + if (menu.contains(target as Node)) return false; + } + return true; +} diff --git a/ui/src/lib/referenceLimits.ts b/ui/src/lib/referenceLimits.ts index f918577f..12569352 100644 --- a/ui/src/lib/referenceLimits.ts +++ b/ui/src/lib/referenceLimits.ts @@ -5,10 +5,12 @@ // - routes/mcpMedia.ts: MCP lane takes up to 3 references [{filename, tag}]; // direct attachments ride POST /api/mcp/temp-references (batch upload into // generated storage), so the tray cap is 3 (composer-tray 010 A5). +// - lib/minimaxImageAdapter.ts: MiniMax takes a single subject_reference // - gpt oauth/api: server capabilities.limits.maxRefCount (referenceLimit) import type { Provider } from "../types"; export const GROK_FAMILY_IMAGE_REF_LIMIT = 3; +export const MINIMAX_IMAGE_REF_LIMIT = 1; export const GROK_VIDEO_REF_LIMIT = 7; export const MCP_REFERENCE_LIMIT = 3; @@ -22,6 +24,9 @@ export function effectiveReferenceLimit(input: { }): number { if (input.mcpProvider) return MCP_REFERENCE_LIMIT; if (input.videoModelSelected) return Math.min(input.serverLimit, GROK_VIDEO_REF_LIMIT); + if (input.provider === "minimax") { + return Math.min(input.serverLimit, MINIMAX_IMAGE_REF_LIMIT); + } if (LIMITED_IMAGE_PROVIDERS.has(input.provider)) { return Math.min(input.serverLimit, GROK_FAMILY_IMAGE_REF_LIMIT); } diff --git a/ui/src/store/storeHelpers.ts b/ui/src/store/storeHelpers.ts index a428851a..23dc8d80 100644 --- a/ui/src/store/storeHelpers.ts +++ b/ui/src/store/storeHelpers.ts @@ -342,7 +342,7 @@ export function getCustomSizeConfirmation( state: AppState, continuation: NonNullable["continuation"], ): CustomSizeConfirmState { - if (state.provider === "grok" || state.provider === "grok-api" || state.provider === "agy" || state.provider === "gemini-api" || state.provider === "atlascloud") return null; + if (state.provider === "grok" || state.provider === "grok-api" || state.provider === "agy" || state.provider === "gemini-api" || state.provider === "atlascloud" || state.provider === "minimax") return null; if (state.sizePreset !== "custom") return null; const result = normalizeCustomSizePairDetailed( state.customW, diff --git a/ui/src/store/storePersistence.ts b/ui/src/store/storePersistence.ts index fe3c7dfb..6cb317b8 100644 --- a/ui/src/store/storePersistence.ts +++ b/ui/src/store/storePersistence.ts @@ -320,7 +320,7 @@ export function isModeration(value: unknown): value is Moderation { } export function isProvider(value: unknown): value is Provider { - return value === "oauth" || value === "api" || value === "grok" || value === "grok-api" || value === "agy" || value === "gemini-api" || value === "atlascloud"; + return value === "oauth" || value === "api" || value === "grok" || value === "grok-api" || value === "agy" || value === "gemini-api" || value === "atlascloud" || value === "minimax"; } export function isPromptMode(value: unknown): value is "auto" | "direct" { diff --git a/ui/src/store/storeSettingsImpl.ts b/ui/src/store/storeSettingsImpl.ts index 89c86d01..08ca90f6 100644 --- a/ui/src/store/storeSettingsImpl.ts +++ b/ui/src/store/storeSettingsImpl.ts @@ -1,6 +1,6 @@ import type { Provider, Quality, SizePreset, Format, Moderation, ImageModel, Count } from "../types"; import type { ReasoningEffort } from "../lib/reasoning"; -import { DEFAULT_IMAGE_MODEL, GROK_VIDEO_MODEL_15, isGrokImageModel, isGeminiImageModel, isAtlasCloudImageModel, normalizeVideoModelValue } from "../lib/imageModels"; +import { DEFAULT_IMAGE_MODEL, GROK_VIDEO_MODEL_15, isGrokImageModel, isGeminiImageModel, isAtlasCloudImageModel, isMinimaxImageModel, normalizeVideoModelValue } from "../lib/imageModels"; import { parseRequestedCustomSide } from "../lib/size"; import { getEffectiveVideoSourceCount } from "../lib/videoSourceCount"; import { @@ -376,7 +376,11 @@ export function setProviderImpl(provider: Provider, set: StoreSet, get: StoreGet const atlasModel = "openai/gpt-image-2/text-to-image"; saveImageModel(atlasModel); set({ provider, imageModel: atlasModel }); - } else if (provider !== "grok" && provider !== "grok-api" && provider !== "agy" && provider !== "gemini-api" && provider !== "atlascloud" && (isGrokImageModel(currentModel) || isGeminiImageModel(currentModel) || isAtlasCloudImageModel(currentModel))) { + } else if (provider === "minimax" && !isMinimaxImageModel(currentModel)) { + const minimaxModel = "image-01"; + saveImageModel(minimaxModel); + set({ provider, imageModel: minimaxModel }); + } else if (provider !== "grok" && provider !== "grok-api" && provider !== "agy" && provider !== "gemini-api" && provider !== "atlascloud" && provider !== "minimax" && (isGrokImageModel(currentModel) || isGeminiImageModel(currentModel) || isAtlasCloudImageModel(currentModel) || isMinimaxImageModel(currentModel))) { set({ provider, imageModel: DEFAULT_IMAGE_MODEL }); saveImageModel(DEFAULT_IMAGE_MODEL); } else { @@ -446,7 +450,12 @@ export function setImageModelImpl(imageModel: ImageModel, set: StoreSet, get: St set({ provider: "atlascloud", imageModel }); return; } - if (get().provider === "grok" || get().provider === "agy" || get().provider === "gemini-api" || get().provider === "atlascloud") { + if (isMinimaxImageModel(imageModel)) { + saveGenerationDefaultsPatch({ provider: "minimax" }); + set({ provider: "minimax", imageModel }); + return; + } + if (get().provider === "grok" || get().provider === "agy" || get().provider === "gemini-api" || get().provider === "atlascloud" || get().provider === "minimax") { saveGenerationDefaultsPatch({ provider: "oauth" }); set({ provider: "oauth", imageModel }); return; diff --git a/ui/src/types.ts b/ui/src/types.ts index 12b6866a..dfa92d6c 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -2,7 +2,7 @@ export type UIMode = "classic" | "node" | "card-news" | "agent" | "assets" | "as export type AssetGenBackgroundPreset = "chroma-green" | "white" | "black"; export type SettingsSection = "providers" | "workspace" | "general"; export type HistoryStripLayout = "rail" | "horizontal" | "sidebar"; -export type Provider = "oauth" | "api" | "grok" | "grok-api" | "agy" | "gemini-api" | "atlascloud"; +export type Provider = "oauth" | "api" | "grok" | "grok-api" | "agy" | "gemini-api" | "atlascloud" | "minimax"; export type Quality = "low" | "medium" | "high"; export type Format = "png" | "jpeg" | "webp"; export type Moderation = "low" | "auto"; @@ -10,7 +10,8 @@ export type OpenAIImageModel = "gpt-5.5" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.6 export type GrokImageModel = "grok-imagine-image" | "grok-imagine-image-quality"; export type GeminiImageModel = "nano-banana-2" | "nano-banana-pro"; export type AtlasCloudImageModel = "openai/gpt-image-2/text-to-image" | "openai/gpt-image-2/edit"; -export type ImageModel = OpenAIImageModel | GrokImageModel | GeminiImageModel | AtlasCloudImageModel; +export type MinimaxImageModel = "image-01" | "image-01-live"; +export type ImageModel = OpenAIImageModel | GrokImageModel | GeminiImageModel | AtlasCloudImageModel | MinimaxImageModel; export type VideoModel = "grok-imagine-video" | "grok-imagine-video-1.5" | "grok-imagine-video-1.5-preview"; export type VideoResolutionUI = "480p" | "720p" | "1080p"; export type UnsupportedImageModel = "gpt-5.3-codex-spark";