fix(minimax): use dedicated image generation endpoint (#61155)

* fix(minimax): use dedicated image generation endpoint

MiniMax image generation uses a dedicated API endpoint
(api.minimax.io/v1/image_generation) that is separate from the
text/chat API endpoint (api.minimax.io/anthropic).

Previously, the resolveMinimaxImageBaseUrl function would extract
the origin from the provider's configured baseUrl. If a user had
configured their baseUrl to the chat endpoint (e.g.,
api.minimax.chat/anthropic), the image generation would incorrectly
use that endpoint, resulting in "invalid api key" errors.

This fix always uses the dedicated image generation endpoint,
ignoring the provider's baseUrl configuration for image generation.

Fixes #61149

* fix(minimax): support CN endpoint for image generation

Respect MINIMAX_API_HOST environment variable to determine whether
to use the global (api.minimax.io) or CN (api.minimaxi.com) endpoint
for image generation.

This ensures that CN users who configure MINIMAX_API_HOST to use
api.minimaxi.com will continue to use the CN endpoint for image
generation, while global users continue to use api.minimax.io.

The original bug was caused by the code extracting the origin from
the provider's configured baseUrl, which could be set to incorrect
endpoints like api.minimax.chat. This fix uses the dedicated image
generation endpoints instead.

Fixes #61149

* fix(minimax): infer CN endpoint from provider config when env is unset

When MINIMAX_API_HOST is not set, fall back to checking the provider's
configured baseUrl to determine whether to use the CN or global image
endpoint. This ensures CN users who went through onboarding (which sets
models.providers.minimax.baseUrl to https://api.minimaxi.com/anthropic)
are correctly routed to the CN image endpoint.

The isMinimaxCnHost check ensures we only use the baseUrl origin for
CN detection - invalid endpoints like api.minimax.chat would not match
minimaxi.com and would correctly fall through to the global default.

Fixes #61149

* test(minimax): cover dedicated image endpoints

* fix(logging): handle context assembly diagnostics

* Revert "fix(logging): handle context assembly diagnostics"

This reverts commit f51d2f7d67f8193268dd37553ac77e80a0423390.

* test(minimax): isolate image endpoint env

* docs(changelog): credit minimax image fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mushuiyu_xydt
2026-04-26 03:07:52 +08:00
committed by GitHub
parent 7d58362f3f
commit 0e1ef93e84
3 changed files with 144 additions and 32 deletions

View File

@@ -55,6 +55,9 @@ Docs: https://docs.openclaw.ai
setup/join diagnostics, keep inaccessible nodes out of auto-selection, and setup/join diagnostics, keep inaccessible nodes out of auto-selection, and
preflight local BlackHole/SoX requirements before agents try local Chrome. preflight local BlackHole/SoX requirements before agents try local Chrome.
Thanks @steipete. 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 - Plugins/startup: remove ownerless bundled runtime-dependency install locks
after a short grace window and include lock owner details when startup times after a short grace window and include lock owner details when startup times
out waiting for a plugin runtime-deps lock. out waiting for a plugin runtime-deps lock.

View File

@@ -1,17 +1,22 @@
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime"; import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installPinnedHostnameTestHooks } from "../../src/media-understanding/audio.test-helpers.js"; 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(); installPinnedHostnameTestHooks();
describe("minimax image-generation provider", () => { describe("minimax image-generation provider", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.stubEnv("MINIMAX_API_HOST", "");
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllEnvs();
}); });
function mockMinimaxApiKey() { function mockMinimaxApiKey() {
@@ -41,6 +46,10 @@ describe("minimax image-generation provider", () => {
return fetchMock; return fetchMock;
} }
function expectImageGenerationUrl(fetchMock: ReturnType<typeof vi.fn>, url: string) {
expect(fetchMock).toHaveBeenCalledWith(url, expect.any(Object));
}
it("generates PNG buffers through the shared provider HTTP path", async () => { it("generates PNG buffers through the shared provider HTTP path", async () => {
mockMinimaxApiKey(); mockMinimaxApiKey();
const fetchMock = mockSuccessfulMinimaxImageResponse(); 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(); mockMinimaxApiKey();
const fetchMock = mockSuccessfulMinimaxImageResponse(); const fetchMock = mockSuccessfulMinimaxImageResponse();
@@ -102,36 +111,118 @@ describe("minimax image-generation provider", () => {
}, },
}); });
expect(fetchMock).toHaveBeenCalledWith( expectImageGenerationUrl(fetchMock, "https://api.minimax.io/v1/image_generation");
"https://api.minimax.io/v1/image_generation",
expect.any(Object),
);
}); });
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(); mockMinimaxApiKey();
const fetchMock = vi.fn(); const fetchMock = mockSuccessfulMinimaxImageResponse();
vi.stubGlobal("fetch", fetchMock);
const provider = buildMinimaxImageGenerationProvider(); const provider = buildMinimaxImageGenerationProvider();
await expect( await provider.generateImage({
provider.generateImage({ provider: "minimax",
provider: "minimax", model: "image-01",
model: "image-01", prompt: "draw a cat",
prompt: "draw a cat", cfg: {
cfg: { models: {
models: { providers: {
providers: { minimax: {
minimax: { baseUrl: "https://api.minimax.chat/anthropic",
baseUrl: "http://127.0.0.1:8080/anthropic", models: [],
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");
}); });
}); });

View File

@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/provider-http"; } from "openclaw/plugin-sdk/provider-http";
const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io"; 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_MODEL = "image-01";
const DEFAULT_OUTPUT_MIME = "image/png"; const DEFAULT_OUTPUT_MIME = "image/png";
const MINIMAX_SUPPORTED_ASPECT_RATIOS = [ 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( function resolveMinimaxImageBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"], cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string, providerId: string,
): string { ): string {
const direct = cfg?.models?.providers?.[providerId]?.baseUrl?.trim(); // MiniMax image generation uses dedicated endpoints that are separate from
if (!direct) { // the text/chat API endpoints. First check MINIMAX_API_HOST env var,
return DEFAULT_MINIMAX_IMAGE_BASE_URL; // 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) // CN onboarding stores region in provider config without requiring env var
try { const providerBaseUrl = cfg?.models?.providers?.[providerId]?.baseUrl;
return new URL(direct).origin; if (isMinimaxCnHost(providerBaseUrl)) {
} catch { return CN_MINIMAX_IMAGE_BASE_URL;
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
} }
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
} }
function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider { function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider {