From 67f3e044e50feaa307ec65088ff342e33ba72169 Mon Sep 17 00:00:00 2001 From: Octopus Date: Sun, 18 Oct 2026 10:03:20 +0800 Subject: [PATCH] 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 Co-authored-by: jatmn --- .env.example | 5 +- src/integrations/brands/minimax.ts | 1 + src/integrations/routeMetadata.test.ts | 10 ++ src/integrations/routeMetadata.ts | 35 ++++- src/integrations/vendors/minimax.ts | 12 +- src/services/api/client.test.ts | 176 +++++++++++++++++++++++++ src/services/api/client.ts | 28 +++- src/services/api/minimaxUsage.test.ts | 61 +++++++++ src/services/api/minimaxUsage/fetch.ts | 5 + src/services/api/openaiShim.ts | 4 + src/utils/context.test.ts | 15 +++ src/utils/envProviderOption.test.ts | 20 +++ src/utils/model/minimaxModels.ts | 13 -- src/utils/model/model.ts | 4 +- src/utils/providerValidation.test.ts | 31 +++++ src/utils/providerValidation.ts | 10 ++ 16 files changed, 407 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 5633d23c9..e369f556d 100644 --- a/.env.example +++ b/.env.example @@ -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. # ----------------------------------------------------------------------------- diff --git a/src/integrations/brands/minimax.ts b/src/integrations/brands/minimax.ts index 3dff8c0f0..04b573dda 100644 --- a/src/integrations/brands/minimax.ts +++ b/src/integrations/brands/minimax.ts @@ -13,6 +13,7 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'minimax-m3', 'minimax-m2', 'minimax-m2.1', 'minimax-m2.1-highspeed', diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 87212ee94..3d0687e40 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -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({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index c8bf09ab5..f249269b1 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -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 diff --git a/src/integrations/vendors/minimax.ts b/src/integrations/vendors/minimax.ts index 6d17748b2..712061d64 100644 --- a/src/integrations/vendors/minimax.ts +++ b/src/integrations/vendors/minimax.ts @@ -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', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 9eea1a6bb..28a75d137 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -55,6 +55,7 @@ type ShimClient = { const originalFetch = globalThis.fetch const originalMacro = (globalThis as Record).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 | 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 | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 5c8b008fc..46e61a6cf 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -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 diff --git a/src/services/api/minimaxUsage.test.ts b/src/services/api/minimaxUsage.test.ts index be4130290..245d9c11c 100644 --- a/src/services/api/minimaxUsage.test.ts +++ b/src/services/api/minimaxUsage.test.ts @@ -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 + 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] + } + }) + } + } +} diff --git a/src/services/api/minimaxUsage/fetch.ts b/src/services/api/minimaxUsage/fetch.ts index 33d43a571..d2e41ed82 100644 --- a/src/services/api/minimaxUsage/fetch.ts +++ b/src/services/api/minimaxUsage/fetch.ts @@ -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 { } 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 diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6f561e339..a35b02caf 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -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 }, requestProcessEnv: NodeJS.ProcessEnv = process.env, ): Promise { + const validationError = getMiniMaxBaseUrlValidationError(request.baseUrl) + if (validationError) throw new Error(validationError) + const codexResponse = await dispatchCodexRequest({ request, params, diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index b9b07e221..d86406c94 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -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 diff --git a/src/utils/envProviderOption.test.ts b/src/utils/envProviderOption.test.ts index d7e9f4cb8..daf1cd784 100644 --- a/src/utils/envProviderOption.test.ts +++ b/src/utils/envProviderOption.test.ts @@ -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() + }) +} diff --git a/src/utils/model/minimaxModels.ts b/src/utils/model/minimaxModels.ts index e0ebc4338..0629bc3a9 100644 --- a/src/utils/model/minimaxModels.ts +++ b/src/utils/model/minimaxModels.ts @@ -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' } diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index a3138ed25..860f8eed4 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -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 { diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 2c362cd7c..fa32c11cf 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -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) + }) +} diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index c1dec35c1..c5d49ba32 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -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 }