Add MiniMax China endpoint routing (#2285)

* Add MiniMax China endpoint routing

* fix(minimax): preserve regional routing and secure credential handling

* fix(minimax): isolate active routes and enforce secure request boundaries

* fix(minimax): reject insecure DNS root-dot quota URLs

---------

Co-authored-by: Octopatch <octopatch@users.noreply.github.com>
Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
Octopus
2026-10-18 10:03:20 +08:00
committed by GitHub
co-authored by Octopatch jatmn
parent 8ee936cfa1
commit 67f3e044e5
16 changed files with 407 additions and 23 deletions
+4 -1
View File
@@ -386,7 +386,10 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
#
# MINIMAX_API_KEY=your-minimax-key-here
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
# ANTHROPIC_MODEL=MiniMax-M2.7
# ANTHROPIC_MODEL=MiniMax-M3
# Mainland China: use the regional endpoint with a key from that region.
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
# Explicit MiniMax endpoints must use HTTPS.
# -----------------------------------------------------------------------------
+1
View File
@@ -13,6 +13,7 @@ export default defineBrand({
supportsPreciseTokenCount: false,
},
modelIds: [
'minimax-m3',
'minimax-m2',
'minimax-m2.1',
'minimax-m2.1-highspeed',
+10
View File
@@ -628,6 +628,16 @@ test('resolveActiveRouteIdFromEnv treats Anthropic-compatible MiniMax profile en
).toBe('minimax')
})
test('resolveActiveRouteIdFromEnv recognizes the mainland China MiniMax endpoint', () => {
expect(
resolveActiveRouteIdFromEnv({
ANTHROPIC_BASE_URL: 'https://api.minimaxi.com/anthropic',
ANTHROPIC_API_KEY: 'minimax-key',
ANTHROPIC_MODEL: 'MiniMax-M3',
}),
).toBe('minimax')
})
test('resolveActiveRouteIdFromEnv treats Venice credential-only env as Venice', () => {
expect(
resolveActiveRouteIdFromEnv({
+33 -2
View File
@@ -274,12 +274,33 @@ export function isMiniMaxBaseUrl(value: string | undefined): boolean {
try {
const hostname = new URL(trimmed).hostname.toLowerCase()
return hostname === 'api.minimax.io' || hostname === 'api.minimax.chat'
const descriptor = getRouteDescriptor('minimax')
return descriptor !== null && matchHostnameAgainstRouteHosts(
hostname,
getValidationRoutingHosts(descriptor),
)
} catch {
return false
}
}
export function getMiniMaxBaseUrlValidationError(
baseUrl: string | undefined,
): string | null {
if (!baseUrl) return null
try {
const url = new URL(baseUrl)
// A terminal DNS root dot names the same host. Normalize only for the
// transport check; keep provider selection and the configured URL intact.
url.hostname = url.hostname.replace(/\.$/, '')
return isMiniMaxBaseUrl(url.href) && url.protocol !== 'https:'
? 'MiniMax base URL must use HTTPS'
: null
} catch {
return null
}
}
export function isXaiBaseUrl(value: string | undefined): boolean {
const trimmed = value?.trim()
if (!trimmed) {
@@ -967,7 +988,9 @@ export function hasMiniMaxEnvOnlyProviderIntent(
return (
hasMiniMaxCredential &&
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isMiniMaxBaseUrl) &&
((isMiniMaxBaseUrl(processEnv.ANTHROPIC_BASE_URL) &&
hasNoExplicitNonOpenAICompatibleProvider(processEnv)) ||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isMiniMaxBaseUrl)) &&
(hasExplicitMiniMaxIntent ||
(!hasAnyUsableOpenAICredential(processEnv) &&
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
@@ -1261,6 +1284,14 @@ export function resolveRouteCredentialValue(
return undefined
}
// Check transport before releasing a dedicated MiniMax credential to the shim.
if (
routeId === 'minimax' &&
getMiniMaxBaseUrlValidationError(options?.baseUrl)
) {
return undefined
}
// ApiSmart's host is intentionally sufficient for route identity, but its
// dedicated credential is valid only for the documented inference base.
// Keep those concerns separate so discovery, versioned, or custom paths on
+8 -4
View File
@@ -1,10 +1,13 @@
import { defineVendor } from '../define.js'
export const MINIMAX_GLOBAL_ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
export const MINIMAX_CHINA_ANTHROPIC_BASE_URL = 'https://api.minimaxi.com/anthropic'
export default defineVendor({
id: 'minimax',
label: 'MiniMax',
classification: 'native',
defaultBaseUrl: 'https://api.minimax.io/anthropic',
defaultBaseUrl: MINIMAX_GLOBAL_ANTHROPIC_BASE_URL,
defaultModel: 'MiniMax-M3',
requiredEnvVars: ['MINIMAX_API_KEY'],
setup: {
@@ -24,11 +27,12 @@ export default defineVendor({
kind: 'credential-env',
routing: {
matchDefaultBaseUrl: true,
matchBaseUrlHosts: ['api.minimax.io', 'api.minimax.chat'],
matchBaseUrlHosts: ['api.minimax.io', 'api.minimaxi.com', 'api.minimax.chat'],
},
credentialEnvVars: ['MINIMAX_API_KEY'],
// Explicit OpenAI-compatible endpoints also accept the shim's credentials.
credentialEnvVars: ['MINIMAX_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'],
missingCredentialMessage:
'MiniMax auth is required. Set MINIMAX_API_KEY.',
'MiniMax auth is required. Set MINIMAX_API_KEY or OPENAI_API_KEY / OPENAI_API_KEYS.',
},
catalog: {
source: 'static',
+176
View File
@@ -55,6 +55,7 @@ type ShimClient = {
const originalFetch = globalThis.fetch
const originalMacro = (globalThis as Record<string, unknown>).MACRO
const originalEnv = {
OPENAI_API_KEYS: process.env.OPENAI_API_KEYS,
CLAUDE_CODE_ALWAYS_ENABLE_EFFORT:
process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT,
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
@@ -130,6 +131,7 @@ function restoreEnv(key: string, value: string | undefined): void {
}
function clearEnvForMiniMaxOnlyTest(): void {
delete process.env.OPENAI_API_KEYS
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH
@@ -260,6 +262,7 @@ afterEach(() => {
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS)
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
@@ -750,6 +753,179 @@ test('routes env-only MiniMax requests through the Anthropic-compatible API', as
})
})
for (const credentialEnv of ['ANTHROPIC_API_KEY', 'MINIMAX_API_KEY']) {
for (const dormantBaseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
for (const host of ['api.minimax.io', 'api.minimaxi.com', 'api.minimax.chat']) {
test(`MiniMax native ${host} preserves ${credentialEnv} despite dormant ${dormantBaseEnv}`, async () => {
clearEnvForMiniMaxOnlyTest()
process.env[dormantBaseEnv] = 'https://unrelated.example/v1'
process.env.ANTHROPIC_BASE_URL = `https://${host}/anthropic`
process.env.ANTHROPIC_MODEL = 'MiniMax-M3'
process.env.OPENAI_MODEL = 'stale-model'
const { getMainLoopModel } = await import('../../utils/model/model.js')
expect(getMainLoopModel()).toBe('MiniMax-M3')
process.env[credentialEnv] = 'china-test-key'
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
globalThis.fetch = (async (input, init) => {
capturedUrl = String(input)
capturedHeaders = new Headers(init?.headers)
return new Response(JSON.stringify({
id: 'msg-china', type: 'message', role: 'assistant', model: 'MiniMax-M3',
content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn',
usage: { input_tokens: 1, output_tokens: 1 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = await getAnthropicClient({ maxRetries: 0, model: 'MiniMax-M3' })
await client.beta.messages.create({
model: 'MiniMax-M3', max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
expect(capturedUrl).toBe(`https://${host}/anthropic/v1/messages?beta=true`)
expect(capturedHeaders?.get('x-api-key')).toBe('china-test-key')
})
}
}
}
for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
for (const credentialEnv of ['MINIMAX_API_KEY', 'OPENAI_API_KEY', 'OPENAI_API_KEYS']) {
test(`MiniMax China preserves OpenAI requests via ${baseEnv} and ${credentialEnv}`, async () => {
clearEnvForMiniMaxOnlyTest()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env[baseEnv] = 'https://api.minimaxi.com/v1'
process.env[credentialEnv] = 'china-test-key'
process.env.OPENAI_MODEL = 'MiniMax-M3'
process.env.ANTHROPIC_MODEL = 'claude-sonnet-4-6'
const models = await import('../../utils/model/model.js')
const selectedModel = models.getMainLoopModel()
expect([selectedModel, models.getDefaultMainLoopModel(), models.getSmallFastModel(),
models.getDefaultOpusModel(), models.getDefaultSonnetModel(), models.getDefaultHaikuModel(),
]).toEqual(Array(6).fill('MiniMax-M3'))
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
let capturedBody: Record<string, unknown> | undefined
globalThis.fetch = (async (input, init) => {
capturedUrl = String(input)
capturedHeaders = new Headers(init?.headers)
capturedBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({
id: 'chat-china', object: 'chat.completion', model: 'MiniMax-M3',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = await getAnthropicClient({ maxRetries: 0, model: selectedModel })
await client.beta.messages.create({
model: selectedModel, max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
expect(capturedUrl).toBe('https://api.minimaxi.com/v1/chat/completions')
expect(capturedBody?.model).toBe('MiniMax-M3')
expect(capturedHeaders?.get('authorization')).toBe('Bearer china-test-key')
const { getModelOptions } = await import('../../utils/model/modelOptions.js')
const { validateModel } = await import('../../utils/model/validateModel.js')
expect(getModelOptions().some(option => option.value === 'MiniMax-M3')).toBe(true)
expect(await validateModel('MiniMax-M3')).toEqual({ valid: true })
})
}
}
test('selected Gemini ignores dormant native MiniMax transport settings', async () => {
clearEnvForMiniMaxOnlyTest()
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_API_KEY = 'gemini-test-key'
process.env.GEMINI_MODEL = 'gemini-2.5-pro'
process.env.ANTHROPIC_BASE_URL = 'http://api.minimaxi.com/anthropic'
process.env.MINIMAX_API_KEY = 'dormant-mini-key'
process.env.OPENAI_BASE_URL = 'https://unrelated.example/v1'
await expect(getAnthropicClient({ maxRetries: 0, model: 'gemini-2.5-pro' })).resolves.toBeDefined()
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBe('1')
expect(process.env.ANTHROPIC_API_KEY).toBe('must-not-forward')
})
test('dormant MiniMax China configuration cannot replace an explicit OpenAI route', async () => {
clearEnvForMiniMaxOnlyTest()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://openai.example/v1'
process.env.OPENAI_API_KEY = 'selected-openai-key'
process.env.ANTHROPIC_BASE_URL = 'https://api.minimaxi.com/anthropic'
process.env.ANTHROPIC_API_KEY = 'dormant-china-key'
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
globalThis.fetch = (async (input, init) => {
capturedUrl = String(input)
capturedHeaders = new Headers(init?.headers)
return new Response(JSON.stringify({
id: 'chat-custom', object: 'chat.completion', model: 'custom-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = await getAnthropicClient({ maxRetries: 0, model: 'custom-model' })
await client.beta.messages.create({
model: 'custom-model', max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
expect(capturedUrl).toBe('https://openai.example/v1/chat/completions')
expect(capturedHeaders?.get('authorization')).toBe('Bearer selected-openai-key')
expect(capturedHeaders?.get('x-api-key')).toBeNull()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
const { getModelOptions } = await import('../../utils/model/modelOptions.js')
const { validateModel } = await import('../../utils/model/validateModel.js')
expect(getModelOptions().some(option => option.value === 'MiniMax-M3')).toBe(false)
expect(await validateModel('sonnet')).toEqual({ valid: true })
})
for (const baseEnv of ['ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
test(`MiniMax China rejects HTTP before sending credentials via ${baseEnv}`, async () => {
clearEnvForMiniMaxOnlyTest()
if (baseEnv !== 'ANTHROPIC_BASE_URL') process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env[baseEnv] = 'http://api.minimaxi.com/anthropic'
process.env.MINIMAX_API_KEY = 'china-test-key'
let fetchCount = 0
globalThis.fetch = (async (_input, _init) => {
fetchCount++
throw new Error('Unexpected insecure request')
}) as unknown as FetchType
await expect((async () => {
const client = await getAnthropicClient({ maxRetries: 0, model: 'MiniMax-M3' })
await client.beta.messages.create({
model: 'MiniMax-M3', max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
})()).rejects.toThrow('MiniMax base URL must use HTTPS')
expect(fetchCount).toBe(0)
expect(process.env.OPENAI_API_KEY).toBeUndefined()
expect(process.env.ANTHROPIC_API_KEY).toBe('must-not-forward')
})
}
for (const dormantBaseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
for (const credentialEnv of ['MINIMAX_API_KEY', 'ANTHROPIC_API_KEY']) {
for (const host of ['api.minimax.io', 'api.minimaxi.com', 'api.minimax.chat']) {
test(`MiniMax native HTTP ${host} rejects ${credentialEnv} despite dormant ${dormantBaseEnv}`, async () => {
clearEnvForMiniMaxOnlyTest()
process.env[dormantBaseEnv] = 'https://unrelated.example/v1'
process.env.ANTHROPIC_BASE_URL = `http://${host}/anthropic`
process.env[credentialEnv] = 'native-test-key'
let calls = 0
globalThis.fetch = (async () => {
calls++
return new Response('{}', { headers: { 'Content-Type': 'application/json' } })
}) as unknown as FetchType
await expect((async () => {
const client = await getAnthropicClient({ maxRetries: 0, model: 'MiniMax-M3' })
await client.beta.messages.create({
model: 'MiniMax-M3', max_tokens: 16, messages: [{ role: 'user', content: 'hello' }],
})
})()).rejects.toThrow('MiniMax base URL must use HTTPS')
expect(calls).toBe(0)
})
}
}
}
test('env-only MiniMax fallback preserves legacy OPENAI_MODEL as Anthropic model', async () => {
let capturedUrl: string | undefined
let capturedBody: Record<string, unknown> | undefined
+26 -2
View File
@@ -42,6 +42,8 @@ import {
getFireworksBaseUrlOverride,
getLongcatBaseUrlOverride,
getMiniMaxBaseUrlOverride,
getMiniMaxBaseUrlValidationError,
isMiniMaxBaseUrl,
getNearaiBaseUrlOverride,
isCanonicalApiRouteInferenceBaseUrl,
isCanonicalApismartInferenceBaseUrl,
@@ -149,13 +151,24 @@ function hasMiniMaxModelIntent(model: string | undefined): boolean {
function hasConflictingOpenAIBaseUrlForMiniMax(): boolean {
const openAIBaseUrl =
process.env.OPENAI_BASE_URL?.trim() || process.env.OPENAI_API_BASE?.trim()
return Boolean(openAIBaseUrl && getMiniMaxBaseUrlOverride() === undefined)
return Boolean(openAIBaseUrl && !isMiniMaxBaseUrl(openAIBaseUrl))
}
function shouldUseMiniMaxEnvOnlyProvider(
model: string | undefined,
envOnlyProviderRouteId: string | null,
): boolean {
// An explicit OpenAI endpoint selects its wire protocol, even with a
// dedicated MiniMax key. Native defaults must not rewrite that request.
if (
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) &&
isMiniMaxBaseUrl(
process.env.OPENAI_BASE_URL?.trim() || process.env.OPENAI_API_BASE?.trim(),
)
) {
return false
}
const hasMiniMaxCredential =
process.env.MINIMAX_API_KEY?.trim() ||
(getMiniMaxBaseUrlOverride() !== undefined &&
@@ -184,6 +197,17 @@ function isXaiModelName(value: string | undefined): boolean {
}
function applyMiniMaxEnvOnlyDefaults(model: string | undefined): void {
const baseUrlOverride = getMiniMaxBaseUrlOverride()
const validationError = getMiniMaxBaseUrlValidationError(baseUrlOverride)
if (validationError) throw new Error(validationError)
// ANTHROPIC_BASE_URL already describes the native endpoint. Legacy OpenAI
// URL hints select the region, but still need the native /anthropic path.
const baseUrl = isMiniMaxBaseUrl(process.env.ANTHROPIC_BASE_URL)
? process.env.ANTHROPIC_BASE_URL!.trim()
: baseUrlOverride
? new URL('/anthropic', baseUrlOverride).toString()
: getRouteDefaultBaseUrl('minimax')
const modelOverride =
(isMiniMaxModelName(model) ? model?.trim() : undefined) ??
process.env.OPENAI_MODEL?.trim() ??
@@ -197,7 +221,7 @@ function applyMiniMaxEnvOnlyDefaults(model: string | undefined): void {
process.env.ANTHROPIC_API_KEY = apiKey
}
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_BASE_URL = baseUrl
process.env.ANTHROPIC_MODEL =
(isMiniMaxModelName(modelOverride)
? modelOverride
+61
View File
@@ -332,3 +332,64 @@ describe('MiniMax usage helpers', () => {
}
})
})
for (const setup of ['openai-profile', 'native-profile-trailing-dot']) {
for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
for (const protocol of ['http', 'https']) {
test(`China quota ${protocol} via ${setup} and ${baseEnv}`, async () => {
const { applyProviderProfileToProcessEnv, clearProviderProfileEnvFromProcessEnv } = await import('../../utils/providerProfiles.js')
const { buildStartupEnvFromProfile } = await import('../../utils/providerProfile.js')
const { getProviderValidationError } = await import('../../utils/providerValidation.js')
const { resolveActiveUsageId } = await import('../../commands/usage/index.js')
const { fetchMiniMaxUsage } = await import('./minimaxUsage.js')
const savedEnv = { ...process.env }
const clearedEnv = { ...process.env }
clearProviderProfileEnvFromProcessEnv(clearedEnv)
const managedKeys = Object.keys(savedEnv).filter(key => !(key in clearedEnv))
const savedFetch = globalThis.fetch
const globals = globalThis as Record<string, unknown>
const savedMacro = globals.MACRO
globals.MACRO = { VERSION: '0.0.0-test' }
const calls: { url: string; auth: string | null }[] = []
const nativeProfile = setup === 'native-profile-trailing-dot'
const quotaHost = nativeProfile ? 'api.minimaxi.com.' : 'api.minimaxi.com'
try {
applyProviderProfileToProcessEnv({
id: 'china-quota-test', name: 'China quota test', provider: nativeProfile ? 'minimax' : 'custom',
model: 'MiniMax-M3',
baseUrl: nativeProfile ? 'https://api.minimaxi.com/anthropic' : `${protocol}://${quotaHost}/v1`,
apiKey: 'quota-test-key',
})
process.env.OPENAI_BASE_URL = `${protocol}://${quotaHost}/v1`
if (baseEnv === 'OPENAI_API_BASE') {
process.env.OPENAI_API_BASE = process.env.OPENAI_BASE_URL
delete process.env.OPENAI_BASE_URL
}
const startup = await buildStartupEnvFromProfile({ processEnv: process.env, persisted: null })
expect(resolveActiveUsageId(startup)).toBe('minimax')
globalThis.fetch = (async (input, init) => {
calls.push({ url: String(input), auth: new Headers(init?.headers).get('authorization') })
return new Response('{}', { status: 404 })
}) as typeof fetch
if (protocol === 'http') {
await expect(fetchMiniMaxUsage()).rejects.toThrow('MiniMax base URL must use HTTPS')
expect(calls).toEqual([])
await expect(getProviderValidationError(startup)).resolves.toBe(nativeProfile ? null : 'MiniMax base URL must use HTTPS')
} else {
await expect(getProviderValidationError(startup)).resolves.toBeNull()
await expect(fetchMiniMaxUsage()).resolves.toMatchObject({ availability: 'unknown' })
expect(calls).toEqual([
{ url: `https://${quotaHost}/v1/token_plan/remains`, auth: 'Bearer quota-test-key' },
{ url: `https://${quotaHost}/v1/api/openplatform/coding_plan/remains`, auth: 'Bearer quota-test-key' },
])
}
} finally {
globalThis.fetch = savedFetch
globals.MACRO = savedMacro
clearProviderProfileEnvFromProcessEnv()
for (const key of managedKeys) process.env[key] = savedEnv[key]
}
})
}
}
}
+5
View File
@@ -1,3 +1,4 @@
import { getMiniMaxBaseUrlValidationError } from '../../../integrations/routeMetadata.js'
import { createCombinedAbortSignal } from '../../../utils/combinedAbortSignal.js'
import { logForDebugging } from '../../../utils/debug.js'
import { getClaudeCodeUserAgent } from '../../../utils/userAgent.js'
@@ -82,6 +83,10 @@ export async function fetchMiniMaxUsage(): Promise<MiniMaxUsageData> {
}
const usageUrls = getMiniMaxUsageUrls()
for (const usageUrl of usageUrls) {
const validationError = getMiniMaxBaseUrlValidationError(usageUrl)
if (validationError) throw new Error(validationError)
}
const nonFatalFailures: Array<{ status: number; body: string }> = []
let lastFatalError: Error | null = null
+4
View File
@@ -51,6 +51,7 @@ import {
} from '../../utils/githubModelsCredentials.js'
import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js'
import {
getMiniMaxBaseUrlValidationError,
getRouteDescriptor,
isLongcatBaseUrl,
isXaiBaseUrl,
@@ -449,6 +450,9 @@ class OpenAIShimMessages {
options?: { signal?: AbortSignal; headers?: Record<string, string> },
requestProcessEnv: NodeJS.ProcessEnv = process.env,
): Promise<Response> {
const validationError = getMiniMaxBaseUrlValidationError(request.baseUrl)
if (validationError) throw new Error(validationError)
const codexResponse = await dispatchCodexRequest({
request,
params,
+15
View File
@@ -504,6 +504,21 @@ test('MiniMax-M2.7 uses the shared gateway-safe context cap by default', () => {
expect(getMaxOutputTokensForModel('MiniMax-M2.7')).toBe(131_072)
})
test('MiniMax China uses the M3 context limit before client setup', () => {
const savedBaseUrl = process.env.ANTHROPIC_BASE_URL
const savedApiKey = process.env.ANTHROPIC_API_KEY
try {
process.env.ANTHROPIC_BASE_URL = 'https://api.minimaxi.com/anthropic'
process.env.ANTHROPIC_API_KEY = 'china-test-key'
expect(getContextWindowForModel('MiniMax-M3')).toBe(1_048_576)
} finally {
if (savedBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL
else process.env.ANTHROPIC_BASE_URL = savedBaseUrl
if (savedApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = savedApiKey
}
})
test('env-only MiniMax key uses provider-specific context and output caps before client setup', () => {
process.env.MINIMAX_API_KEY = 'minimax-test-key'
delete process.env.CLAUDE_CODE_USE_OPENAI
+20
View File
@@ -132,3 +132,23 @@ describe('getEnvProviderOption — availability and var naming', () => {
expect(option.apiKey).toBe('generic-openai-secret')
})
})
for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
test(`inactive insecure MiniMax ${baseEnv} is safe for startup and onboarding inspection`, async () => {
const { buildStartupEnvFromProfile } = await import('./providerProfile.js')
const { refreshStartupDiscoveryForActiveRoute } = await import('../integrations/discoveryService.js')
const env = {
CLAUDE_CODE_USE_GEMINI: '1', GEMINI_API_KEY: 'gemini-test-key',
GEMINI_MODEL: 'gemini-2.5-pro', [baseEnv]: 'http://api.minimax.io/v1',
}
const startup = await buildStartupEnvFromProfile({ processEnv: env, persisted: null })
expect(startup.CLAUDE_CODE_USE_GEMINI).toBe('1')
expect(() => getEnvProviderOption(env)).not.toThrow()
const miniEnv = {
CLAUDE_CODE_USE_OPENAI: '1', [baseEnv]: 'http://api.minimaxi.com/v1',
OPENAI_MODEL: 'MiniMax-M3', MINIMAX_API_KEY: 'mini-test-key',
}
expect(getEnvProviderOption(miniEnv).apiKey).toBeUndefined()
await expect(refreshStartupDiscoveryForActiveRoute({ processEnv: miniEnv })).resolves.toBeNull()
})
}
-13
View File
@@ -5,20 +5,7 @@
import type { ModelOption } from './modelOptions.js'
import { getAPIProvider } from './providers.js'
import { getMiniMaxBaseUrlOverride } from '../../integrations/routeMetadata.js'
import { isEnvTruthy } from '../envUtils.js'
export function isMiniMaxProvider(): boolean {
if (isEnvTruthy(process.env.MINIMAX_API_KEY)) {
return true
}
// MiniMax runs over the anthropic-proxy transport, so the active base URL can
// arrive via ANTHROPIC_BASE_URL (not just OPENAI_BASE_URL). Reuse the shared
// host matcher so the model picker recognises every configured MiniMax base
// URL even when MINIMAX_API_KEY isn't exported into the environment.
if (getMiniMaxBaseUrlOverride(process.env) !== undefined) {
return true
}
return getAPIProvider() === 'minimax'
}
+3 -1
View File
@@ -42,7 +42,9 @@ export type ModelName = string
export type ModelSetting = ModelName | ModelAlias | null
function getMiniMaxModelEnv(): string | undefined {
return process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL
return isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
? process.env.OPENAI_MODEL
: process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL
}
function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefined {
+31
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test'
import { applyAgentProviderOverrideToEnv } from '../services/api/agentRouting.js'
import { ensureIntegrationsLoaded, getAllGateways } from '../integrations/index.js'
import {
acquireSharedMutationLock,
@@ -577,6 +578,24 @@ test('minimax validation accepts MINIMAX_API_KEY on minimax chat host alias', as
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
})
for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
for (const credentialEnv of ['OPENAI_API_KEY', 'OPENAI_API_KEYS']) {
test(`MiniMax China startup accepts ${credentialEnv} with ${baseEnv}`, async () => {
const env: NodeJS.ProcessEnv = {}
applyAgentProviderOverrideToEnv({
baseURL: 'https://api.minimaxi.com/v1', model: 'MiniMax-M3', apiKey: 'china-test-key',
}, env)
delete env.OPENAI_BASE_URL
delete env.OPENAI_API_KEY
env[baseEnv] = 'https://api.minimaxi.com/v1'
env[credentialEnv] = 'china-test-key'
await expect(getProviderValidationError(env)).resolves.toBeNull()
delete env[credentialEnv]
await expect(getProviderValidationError(env)).resolves.toContain('MiniMax auth is required')
})
}
}
test('nvidia nim validation accepts NVIDIA_API_KEY without OPENAI_API_KEY', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://integrate.api.nvidia.com/v1'
@@ -1001,3 +1020,15 @@ test('startup provider validation stays strict for non-interactive launches', ()
}),
).toBe(true)
})
for (const baseEnv of ['ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
test(`MiniMax HTTP ${baseEnv} reports a structured transport error`, async () => {
const env = {
...(baseEnv === 'ANTHROPIC_BASE_URL' ? {} : { CLAUDE_CODE_USE_OPENAI: '1' }),
[baseEnv]: 'http://api.minimaxi.com/anthropic', MINIMAX_API_KEY: 'mini-test-key',
}
await expect(getProviderValidationError(env)).resolves.toBe('MiniMax base URL must use HTTPS')
expect(shouldExitForStartupProviderValidationError({ args: [], stdoutIsTTY: true })).toBe(false)
expect(shouldExitForStartupProviderValidationError({ args: ['-p', 'hello'], stdoutIsTTY: false })).toBe(true)
})
}
+10
View File
@@ -12,6 +12,8 @@ import type {
VendorDescriptor,
} from '../integrations/descriptors.js'
import {
getMiniMaxBaseUrlOverride,
getMiniMaxBaseUrlValidationError,
getRouteCredentialEnvVars,
getRouteCredentialValue,
getRouteDescriptor,
@@ -594,6 +596,14 @@ export async function getProviderValidationError(
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const validationTarget = getRuntimeValidationTarget(env)
if (resolveActiveRouteIdFromEnv(env) === 'minimax') {
const baseUrl = useOpenAI
? env.OPENAI_BASE_URL?.trim() || env.OPENAI_API_BASE?.trim()
: getMiniMaxBaseUrlOverride(env)
const validationError = getMiniMaxBaseUrlValidationError(baseUrl)
if (validationError) return validationError
}
if (!useOpenAI && !validationTarget) {
return null
}