diff --git a/CHANGELOG.md b/CHANGELOG.md index 21963fd7abf..05b9c8d3acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,9 @@ Docs: https://docs.openclaw.ai setup/join diagnostics, keep inaccessible nodes out of auto-selection, and preflight local BlackHole/SoX requirements before agents try local Chrome. Thanks @steipete. +- Providers/MiniMax: route `image-01` requests to the dedicated image + generation endpoint while preserving CN endpoint selection. Fixes #61149. + Thanks @mushuiyu886. - Plugins/startup: remove ownerless bundled runtime-dependency install locks after a short grace window and include lock owner details when startup times out waiting for a plugin runtime-deps lock. diff --git a/extensions/minimax/image-generation-provider.test.ts b/extensions/minimax/image-generation-provider.test.ts index 14d03cbe9cd..8f3a8f9859b 100644 --- a/extensions/minimax/image-generation-provider.test.ts +++ b/extensions/minimax/image-generation-provider.test.ts @@ -1,17 +1,22 @@ import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installPinnedHostnameTestHooks } from "../../src/media-understanding/audio.test-helpers.js"; -import { buildMinimaxImageGenerationProvider } from "./image-generation-provider.js"; +import { + buildMinimaxImageGenerationProvider, + buildMinimaxPortalImageGenerationProvider, +} from "./image-generation-provider.js"; installPinnedHostnameTestHooks(); describe("minimax image-generation provider", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv("MINIMAX_API_HOST", ""); }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); function mockMinimaxApiKey() { @@ -41,6 +46,10 @@ describe("minimax image-generation provider", () => { return fetchMock; } + function expectImageGenerationUrl(fetchMock: ReturnType, url: string) { + expect(fetchMock).toHaveBeenCalledWith(url, expect.any(Object)); + } + it("generates PNG buffers through the shared provider HTTP path", async () => { mockMinimaxApiKey(); const fetchMock = mockSuccessfulMinimaxImageResponse(); @@ -81,7 +90,7 @@ describe("minimax image-generation provider", () => { }); }); - it("uses the configured provider base URL origin", async () => { + it("keeps the dedicated global image endpoint when text config uses the global API host", async () => { mockMinimaxApiKey(); const fetchMock = mockSuccessfulMinimaxImageResponse(); @@ -102,36 +111,118 @@ describe("minimax image-generation provider", () => { }, }); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.minimax.io/v1/image_generation", - expect.any(Object), - ); + expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation"); }); - it("does not allow private-network routing just because a custom base URL is configured", async () => { + it("does not inherit unrelated MiniMax text endpoint hosts for image generation", async () => { mockMinimaxApiKey(); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = mockSuccessfulMinimaxImageResponse(); const provider = buildMinimaxImageGenerationProvider(); - await expect( - provider.generateImage({ - provider: "minimax", - model: "image-01", - prompt: "draw a cat", - cfg: { - models: { - providers: { - minimax: { - baseUrl: "http://127.0.0.1:8080/anthropic", - models: [], - }, + await provider.generateImage({ + provider: "minimax", + model: "image-01", + prompt: "draw a cat", + cfg: { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.chat/anthropic", + models: [], }, }, }, - }), - ).rejects.toThrow("Blocked hostname or private/internal/special-use IP address"); + }, + }); - expect(fetchMock).not.toHaveBeenCalled(); + expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation"); + }); + + it("uses the dedicated CN image endpoint when CN API host is configured", async () => { + vi.stubEnv("MINIMAX_API_HOST", "https://api.minimaxi.com/anthropic"); + mockMinimaxApiKey(); + const fetchMock = mockSuccessfulMinimaxImageResponse(); + + const provider = buildMinimaxImageGenerationProvider(); + await provider.generateImage({ + provider: "minimax", + model: "image-01", + prompt: "draw a cat", + cfg: {}, + }); + + expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation"); + }); + + it("infers the dedicated CN image endpoint from MiniMax provider config", async () => { + mockMinimaxApiKey(); + const fetchMock = mockSuccessfulMinimaxImageResponse(); + + const provider = buildMinimaxImageGenerationProvider(); + await provider.generateImage({ + provider: "minimax", + model: "image-01", + prompt: "draw a cat", + cfg: { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com/anthropic", + models: [], + }, + }, + }, + }, + }); + + expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation"); + }); + + it("infers the dedicated CN image endpoint from MiniMax Portal provider config", async () => { + mockMinimaxApiKey(); + const fetchMock = mockSuccessfulMinimaxImageResponse(); + + const provider = buildMinimaxPortalImageGenerationProvider(); + await provider.generateImage({ + provider: "minimax-portal", + model: "image-01", + prompt: "draw a cat", + cfg: { + models: { + providers: { + "minimax-portal": { + baseUrl: "api.minimaxi.com/anthropic", + models: [], + }, + }, + }, + }, + }); + + expectImageGenerationUrl(fetchMock, "https://api.minimaxi.com/v1/image_generation"); + }); + + it("ignores private custom text endpoints for image generation", async () => { + mockMinimaxApiKey(); + const fetchMock = mockSuccessfulMinimaxImageResponse(); + + const provider = buildMinimaxImageGenerationProvider(); + await provider.generateImage({ + provider: "minimax", + model: "image-01", + prompt: "draw a cat", + cfg: { + models: { + providers: { + minimax: { + baseUrl: "http://127.0.0.1:8080/anthropic", + models: [], + }, + }, + }, + }, + }); + + expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation"); }); }); diff --git a/extensions/minimax/image-generation-provider.ts b/extensions/minimax/image-generation-provider.ts index 24d80c712f9..3496c33344c 100644 --- a/extensions/minimax/image-generation-provider.ts +++ b/extensions/minimax/image-generation-provider.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/provider-http"; const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io"; +const CN_MINIMAX_IMAGE_BASE_URL = "https://api.minimaxi.com"; const DEFAULT_MODEL = "image-01"; const DEFAULT_OUTPUT_MIME = "image/png"; const MINIMAX_SUPPORTED_ASPECT_RATIOS = [ @@ -36,20 +37,37 @@ type MinimaxImageApiResponse = { }; }; +function isMinimaxCnHost(value: string | undefined): boolean { + const trimmed = value?.trim(); + if (!trimmed) { + return false; + } + const candidate = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const hostname = new URL(candidate).hostname.toLowerCase(); + return hostname === "minimaxi.com" || hostname.endsWith(".minimaxi.com"); + } catch { + return false; + } +} + function resolveMinimaxImageBaseUrl( cfg: Parameters[0]["cfg"], providerId: string, ): string { - const direct = cfg?.models?.providers?.[providerId]?.baseUrl?.trim(); - if (!direct) { - return DEFAULT_MINIMAX_IMAGE_BASE_URL; + // MiniMax image generation uses dedicated endpoints that are separate from + // the text/chat API endpoints. First check MINIMAX_API_HOST env var, + // then fall back to the provider's configured baseUrl to determine region. + const apiHost = process.env.MINIMAX_API_HOST; + if (isMinimaxCnHost(apiHost)) { + return CN_MINIMAX_IMAGE_BASE_URL; } - // Extract origin from the configured base URL (which may include path like /anthropic) - try { - return new URL(direct).origin; - } catch { - return DEFAULT_MINIMAX_IMAGE_BASE_URL; + // CN onboarding stores region in provider config without requiring env var + const providerBaseUrl = cfg?.models?.providers?.[providerId]?.baseUrl; + if (isMinimaxCnHost(providerBaseUrl)) { + return CN_MINIMAX_IMAGE_BASE_URL; } + return DEFAULT_MINIMAX_IMAGE_BASE_URL; } function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider {