mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
co-authored by
Octopatch
jatmn
parent
8ee936cfa1
commit
67f3e044e5
+4
-1
@@ -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.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineBrand({
|
||||
supportsPreciseTokenCount: false,
|
||||
},
|
||||
modelIds: [
|
||||
'minimax-m3',
|
||||
'minimax-m2',
|
||||
'minimax-m2.1',
|
||||
'minimax-m2.1-highspeed',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+8
-4
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user