fix: route MiniMax compacting through Anthropic-compatible API (#1154)

* fix: route MiniMax through Anthropic-compatible API

Switch MiniMax provider setup away from the OpenAI-compatible shim and onto the Anthropic-compatible endpoint. Update env-only, provider flag, and saved profile paths to use ANTHROPIC_* while preserving legacy OPENAI_MODEL as a migration fallback.

Adjust MiniMax M2 context metadata so shared descriptors use the gateway-safe 196608 window and the direct MiniMax catalog overrides to the documented 204800 window. Extend runtime context lookup to anthropic-proxy routes so compact budgeting uses the direct provider metadata.

Update MiniMax client, provider profile, provider flag, context, and auto-compact tests for the Anthropic-compatible route and provider-specific compact limits.

* test: cover MiniMax provider manager paths

Update ProviderManager test fixtures so MiniMax uses the Anthropic-compatible endpoint instead of the old OpenAI-compatible /v1 endpoint.

Add coverage for the /provider add flow to assert MiniMax saves provider=minimax, endpoint https://api.minimax.io/anthropic, and displays the Anthropic-compatible API provider type.

Add edit-flow coverage to ensure existing MiniMax profiles remain on the Anthropic-compatible provider path and continue hiding OpenAI-only advanced fields.

* test: isolate MiniMax env-only coverage

Harden MiniMax client and compact tests against ambient CI provider env such as OPENAI_API_KEY, ANTHROPIC_BASE_URL, and provider-profile markers.

The compact budget test now explicitly clears competing provider flags before asserting direct MiniMax metadata, preventing CI-level OpenAI credentials from masking env-only MiniMax route detection.

* test: reset provider env inside MiniMax client cases

Make each env-only MiniMax client test clear competing provider flags, OpenAI/XAI keys, Anthropic env, and saved-profile markers before setting MINIMAX_API_KEY.

This keeps the Anthropic-compatible MiniMax route assertions independent of CI-level process env that can otherwise mask env-only provider detection in the full serial suite.

* fix: honor explicit MiniMax routing intent

Route MiniMax env-only requests by explicit MiniMax model/base intent even when generic OpenAI-compatible environment variables are present, while preserving non-MiniMax base URL conflicts.

Use the resolved MiniMax env-only path for Anthropic SDK key selection so stale provider classification or Bun module mocks cannot fall back to an Anthropic test key.

Harden MiniMax compact coverage against leaked env overrides and prior autoCompact module mocks, and cover the ambient OpenAI/XAI env regression.

* test: clean up compression autoCompact mocks

Restore Bun module mocks after compression test files so their deterministic autoCompact window does not leak into later compact tests in full-suite order.

Verified the MiniMax compact regression now passes after the compression suites and in the full local test log.

* test: avoid autoCompact module mocks in compression tests

Replace the compression suites' top-level Bun module mocks for autoCompact/config with real test config and env controls. This avoids Bun 1.3.11 leaking a mocked effective context window into the later MiniMax compact test in full-suite order.

Verified compression-before-compact and MiniMax focused suites pass locally.

* test: allow capped MiniMax compact reservation

CI enables the output-token slot-reservation cap, so MiniMax's direct 204,800 context can produce a 196,800 effective compact window instead of the uncapped 184,800. Keep the test focused on direct MiniMax context metadata while accepting either reservation state.

* fix: address MiniMax review findings

Treat env-only provider routes such as direct MiniMax as complete startup provider selections so saved profiles do not override explicit MINIMAX_API_KEY/ANTHROPIC_* env.

Stop advertising direct MiniMax benchmark support through the OpenAI-compatible benchmark path, and add regression coverage for the unsupported direct MiniMax benchmark env.

* fix: classify MiniMax profile startup correctly

Recognize MiniMax when /provider loads it through the Anthropic-compatible env shape using ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, and ANTHROPIC_API_KEY.

Label MiniMax correctly on the startup screen and skip the Anthropic custom-key approval prompt when the resolved provider is not using the Anthropic account flow.

Add regressions for route metadata, legacy provider classification, account-flow bypass, and startup display for Anthropic-compatible MiniMax profiles.

* fix: include Anthropic key in provider secret source

Allow MiniMax profile redaction to include ANTHROPIC_API_KEY in the SecretValueSource type used by sanitizeProviderConfigValue.

This fixes the PR-specific TS2353 reported by review while keeping the MiniMax Anthropic-compatible key alias redacted alongside MINIMAX_API_KEY.

Validation: bun test --max-concurrency=1 src\utils\providerProfiles.test.ts src\utils\providerFlag.test.ts src\utils\model\providers.test.ts src\integrations\routeMetadata.test.ts; bun run typecheck still has existing repo-wide errors, with no providerProfile.ts matches.

* test: stabilize tool history compression smoke

Add a narrow compression-enabled override for tests so the compression suites do not depend on shared global config state from the full Bun runner.

Pass explicit effective context windows in direct compression tests and use catalog-backed models in shim compression tests to avoid env-capped tier drift.

Verified with focused compression tests, full bun test --max-concurrency=1, and bun run build.

* test: stabilize Orama corruption recovery assertion

Verify the quarantined corrupted Orama file from the actual persistence directory returned by getOramaPersistencePath, instead of assuming the config-dir projects root used by the full CI runner.

Verified with the failing KnowledgeGraph stress test, compression smoke suites, full bun test --max-concurrency=1, and bun run build.

* fix: refresh MiniMax compact branch

Merge upstream/main into fix/minimax-compact so PR #1154 is current with the target branch.

Also fix two branch-local FetchType test casts that surfaced during typecheck scanning of the MiniMax/xAI fallback tests.

Validation: bun test --max-concurrency=1 src/utils/providerProfiles.test.ts src/utils/providerFlag.test.ts src/utils/model/providers.test.ts src/integrations/routeMetadata.test.ts src/services/api/client.test.ts src/services/compact/autoCompact.test.ts src/utils/model/benchmark.test.ts; bun run build.
This commit is contained in:
JATMN
2026-05-25 19:17:45 +08:00
committed by GitHub
parent cb666c85d0
commit b3dc674dbe
29 changed files with 896 additions and 361 deletions
+2 -3
View File
@@ -282,10 +282,9 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# MiniMax API provides text generation models.
# Get your API key from https://platform.minimax.io/
#
# CLAUDE_CODE_USE_OPENAI=1
# MINIMAX_API_KEY=your-minimax-key-here
# OPENAI_BASE_URL=https://api.minimax.io/v1
# OPENAI_MODEL=MiniMax-M2.5
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
# ANTHROPIC_MODEL=MiniMax-M2.7
# =============================================================================
+2 -2
View File
@@ -15,7 +15,7 @@ async function runBenchmark(
if (!isBenchmarkSupported()) {
context?.stdout?.write(
'Benchmark not supported for this provider.\n' +
'Supported: OpenAI-compatible endpoints (Ollama, NVIDIA NIM, MiniMax)\n',
'Supported: OpenAI-compatible endpoints (Ollama, NVIDIA NIM)\n',
)
return
}
@@ -53,4 +53,4 @@ export const benchmark: Command = {
await runBenchmark(model, context)
},
}
}
+112 -3
View File
@@ -234,7 +234,7 @@ function mockProviderProfilesModule(options?: {
return {
provider: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimax.io/v1',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M2.7',
apiKey: '',
requiresApiKey: true,
@@ -805,8 +805,15 @@ test('ProviderManager saves OpenAI preset GPT-5 models with Responses API', asyn
}
})
test('ProviderManager skips advanced setup fields when adding MiniMax preset', async () => {
mockProviderManagerDependencies(() => undefined, async () => undefined)
test('ProviderManager saves MiniMax preset with Anthropic-compatible endpoint and type', async () => {
const addProviderProfile = mock((payload: any) => ({
id: 'minimax_profile',
...payload,
}))
mockProviderManagerDependencies(() => undefined, async () => undefined, {
addProviderProfile,
})
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
@@ -831,6 +838,7 @@ test('ProviderManager skips advanced setup fields when adding MiniMax preset', a
expect(modelOutput).toContain('MiniMax')
expect(modelOutput).toContain('MiniMax-M2.7')
expect(modelOutput).toContain('Provider type: Anthropic-compatible API')
expect(modelOutput).not.toContain('Provider name')
expect(modelOutput).not.toContain('Base URL')
expect(modelOutput).not.toContain('API mode')
@@ -846,6 +854,107 @@ test('ProviderManager skips advanced setup fields when adding MiniMax preset', a
expect(keyOutput).not.toContain('API mode')
expect(keyOutput).not.toContain('Auth header')
expect(keyOutput).not.toContain('Custom headers')
mounted.stdin.write('minimax-test-key')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => addProviderProfile.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'minimax',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M2.7',
apiFormat: 'chat_completions',
}),
expect.objectContaining({ makeActive: true }),
)
} finally {
await mounted.dispose()
}
})
test('ProviderManager edit flow keeps MiniMax on Anthropic-compatible provider path', async () => {
const minimaxProfile = {
id: 'provider_minimax',
provider: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M2.7',
apiKey: 'minimax-key',
}
const updateProviderProfile = mock((id: string, payload: any) => ({
...minimaxProfile,
id,
...payload,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
getProviderProfiles: () => [minimaxProfile],
getActiveProviderProfile: () => minimaxProfile,
updateProviderProfile,
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
try {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Provider manager') &&
frame.includes('Edit provider'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider') &&
frame.includes('MiniMax') &&
!frame.includes('Provider manager'),
)
mounted.stdin.write('\r')
const editOutput = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider type: Anthropic-compatible API'),
)
expect(editOutput).toContain('Provider type: Anthropic-compatible API')
expect(editOutput).not.toContain('API mode')
expect(editOutput).not.toContain('Auth header')
expect(editOutput).not.toContain('Custom headers')
for (let step = 2; step <= 4; step++) {
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes(`Step ${step} of 4`),
)
}
mounted.stdin.write('\r')
await waitForCondition(() => updateProviderProfile.mock.calls.length > 0)
expect(updateProviderProfile).toHaveBeenCalledWith(
'provider_minimax',
expect.objectContaining({
provider: 'minimax',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M2.7',
}),
)
expect(updateProviderProfile.mock.calls[0]?.[1]).toMatchObject({
authHeader: undefined,
authScheme: undefined,
authHeaderValue: undefined,
customHeaders: undefined,
})
} finally {
await mounted.dispose()
}
+10
View File
@@ -52,6 +52,7 @@ const ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
]
const originalEnv: Record<string, string | undefined> = {}
@@ -265,6 +266,15 @@ describe('detectProvider — explicit dedicated-provider env flags', () => {
process.env.MINIMAX_API_KEY = 'test-key'
expect(detectProvider().name).toBe('MiniMax')
})
test('Anthropic-compatible MiniMax profile is labeled MiniMax', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_API_KEY = 'test-key'
process.env.ANTHROPIC_MODEL = 'MiniMax-M2.7'
expect(detectProvider().name).toBe('MiniMax')
expect(detectProvider().baseUrl).toBe('https://api.minimax.io/anthropic')
})
})
// --- modelOverride from --model flag ---
+3 -1
View File
@@ -8,6 +8,7 @@
import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
import {
getRouteLabel,
isMiniMaxBaseUrl,
resolveRouteIdFromBaseUrl,
} from '../integrations/routeMetadata.js'
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
@@ -161,7 +162,8 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
const resolvedModel = parseUserSpecifiedModel(modelSetting)
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
const isLocal = isLocalProviderUrl(baseUrl)
return { name: 'Anthropic', model: resolvedModel, baseUrl, isLocal }
const name = isMiniMaxBaseUrl(baseUrl) ? 'MiniMax' : 'Anthropic'
return { name, model: resolvedModel, baseUrl, isLocal }
}
// ─── Box drawing ──────────────────────────────────────────────────────────────
+7 -7
View File
@@ -18,7 +18,7 @@ export default [
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'MiniMax-M2',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -29,7 +29,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.1',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -40,7 +40,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.1-highspeed',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -51,7 +51,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.5',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -62,7 +62,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.5-highspeed',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -73,7 +73,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.7',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
@@ -84,7 +84,7 @@ export default [
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'MiniMax-M2.7-highspeed',
capabilities: minimaxM2Capabilities,
contextWindow: 204_800,
contextWindow: 196_608,
maxOutputTokens: 131_072,
}),
defineModel({
+34
View File
@@ -98,6 +98,16 @@ test('resolveActiveRouteIdFromEnv treats MiniMax credential-only env as MiniMax'
).toBe('minimax')
})
test('resolveActiveRouteIdFromEnv treats Anthropic-compatible MiniMax profile env as MiniMax', () => {
expect(
resolveActiveRouteIdFromEnv({
ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic',
ANTHROPIC_API_KEY: 'minimax-key',
ANTHROPIC_MODEL: 'MiniMax-M2.7',
}),
).toBe('minimax')
})
test('resolveActiveRouteIdFromEnv treats Venice credential-only env as Venice', () => {
expect(
resolveActiveRouteIdFromEnv({
@@ -122,6 +132,30 @@ test('resolveActiveRouteIdFromEnv prefers xAI when env-only keys compete', () =>
).toBe('xai')
})
test('resolveActiveRouteIdFromEnv lets explicit MiniMax model beat ambient OpenAI-compatible env', () => {
expect(
resolveActiveRouteIdFromEnv({
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_API_KEY: 'openai-key',
XAI_API_KEY: 'xai-key',
MINIMAX_API_KEY: 'minimax-key',
OPENAI_MODEL: 'MiniMax-M2.7',
}),
).toBe('minimax')
})
test('resolveActiveRouteIdFromEnv does not use MiniMax when OpenAI base conflicts', () => {
expect(
resolveActiveRouteIdFromEnv({
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_API_KEY: 'openai-key',
MINIMAX_API_KEY: 'minimax-key',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_MODEL: 'MiniMax-M2.7',
}),
).toBe('openai')
})
test('resolveActiveRouteIdFromEnv keeps xAI primary base over stale API base', () => {
expect(
resolveActiveRouteIdFromEnv({
+42 -7
View File
@@ -275,6 +275,11 @@ export function isVeniceBaseUrl(value: string | undefined): boolean {
export function getMiniMaxBaseUrlOverride(
processEnv: NodeJS.ProcessEnv = process.env,
): string | undefined {
const anthropicBaseUrl = processEnv.ANTHROPIC_BASE_URL?.trim()
if (isMiniMaxBaseUrl(anthropicBaseUrl)) {
return anthropicBaseUrl
}
const openAIBaseUrl = processEnv.OPENAI_BASE_URL?.trim()
if (isMiniMaxBaseUrl(openAIBaseUrl)) {
return openAIBaseUrl
@@ -288,6 +293,22 @@ export function getMiniMaxBaseUrlOverride(
return undefined
}
function isMiniMaxModelName(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase()
return Boolean(
normalized &&
(normalized.startsWith('minimax-') || normalized.startsWith('minimax/')),
)
}
function hasMiniMaxRouteIntent(processEnv: NodeJS.ProcessEnv): boolean {
return (
getMiniMaxBaseUrlOverride(processEnv) !== undefined ||
isMiniMaxModelName(processEnv.OPENAI_MODEL) ||
isMiniMaxModelName(processEnv.ANTHROPIC_MODEL)
)
}
export function getXaiBaseUrlOverride(
processEnv: NodeJS.ProcessEnv = process.env,
): string | undefined {
@@ -345,12 +366,19 @@ export function hasXaiEnvOnlyProviderIntent(
export function hasMiniMaxEnvOnlyProviderIntent(
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
const hasExplicitMiniMaxIntent = hasMiniMaxRouteIntent(processEnv)
const hasMiniMaxCredential =
hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) ||
(isMiniMaxBaseUrl(processEnv.ANTHROPIC_BASE_URL) &&
hasNonEmptyEnvValue(processEnv.ANTHROPIC_API_KEY))
return (
hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) &&
!hasNonEmptyEnvValue(processEnv.OPENAI_API_KEY) &&
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
hasMiniMaxCredential &&
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isMiniMaxBaseUrl) &&
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
(hasExplicitMiniMaxIntent ||
(!hasNonEmptyEnvValue(processEnv.OPENAI_API_KEY) &&
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
hasNoExplicitNonOpenAICompatibleProvider(processEnv)))
)
}
@@ -384,6 +412,13 @@ export function hasXiaomiMimoEnvOnlyProviderIntent(
export function resolveEnvOnlyProviderRouteId(
processEnv: NodeJS.ProcessEnv = process.env,
): 'xai' | 'minimax' | 'venice' | 'xiaomi-mimo' | null {
if (
hasMiniMaxRouteIntent(processEnv) &&
hasMiniMaxEnvOnlyProviderIntent(processEnv)
) {
return 'minimax'
}
if (hasXaiEnvOnlyProviderIntent(processEnv)) {
return 'xai'
}
@@ -604,6 +639,9 @@ export function resolveActiveRouteIdFromEnv(
return 'vertex'
}
const envOnlyRouteId = resolveEnvOnlyProviderRouteId(processEnv)
if (envOnlyRouteId) return envOnlyRouteId
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) {
const baseUrl =
processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE
@@ -639,9 +677,6 @@ export function resolveActiveRouteIdFromEnv(
return 'custom'
}
const envOnlyRouteId = resolveEnvOnlyProviderRouteId(processEnv)
if (envOnlyRouteId) return envOnlyRouteId
return 'anthropic'
}
+12 -16
View File
@@ -3,8 +3,8 @@ import { defineVendor } from '../define.js'
export default defineVendor({
id: 'minimax',
label: 'MiniMax',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.minimax.io/v1',
classification: 'native',
defaultBaseUrl: 'https://api.minimax.io/anthropic',
defaultModel: 'MiniMax-M2.7',
requiredEnvVars: ['MINIMAX_API_KEY'],
setup: {
@@ -13,11 +13,7 @@ export default defineVendor({
credentialEnvVars: ['MINIMAX_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
kind: 'anthropic-proxy',
},
preset: {
id: 'minimax',
@@ -30,20 +26,20 @@ export default defineVendor({
matchDefaultBaseUrl: true,
matchBaseUrlHosts: ['api.minimax.io', 'api.minimax.chat'],
},
credentialEnvVars: ['MINIMAX_API_KEY', 'OPENAI_API_KEY'],
credentialEnvVars: ['MINIMAX_API_KEY'],
missingCredentialMessage:
'MiniMax auth is required. Set MINIMAX_API_KEY or OPENAI_API_KEY.',
'MiniMax auth is required. Set MINIMAX_API_KEY.',
},
catalog: {
source: 'static',
models: [
{ id: 'minimax-m2', apiName: 'MiniMax-M2', label: 'MiniMax M2', modelDescriptorId: 'minimax-m2' },
{ id: 'minimax-m2.1', apiName: 'MiniMax-M2.1', label: 'MiniMax M2.1', modelDescriptorId: 'minimax-m2.1' },
{ id: 'minimax-m2.1-highspeed', apiName: 'MiniMax-M2.1-highspeed', label: 'MiniMax M2.1 Highspeed', modelDescriptorId: 'minimax-m2.1-highspeed' },
{ id: 'minimax-m2.5', apiName: 'MiniMax-M2.5', label: 'MiniMax M2.5', modelDescriptorId: 'minimax-m2.5' },
{ id: 'minimax-m2.5-highspeed', apiName: 'MiniMax-M2.5-highspeed', label: 'MiniMax M2.5 Highspeed', modelDescriptorId: 'minimax-m2.5-highspeed' },
{ id: 'minimax-m2.7', apiName: 'MiniMax-M2.7', label: 'MiniMax M2.7', modelDescriptorId: 'minimax-m2.7' },
{ id: 'minimax-m2.7-highspeed', apiName: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 Highspeed', modelDescriptorId: 'minimax-m2.7-highspeed' },
{ id: 'minimax-m2', apiName: 'MiniMax-M2', label: 'MiniMax M2', modelDescriptorId: 'minimax-m2', contextWindow: 204_800 },
{ id: 'minimax-m2.1', apiName: 'MiniMax-M2.1', label: 'MiniMax M2.1', modelDescriptorId: 'minimax-m2.1', contextWindow: 204_800 },
{ id: 'minimax-m2.1-highspeed', apiName: 'MiniMax-M2.1-highspeed', label: 'MiniMax M2.1 Highspeed', modelDescriptorId: 'minimax-m2.1-highspeed', contextWindow: 204_800 },
{ id: 'minimax-m2.5', apiName: 'MiniMax-M2.5', label: 'MiniMax M2.5', modelDescriptorId: 'minimax-m2.5', contextWindow: 204_800 },
{ id: 'minimax-m2.5-highspeed', apiName: 'MiniMax-M2.5-highspeed', label: 'MiniMax M2.5 Highspeed', modelDescriptorId: 'minimax-m2.5-highspeed', contextWindow: 204_800 },
{ id: 'minimax-m2.7', apiName: 'MiniMax-M2.7', label: 'MiniMax M2.7', modelDescriptorId: 'minimax-m2.7', contextWindow: 204_800 },
{ id: 'minimax-m2.7-highspeed', apiName: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 Highspeed', modelDescriptorId: 'minimax-m2.7-highspeed', contextWindow: 204_800 },
{ id: 'minimax-text-01', apiName: 'MiniMax-Text-01', label: 'MiniMax Text 01', modelDescriptorId: 'minimax-text-01' },
{ id: 'minimax-text-01-preview', apiName: 'MiniMax-Text-01-Preview', label: 'MiniMax Text 01 Preview', modelDescriptorId: 'minimax-text-01-preview' },
{ id: 'minimax-vision-01', apiName: 'MiniMax-Vision-01', label: 'MiniMax Vision 01', modelDescriptorId: 'minimax-vision-01' },
+1 -1
View File
@@ -213,7 +213,7 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// Check for custom API key
// On homespace, ANTHROPIC_API_KEY is preserved in process.env for child
// processes but ignored by Claude Code itself (see auth.ts).
if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) {
if (usesAnthropicSetup && process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) {
const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY);
const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated);
if (keyStatus === 'new') {
+91 -131
View File
@@ -39,7 +39,12 @@ const originalEnv = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID,
}
function restoreEnv(key: string, value: string | undefined): void {
@@ -50,6 +55,35 @@ function restoreEnv(key: string, value: string | undefined): void {
}
}
function clearEnvForMiniMaxOnlyTest(): void {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
delete process.env.GOOGLE_API_KEY
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
delete process.env.XAI_API_KEY
delete process.env.NVIDIA_NIM
delete process.env.ANTHROPIC_API_KEY
delete process.env.ANTHROPIC_AUTH_TOKEN
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_MODEL
delete process.env.ANTHROPIC_CUSTOM_HEADERS
}
beforeEach(async () => {
await acquireSharedMutationLock('client.test.ts')
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
@@ -77,7 +111,11 @@ beforeEach(async () => {
delete process.env.NVIDIA_NIM
delete process.env.ANTHROPIC_API_KEY
delete process.env.ANTHROPIC_AUTH_TOKEN
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_MODEL
delete process.env.ANTHROPIC_CUSTOM_HEADERS
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
})
afterEach(() => {
@@ -110,7 +148,16 @@ afterEach(() => {
restoreEnv('ANTHROPIC_API_KEY', originalEnv.ANTHROPIC_API_KEY)
restoreEnv('ANTHROPIC_AUTH_TOKEN', originalEnv.ANTHROPIC_AUTH_TOKEN)
restoreEnv('ANTHROPIC_BASE_URL', originalEnv.ANTHROPIC_BASE_URL)
restoreEnv('ANTHROPIC_MODEL', originalEnv.ANTHROPIC_MODEL)
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
restoreEnv(
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
)
restoreEnv(
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID,
)
globalThis.fetch = originalFetch
} finally {
releaseSharedMutationLock()
@@ -249,16 +296,15 @@ test('routes Gemini provider requests through the OpenAI-compatible shim', async
})
})
test('routes env-only MiniMax requests through the OpenAI-compatible shim', async () => {
test('routes env-only MiniMax requests through the Anthropic-compatible API', async () => {
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
let capturedBody: Record<string, unknown> | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
clearEnvForMiniMaxOnlyTest()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_API_KEY = 'ambient-openai-key'
process.env.XAI_API_KEY = 'ambient-xai-key'
process.env.MINIMAX_API_KEY = 'minimax-test-key'
globalThis.fetch = (async (input, init) => {
@@ -273,22 +319,18 @@ test('routes env-only MiniMax requests through the OpenAI-compatible shim', asyn
return new Response(
JSON.stringify({
id: 'chatcmpl-minimax',
id: 'msg-minimax',
type: 'message',
role: 'assistant',
model: 'MiniMax-M2.5',
choices: [
{
message: {
role: 'assistant',
content: 'minimax ok',
},
finish_reason: 'stop',
},
],
content: [{ type: 'text', text: 'minimax ok' }],
usage: {
prompt_tokens: 8,
completion_tokens: 3,
total_tokens: 11,
input_tokens: 8,
output_tokens: 3,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
stop_reason: 'end_turn',
}),
{
headers: {
@@ -311,26 +353,24 @@ test('routes env-only MiniMax requests through the OpenAI-compatible shim', asyn
stream: false,
})
expect(capturedUrl).toBe('https://api.minimax.io/v1/chat/completions')
expect(capturedHeaders?.get('authorization')).toBe('Bearer minimax-test-key')
expect(capturedUrl).toBe('https://api.minimax.io/anthropic/v1/messages?beta=true')
expect(capturedHeaders?.get('x-api-key')).toBe('minimax-test-key')
expect(capturedBody?.model).toBe('MiniMax-M2.5')
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.minimax.io/anthropic')
expect(process.env.ANTHROPIC_API_KEY).toBe('minimax-test-key')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(response).toMatchObject({
role: 'assistant',
model: 'MiniMax-M2.5',
})
})
test('env-only MiniMax fallback preserves OpenAI-shaped model and base overrides', async () => {
test('env-only MiniMax fallback preserves legacy OPENAI_MODEL as Anthropic model', async () => {
let capturedUrl: string | undefined
let capturedBody: Record<string, unknown> | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
clearEnvForMiniMaxOnlyTest()
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.OPENAI_BASE_URL = 'https://api.minimax.chat/v1'
process.env.OPENAI_MODEL = 'MiniMax-M2.7-highspeed'
globalThis.fetch = (async (input, init) => {
@@ -344,15 +384,13 @@ test('env-only MiniMax fallback preserves OpenAI-shaped model and base overrides
return new Response(
JSON.stringify({
id: 'chatcmpl-minimax-override',
id: 'msg-minimax-override',
type: 'message',
role: 'assistant',
model: 'MiniMax-M2.7-highspeed',
choices: [
{
message: { role: 'assistant', content: 'minimax override ok' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
content: [{ type: 'text', text: 'minimax override ok' }],
usage: { input_tokens: 8, output_tokens: 3, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
stop_reason: 'end_turn',
}),
{ headers: { 'Content-Type': 'application/json' } },
)
@@ -371,92 +409,16 @@ test('env-only MiniMax fallback preserves OpenAI-shaped model and base overrides
stream: false,
})
expect(capturedUrl).toBe('https://api.minimax.chat/v1/chat/completions')
expect(capturedUrl).toBe('https://api.minimax.io/anthropic/v1/messages?beta=true')
expect(capturedBody?.model).toBe('MiniMax-M2.7-highspeed')
expect(process.env.OPENAI_API_KEY).toBe('minimax-test-key')
expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed')
})
test('env-only MiniMax fallback ignores stale OPENAI_API_BASE when primary base matches', async () => {
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.OPENAI_BASE_URL = 'https://api.minimax.chat/v1'
process.env.OPENAI_API_BASE = 'https://api.openai.com/v1'
await getAnthropicClient({
maxRetries: 0,
model: 'MiniMax-M2.7',
})
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(process.env.OPENAI_BASE_URL).toBe('https://api.minimax.chat/v1')
expect(process.env.OPENAI_API_KEY).toBe('minimax-test-key')
})
test('env-only MiniMax fallback preserves OPENAI_API_BASE host overrides', async () => {
let capturedUrl: string | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.OPENAI_API_BASE = 'https://api.minimax.chat/v1'
globalThis.fetch = (async (input) => {
capturedUrl =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
return new Response(
JSON.stringify({
id: 'chatcmpl-minimax-api-base',
model: 'MiniMax-M2.7',
choices: [
{
message: { role: 'assistant', content: 'minimax api base ok' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as FetchType
const client = (await getAnthropicClient({
maxRetries: 0,
model: 'MiniMax-M2.7',
})) as unknown as ShimClient
await client.beta.messages.create({
model: 'MiniMax-M2.7',
system: 'test system',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://api.minimax.chat/v1/chat/completions')
expect(process.env.OPENAI_BASE_URL).toBe('https://api.minimax.chat/v1')
})
test('env-only MiniMax fallback drops unsupported OpenAI shim options', async () => {
test('env-only MiniMax fallback drops stale OpenAI shim options', async () => {
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_MODEL
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_AUTH_MODE
clearEnvForMiniMaxOnlyTest()
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.OPENAI_API_FORMAT = 'responses'
process.env.OPENAI_AUTH_HEADER = 'api-key'
@@ -474,15 +436,13 @@ test('env-only MiniMax fallback drops unsupported OpenAI shim options', async ()
return new Response(
JSON.stringify({
id: 'chatcmpl-minimax-clean',
id: 'msg-minimax-clean',
type: 'message',
role: 'assistant',
model: 'MiniMax-M2.7',
choices: [
{
message: { role: 'assistant', content: 'minimax clean ok' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
content: [{ type: 'text', text: 'minimax clean ok' }],
usage: { input_tokens: 8, output_tokens: 3, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
stop_reason: 'end_turn',
}),
{ headers: { 'Content-Type': 'application/json' } },
)
@@ -501,8 +461,8 @@ test('env-only MiniMax fallback drops unsupported OpenAI shim options', async ()
stream: false,
})
expect(capturedUrl).toBe('https://api.minimax.io/v1/chat/completions')
expect(capturedHeaders?.get('authorization')).toBe('Bearer minimax-test-key')
expect(capturedUrl).toBe('https://api.minimax.io/anthropic/v1/messages?beta=true')
expect(capturedHeaders?.get('x-api-key')).toBe('minimax-test-key')
expect(capturedHeaders?.get('api-key')).toBeNull()
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
@@ -525,9 +485,9 @@ test('env-only MiniMax fallback replaces stale non-MiniMax model env', async ()
model: 'MiniMax-M2.7',
})
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(process.env.OPENAI_MODEL).toBe('MiniMax-M2.7')
expect(process.env.OPENAI_API_KEY).toBe('minimax-test-key')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M2.7')
expect(process.env.ANTHROPIC_API_KEY).toBe('minimax-test-key')
})
test('env-only MiniMax fallback does not override explicit OpenAI credentials', async () => {
@@ -871,7 +831,7 @@ test('env-only MiniMax fallback yields to explicit Bedrock selection', async ()
globalThis.fetch = (async () => {
throw new Error('MiniMax/OpenAI shim fetch should not run')
}) as FetchType
}) as unknown as FetchType
await getAnthropicClient({
maxRetries: 0,
@@ -896,7 +856,7 @@ test('env-only xAI fallback yields to explicit Bedrock selection', async () => {
globalThis.fetch = (async () => {
throw new Error('xAI/OpenAI shim fetch should not run')
}) as FetchType
}) as unknown as FetchType
await getAnthropicClient({
maxRetries: 0,
+95 -37
View File
@@ -116,6 +116,43 @@ function isMiniMaxModelName(value: string | undefined): boolean {
)
}
function hasMiniMaxModelIntent(model: string | undefined): boolean {
return (
isMiniMaxModelName(model) ||
isMiniMaxModelName(process.env.OPENAI_MODEL) ||
isMiniMaxModelName(process.env.ANTHROPIC_MODEL)
)
}
function hasConflictingOpenAIBaseUrlForMiniMax(): boolean {
const openAIBaseUrl =
process.env.OPENAI_BASE_URL?.trim() || process.env.OPENAI_API_BASE?.trim()
return Boolean(openAIBaseUrl && getMiniMaxBaseUrlOverride() === undefined)
}
function shouldUseMiniMaxEnvOnlyProvider(
model: string | undefined,
envOnlyProviderRouteId: string | null,
): boolean {
const hasMiniMaxCredential =
process.env.MINIMAX_API_KEY?.trim() ||
(getMiniMaxBaseUrlOverride() !== undefined &&
process.env.ANTHROPIC_API_KEY?.trim())
if (!hasMiniMaxCredential) {
return false
}
if (envOnlyProviderRouteId === 'minimax') {
return true
}
return (
(hasMiniMaxModelIntent(model) || getMiniMaxBaseUrlOverride() !== undefined) &&
!hasConflictingOpenAIBaseUrlForMiniMax()
)
}
function isXaiModelName(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase()
return Boolean(
@@ -124,6 +161,33 @@ function isXaiModelName(value: string | undefined): boolean {
)
}
function applyMiniMaxEnvOnlyDefaults(model: string | undefined): void {
const modelOverride =
(isMiniMaxModelName(model) ? model?.trim() : undefined) ??
process.env.OPENAI_MODEL?.trim() ??
process.env.ANTHROPIC_MODEL?.trim() ??
undefined
const apiKey =
process.env.MINIMAX_API_KEY?.trim() ||
process.env.ANTHROPIC_API_KEY?.trim()
if (apiKey) {
process.env.ANTHROPIC_API_KEY = apiKey
}
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_MODEL =
(isMiniMaxModelName(modelOverride)
? modelOverride
: undefined) ??
getRouteDefaultModel('minimax')
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
}
function isXiaomiMimoModelName(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase()
return Boolean(
@@ -132,26 +196,6 @@ function isXiaomiMimoModelName(value: string | undefined): boolean {
)
}
function applyMiniMaxEnvOnlyDefaults(): void {
const baseUrlOverride = getMiniMaxBaseUrlOverride()
const hasMiniMaxBaseOverride = baseUrlOverride !== undefined
const modelOverride = process.env.OPENAI_MODEL?.trim() || undefined
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL =
baseUrlOverride ?? getRouteDefaultBaseUrl('minimax')
process.env.OPENAI_MODEL =
(hasMiniMaxBaseOverride || isMiniMaxModelName(modelOverride)
? modelOverride
: undefined) ??
getRouteDefaultModel('minimax')
process.env.OPENAI_API_KEY = process.env.MINIMAX_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
}
function applyXiaomiMimoEnvOnlyDefaults(): void {
const baseUrlOverride = getXiaomiMimoBaseUrlOverride()
const hasBaseOverride = baseUrlOverride !== undefined
@@ -245,8 +289,31 @@ export async function getAnthropicClient({
defaultHeaders['x-anthropic-additional-protection'] = 'true'
}
const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env)
const useMiniMaxEnvOnlyProvider = shouldUseMiniMaxEnvOnlyProvider(
model,
envOnlyProviderRouteId,
)
const useXiaomiMimoEnvOnlyProvider =
envOnlyProviderRouteId === 'xiaomi-mimo' && !useMiniMaxEnvOnlyProvider
const useXaiEnvOnlyProvider =
envOnlyProviderRouteId === 'xai' && !useMiniMaxEnvOnlyProvider
if (useMiniMaxEnvOnlyProvider) {
applyMiniMaxEnvOnlyDefaults(model)
}
if (useXiaomiMimoEnvOnlyProvider) {
applyXiaomiMimoEnvOnlyDefaults()
}
if (useXaiEnvOnlyProvider) {
applyXaiEnvOnlyDefaults()
}
const shouldUseFirstPartyAuth =
shouldUseFirstPartyAnthropicAuth(providerOverride)
const useMiniMaxNativeProvider =
useMiniMaxEnvOnlyProvider ||
(getAPIProvider() === 'minimax' &&
!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI))
if (shouldUseFirstPartyAuth) {
logForDebugging('[API:auth] OAuth token check starting')
@@ -313,22 +380,7 @@ export async function getAnthropicClient({
}
return new Anthropic(nativeArgs)
}
const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env)
const useXiaomiMimoEnvOnlyProvider = envOnlyProviderRouteId === 'xiaomi-mimo'
const useXaiEnvOnlyProvider = envOnlyProviderRouteId === 'xai'
const useMiniMaxEnvOnlyProvider = envOnlyProviderRouteId === 'minimax'
if (useMiniMaxEnvOnlyProvider) {
applyMiniMaxEnvOnlyDefaults()
}
if (useXiaomiMimoEnvOnlyProvider) {
applyXiaomiMimoEnvOnlyDefaults()
}
if (useXaiEnvOnlyProvider) {
applyXaiEnvOnlyDefaults()
}
if (
useMiniMaxEnvOnlyProvider ||
useXiaomiMimoEnvOnlyProvider ||
useXaiEnvOnlyProvider ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
@@ -499,7 +551,11 @@ export async function getAnthropicClient({
// Determine authentication method based on available tokens
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
apiKey: isClaudeAiSubscriber ? null : apiKey || getAnthropicApiKey(),
apiKey: isClaudeAiSubscriber
? null
: useMiniMaxNativeProvider
? process.env.MINIMAX_API_KEY || process.env.ANTHROPIC_API_KEY
: apiKey || getAnthropicApiKey(),
authToken: isClaudeAiSubscriber
? getClaudeAIOAuthTokens()?.accessToken
: undefined,
@@ -507,7 +563,9 @@ export async function getAnthropicClient({
...(process.env.USER_TYPE === 'ant' &&
isEnvTruthy(process.env.USE_STAGING_OAUTH)
? { baseURL: getOauthConfig().BASE_API_URL }
: {}),
: process.env.ANTHROPIC_BASE_URL
? { baseURL: process.env.ANTHROPIC_BASE_URL }
: {}),
...ARGS,
...(isDebugToStdErr() && { logger: createStderrLogger() }),
}
+90 -43
View File
@@ -1,37 +1,77 @@
import { afterAll, beforeEach, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { compressToolHistory, getTiers } from './compressToolHistory.js'
import {
compressToolHistory,
getTiers,
setToolHistoryCompressionEnabledOverrideForTest,
} from './compressToolHistory.js'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_AUTO_COMPACT_WINDOW:
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS,
}
const originalConfig = {
toolHistoryCompressionEnabled:
getGlobalConfig().toolHistoryCompressionEnabled,
}
// Mock the two dependencies so tests are deterministic and don't read disk config.
const mockState = {
enabled: true,
effectiveWindow: 100_000,
}
await acquireSharedMutationLock('services/api/compressToolHistory.test.ts')
function restoreEnv(key: keyof typeof originalEnv): void {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
mock.module('../../utils/config.js', () => ({
getGlobalConfig: () => ({
function setEffectiveWindowForTest(effectiveWindow: number): void {
mockState.effectiveWindow = effectiveWindow
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '8000'
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(effectiveWindow + 8_000)
}
function setCompressionEnabledForTest(enabled: boolean): void {
mockState.enabled = enabled
setToolHistoryCompressionEnabledOverrideForTest(enabled)
saveGlobalConfig(current => ({
...current,
toolHistoryCompressionEnabled: mockState.enabled,
}),
}))
}))
}
mock.module('../compact/autoCompact.js', () => ({
getEffectiveContextWindowSize: () => mockState.effectiveWindow,
}))
beforeEach(() => {
mockState.enabled = true
mockState.effectiveWindow = 100_000
beforeEach(async () => {
await acquireSharedMutationLock('compressToolHistory.test.ts')
setCompressionEnabledForTest(true)
setEffectiveWindowForTest(100_000)
})
afterAll(() => {
afterEach(() => {
try {
mock.restore()
mockState.enabled = true
mockState.effectiveWindow = 100_000
for (const key of Object.keys(originalEnv) as Array<keyof typeof originalEnv>) {
restoreEnv(key)
}
saveGlobalConfig(current => ({
...current,
toolHistoryCompressionEnabled:
originalConfig.toolHistoryCompressionEnabled,
}))
} finally {
setToolHistoryCompressionEnabledOverrideForTest(undefined)
releaseSharedMutationLock()
}
})
@@ -100,6 +140,14 @@ function getResultText(msg: Msg): string {
return ''
}
function compressToolHistoryForTest<T>(
messages: T[],
model = 'gpt-4o',
effectiveContextWindowSize = 100_000,
): T[] {
return compressToolHistory(messages, model, { effectiveContextWindowSize })
}
// ---------- getTiers ----------
test('getTiers: < 16k window → recent=2, mid=3', () => {
@@ -133,16 +181,16 @@ test('getTiers: ≥ 500k (gpt-4.1 1M) → recent=25, mid=50', () => {
// ---------- master switch ----------
test('pass-through when toolHistoryCompressionEnabled is false', () => {
mockState.enabled = false
setCompressionEnabledForTest(false)
const messages = buildConversation(20)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
expect(result).toBe(messages) // same reference (no transformation)
})
test('pass-through when total tool_results <= recent tier', () => {
// 100k effective → recent=5; only 4 exchanges → no compression
const messages = buildConversation(4)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
expect(result).toBe(messages)
})
@@ -151,7 +199,7 @@ test('pass-through when total tool_results <= recent tier', () => {
test('recent tier: tool_result content untouched', () => {
// 100k effective → recent=5, mid=10. With 6 exchanges, only the oldest is touched.
const messages = buildConversation(6, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// Last 5 should be untouched (full 5000 chars)
@@ -163,7 +211,7 @@ test('recent tier: tool_result content untouched', () => {
test('mid tier: long content truncated to MID_MAX_CHARS with marker', () => {
// 100k → recent=5, mid=10. 10 exchanges: 5 recent + 5 mid (none old).
const messages = buildConversation(10, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// First 5 are mid tier — should be truncated to ~2000 chars + marker
@@ -179,7 +227,7 @@ test('mid tier: long content truncated to MID_MAX_CHARS with marker', () => {
test('mid tier: short content (< MID_MAX_CHARS) untouched', () => {
const messages = buildConversation(10, 500) // 500 < MID_MAX_CHARS
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
for (let i = 0; i < 5; i++) {
@@ -190,7 +238,7 @@ test('mid tier: short content (< MID_MAX_CHARS) untouched', () => {
test('old tier: content replaced with stub [name args={...} → N chars omitted]', () => {
// 100k → recent=5, mid=10, old=rest. 20 exchanges → 5 old + 10 mid + 5 recent.
const messages = buildConversation(20, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// First 5 are old tier — should be stubs
@@ -224,7 +272,7 @@ test('old tier: stub args truncated to 200 chars', () => {
// Pad with enough recent exchanges to push the above into old tier
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
const text = getResultText(resultMsgs[0])
@@ -247,7 +295,7 @@ test('old tier: orphan tool_result (no matching tool_use) falls back to "tool"',
},
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
const text = getResultText(resultMsgs[0])
@@ -258,7 +306,7 @@ test('old tier: orphan tool_result (no matching tool_use) falls back to "tool"',
test('tool_use blocks always preserved', () => {
const messages = buildConversation(20, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const useCount = (msgs: Msg[]) =>
msgs.reduce((sum, m) => {
@@ -285,7 +333,7 @@ test('text blocks always preserved', () => {
},
...buildConversation(20, 5_000).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const assistantMsg = (result as Msg[])[1]
const textBlock = (assistantMsg.content as Block[]).find((b: any) => b.type === 'text')
@@ -308,7 +356,7 @@ test('thinking blocks always preserved', () => {
},
...buildConversation(20, 5_000).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const assistantMsg = (result as Msg[])[1]
const thinking = (assistantMsg.content as Block[]).find((b: any) => b.type === 'thinking')
@@ -324,7 +372,7 @@ test('non-array content (string) handled gracefully', () => {
{ role: 'user', content: 'plain string content' },
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
expect((result as Msg[])[0].content).toBe('plain string content')
})
@@ -333,7 +381,7 @@ test('empty content array handled gracefully', () => {
{ role: 'user', content: [] },
...buildConversation(20, 100).slice(1),
]
expect(() => compressToolHistory(messages, 'gpt-4o')).not.toThrow()
expect(() => compressToolHistoryForTest(messages, 'gpt-4o')).not.toThrow()
})
// ---------- message shape compatibility ----------
@@ -342,7 +390,7 @@ test('wrapped shape ({ message: { role, content } }) handled', () => {
type WrappedMsg = { message: { role: string; content: Block[] | string } }
const wrap = (m: Msg): WrappedMsg => ({ message: { role: m.role, content: m.content } })
const messages = buildConversation(20, 5_000).map(wrap)
const result = compressToolHistory(messages as any, 'gpt-4o')
const result = compressToolHistoryForTest(messages as any, 'gpt-4o')
// First wrapped tool-result message should have stub content (old tier)
const firstResultMsg = (result as WrappedMsg[]).find(
@@ -359,7 +407,7 @@ test('wrapped shape ({ message: { role, content } }) handled', () => {
test('flat shape ({ role, content }) handled', () => {
const messages = buildConversation(20, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
expect(getResultText(resultMsgs[0])).toMatch(/^\[Read args=.*→ 5000 chars omitted\]$/)
@@ -369,7 +417,7 @@ test('flat shape ({ role, content }) handled', () => {
test('tier boundaries: 6 exchanges → 1 mid + 5 recent (recent=5)', () => {
const messages = buildConversation(6, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// Oldest: mid (truncated)
@@ -382,7 +430,7 @@ test('tier boundaries: 6 exchanges → 1 mid + 5 recent (recent=5)', () => {
test('tier boundaries: 16 exchanges → 1 old + 10 mid + 5 recent', () => {
const messages = buildConversation(16, 5_000)
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// Oldest 1: stub (old tier)
@@ -399,9 +447,8 @@ test('tier boundaries: 16 exchanges → 1 old + 10 mid + 5 recent', () => {
test('large window (1M) with 30 exchanges: all untouched (recent=25 ≥ 30 - 5)', () => {
// ≥500k → recent=25, mid=50. 30 exchanges → 5 mid + 25 recent. None old.
mockState.effectiveWindow = 1_000_000
const messages = buildConversation(30, 5_000)
const result = compressToolHistory(messages, 'gpt-4.1')
const result = compressToolHistoryForTest(messages, 'gpt-4.1', 1_000_000)
const resultMsgs = getResultMessages(result)
// Last 25: untouched
@@ -433,7 +480,7 @@ test('is_error flag preserved in mid tier', () => {
// Pad with enough recent exchanges to push the above into MID tier
...buildConversation(10, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
const block = getResultBlock(resultMsgs[0]) as { is_error?: boolean; content: unknown }
@@ -461,7 +508,7 @@ test('is_error flag preserved in old tier (stub)', () => {
},
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
const block = getResultBlock(resultMsgs[0]) as { is_error?: boolean; content: unknown }
@@ -490,7 +537,7 @@ test('non-compactable tool (e.g. Task/Agent) is NEVER compressed', () => {
// Pad with 20 compactable exchanges to push Task into old tier
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// First tool_result is for Task (non-compactable) → must remain full
@@ -516,7 +563,7 @@ test('mcp__ prefixed tools ARE compactable (matches microCompact behavior)', ()
},
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// MCP tool result is compressed (gets stub since it's in old tier)
@@ -544,7 +591,7 @@ test('blocks already cleared by microCompact are NOT re-compressed', () => {
},
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
// Already-cleared marker survives untouched (no double processing)
@@ -572,7 +619,7 @@ test('extra block attributes (e.g. cache_control) preserved across rewrites', ()
},
...buildConversation(20, 100).slice(1),
]
const result = compressToolHistory(messages, 'gpt-4o')
const result = compressToolHistoryForTest(messages, 'gpt-4o')
const resultMsgs = getResultMessages(result)
const block = getResultBlock(resultMsgs[0]) as { cache_control?: unknown }
+16 -2
View File
@@ -75,6 +75,14 @@ export function getTiers(effectiveWindow: number): Tiers {
return { recent: 25, mid: 50 }
}
let toolHistoryCompressionEnabledOverrideForTest: boolean | undefined
export function setToolHistoryCompressionEnabledOverrideForTest(
enabled: boolean | undefined,
): void {
toolHistoryCompressionEnabledOverrideForTest = enabled
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
@@ -209,12 +217,18 @@ function shouldCompressBlock(
export function compressToolHistory<T extends AnyMessage>(
messages: T[],
model: string,
options: { effectiveContextWindowSize?: number } = {},
): T[] {
// Master kill-switch. Returns the original reference so callers skip a
// defensive copy when the feature is disabled.
if (!getGlobalConfig().toolHistoryCompressionEnabled) return messages
const compressionEnabled =
toolHistoryCompressionEnabledOverrideForTest ??
getGlobalConfig().toolHistoryCompressionEnabled
if (!compressionEnabled) return messages
const tiers = getTiers(getEffectiveContextWindowSize(model))
const tiers = getTiers(
options.effectiveContextWindowSize ?? getEffectiveContextWindowSize(model),
)
const toolResultIndices = indexToolResultMessages(messages)
const total = toolResultIndices.length
+66 -35
View File
@@ -1,34 +1,62 @@
import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
import { setToolHistoryCompressionEnabledOverrideForTest } from './compressToolHistory.js'
import { createOpenAIShimClient } from './openaiShim.js'
type FetchType = typeof globalThis.fetch
const originalFetch = globalThis.fetch
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_AUTO_COMPACT_WINDOW:
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
}
// Mock config + autoCompact so the shim sees deterministic state.
const originalConfig = {
toolHistoryCompressionEnabled:
getGlobalConfig().toolHistoryCompressionEnabled,
autoCompactEnabled: getGlobalConfig().autoCompactEnabled,
}
const mockState = {
enabled: true,
effectiveWindow: 100_000, // Copilot gpt-4o tier
}
await acquireSharedMutationLock('openaiShim.compression.test.ts')
const MID_TIER_MODEL = 'llama-3.1-8b-instant'
const LARGE_CONTEXT_MODEL = 'deepseek-v4-flash'
const SMALL_CONTEXT_MODEL = 'minimax-vision-01'
mock.module('../../utils/config.js', () => ({
getGlobalConfig: () => ({
function restoreEnv(key: keyof typeof originalEnv): void {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
function setEffectiveWindowForTest(effectiveWindow: number): void {
mockState.effectiveWindow = effectiveWindow
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '8000'
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(effectiveWindow + 8_000)
}
function setCompressionEnabledForTest(enabled: boolean): void {
mockState.enabled = enabled
setToolHistoryCompressionEnabledOverrideForTest(enabled)
saveGlobalConfig(current => ({
...current,
toolHistoryCompressionEnabled: mockState.enabled,
autoCompactEnabled: false,
}),
}))
mock.module('../compact/autoCompact.js', () => ({
getEffectiveContextWindowSize: () => mockState.effectiveWindow,
}))
}))
}
type OpenAIShimClient = {
beta: {
@@ -99,27 +127,28 @@ function makeFakeResponse(): Response {
}
beforeEach(async () => {
await acquireSharedMutationLock('openaiShim.compression.test.ts')
setCompressionEnabledForTest(true)
setEffectiveWindowForTest(100_000)
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
process.env.OPENAI_API_KEY = 'test-key'
delete process.env.OPENAI_MODEL
mockState.enabled = true
mockState.effectiveWindow = 100_000
})
afterEach(() => {
if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL
else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
if (originalEnv.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
globalThis.fetch = originalFetch
})
afterAll(() => {
try {
mock.restore()
for (const key of Object.keys(originalEnv) as Array<keyof typeof originalEnv>) {
restoreEnv(key)
}
saveGlobalConfig(current => ({
...current,
toolHistoryCompressionEnabled:
originalConfig.toolHistoryCompressionEnabled,
autoCompactEnabled: originalConfig.autoCompactEnabled,
}))
globalThis.fetch = originalFetch
} finally {
setToolHistoryCompressionEnabledOverrideForTest(undefined)
releaseSharedMutationLock()
}
})
@@ -128,6 +157,8 @@ async function captureRequestBody(
messages: Array<{ role: string; content: unknown }>,
model: string,
): Promise<Record<string, unknown>> {
setCompressionEnabledForTest(mockState.enabled)
setEffectiveWindowForTest(mockState.effectiveWindow)
let captured: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
@@ -166,7 +197,7 @@ function getAssistantToolCalls(body: Record<string, unknown>): unknown[] {
// ============================================================================
test('BUG REPRO: without compression, all 30 tool results are sent at full size', async () => {
mockState.enabled = false
setCompressionEnabledForTest(false)
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4o')
@@ -189,12 +220,12 @@ test('BUG REPRO: without compression, all 30 tool results are sent at full size'
// FIX: with compression, recent kept full, mid truncated, old stubbed
// ============================================================================
test('FIX: with compression on Copilot gpt-4o (tier 5/10/rest), 30 turns shrinks dramatically', async () => {
test('FIX: with compression on a 128k model (tier 5/10/rest), 30 turns shrinks dramatically', async () => {
mockState.enabled = true
mockState.effectiveWindow = 100_000 // 64128k → recent=5, mid=10
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4o')
const body = await captureRequestBody(messages, MID_TIER_MODEL)
const toolMessages = getToolMessages(body)
const payloadSize = JSON.stringify(body).length
@@ -226,12 +257,12 @@ test('FIX: with compression on Copilot gpt-4o (tier 5/10/rest), 30 turns shrinks
// FIX: large-context model gets generous tiers — compression effectively inert
// ============================================================================
test('FIX: gpt-4.1 (1M context) with 25 exchanges keeps all full (recent tier=25)', async () => {
test('FIX: 1M context model with 25 exchanges keeps all full (recent tier=25)', async () => {
mockState.enabled = true
mockState.effectiveWindow = 1_000_000 // ≥500k → recent=25, mid=50
const messages = buildLongConversation(25, 5_000)
const body = await captureRequestBody(messages, 'gpt-4.1')
const body = await captureRequestBody(messages, LARGE_CONTEXT_MODEL)
const toolMessages = getToolMessages(body)
expect(toolMessages.length).toBe(25)
@@ -242,12 +273,12 @@ test('FIX: gpt-4.1 (1M context) with 25 exchanges keeps all full (recent tier=25
}
})
test('FIX: gpt-4.1 (1M context) with 30 exchanges → only first 5 mid-truncated', async () => {
test('FIX: 1M context model with 30 exchanges → only first 5 mid-truncated', async () => {
mockState.enabled = true
mockState.effectiveWindow = 1_000_000 // recent=25, mid=50
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4.1')
const body = await captureRequestBody(messages, LARGE_CONTEXT_MODEL)
const toolMessages = getToolMessages(body)
// 30 total: indices 0..4 mid, indices 5..29 recent
@@ -268,7 +299,7 @@ test('FIX: stub format includes original tool name and arguments', async () => {
mockState.effectiveWindow = 100_000
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4o')
const body = await captureRequestBody(messages, MID_TIER_MODEL)
const toolMessages = getToolMessages(body)
const oldestStub = toolMessages[0].content
@@ -287,7 +318,7 @@ test('FIX: every tool_call retains its full id, name, and arguments', async () =
mockState.effectiveWindow = 100_000
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4o')
const body = await captureRequestBody(messages, MID_TIER_MODEL)
const toolCalls = getAssistantToolCalls(body) as Array<{
id: string
function: { name: string; arguments: string }
@@ -307,12 +338,12 @@ test('FIX: every tool_call retains its full id, name, and arguments', async () =
// FIX: small-context provider (Mistral 32k) gets aggressive compression
// ============================================================================
test('FIX: 32k window (Mistral tier) → recent=3 keeps last 3 only', async () => {
test('FIX: 32k window tier → recent=3 keeps last 3 only', async () => {
mockState.enabled = true
mockState.effectiveWindow = 24_000 // 1632k → recent=3, mid=5
const messages = buildLongConversation(15, 3_000)
const body = await captureRequestBody(messages, 'mistral-large-latest')
const body = await captureRequestBody(messages, SMALL_CONTEXT_MODEL)
const toolMessages = getToolMessages(body)
// 15 total: indices 0..6 old, 7..11 mid, 12..14 recent
+109 -24
View File
@@ -1,40 +1,78 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
getEffectiveContextWindowSize,
getAutoCompactThreshold,
} from './autoCompact.ts'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
let originalUseOpenAI: string | undefined
async function importAutoCompact() {
mock.restore()
const nonce = `${Date.now()}-${Math.random()}`
return import(`./autoCompact.ts?test=${nonce}`)
}
const SAVED_ENV = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID,
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
XAI_API_KEY: process.env.XAI_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
USER_TYPE: process.env.USER_TYPE,
CLAUDE_CODE_MAX_CONTEXT_TOKENS:
process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS,
CLAUDE_CODE_AUTO_COMPACT_WINDOW:
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
CLAUDE_CODE_MAX_OUTPUT_TOKENS:
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS,
}
function restoreEnv(): void {
for (const [key, value] of Object.entries(SAVED_ENV)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
beforeEach(async () => {
await acquireSharedMutationLock('services/compact/autoCompact.test.ts')
originalUseOpenAI = process.env.CLAUDE_CODE_USE_OPENAI
})
afterEach(() => {
try {
if (originalUseOpenAI === undefined) {
delete process.env.CLAUDE_CODE_USE_OPENAI
} else {
process.env.CLAUDE_CODE_USE_OPENAI = originalUseOpenAI
}
restoreEnv()
} finally {
releaseSharedMutationLock()
}
})
describe('getEffectiveContextWindowSize', () => {
test('returns positive value for known models with large context windows', () => {
test('returns positive value for known models with large context windows', async () => {
const { getEffectiveContextWindowSize } = await importAutoCompact()
// claude-sonnet-4 has 200k context
const effective = getEffectiveContextWindowSize('claude-sonnet-4')
expect(effective).toBeGreaterThan(0)
})
test('never returns negative even for unknown 3P models (issue #635)', () => {
test('never returns negative even for unknown 3P models (issue #635)', async () => {
const { getEffectiveContextWindowSize } = await importAutoCompact()
// Previously, unknown 3P models got 8k context → effective context was
// 8k minus 20k summary reservation = -12k, causing infinite auto-compact.
// Now the fallback is 128k and there's a floor, so effective is always
@@ -47,24 +85,71 @@ describe('getEffectiveContextWindowSize', () => {
// disabled it's 20k + 13k = 33k. Assert the worst case so the test is
// stable regardless of flag state in CI vs local.
process.env.CLAUDE_CODE_USE_OPENAI = '1'
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_BUFFER_TOKENS (13k).
// Covers the anti-regression intent of issue #635 without assuming
// the GrowthBook flag state.
expect(effective).toBeGreaterThanOrEqual(21_000)
try {
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_BUFFER_TOKENS (13k).
// Covers the anti-regression intent of issue #635 without assuming
// the GrowthBook flag state.
expect(effective).toBeGreaterThanOrEqual(21_000)
} finally {
restoreEnv()
}
})
test('uses MiniMax M2 context and output metadata for compact budget', async () => {
const { getEffectiveContextWindowSize } = await importAutoCompact()
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
delete process.env.XAI_API_KEY
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.ANTHROPIC_API_KEY
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_MODEL
delete process.env.USER_TYPE
delete process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_API_KEY = 'ambient-openai-key'
process.env.MINIMAX_API_KEY = 'minimax-test'
process.env.OPENAI_MODEL = 'MiniMax-M2.7'
try {
// MiniMax's recommended Anthropic-compatible endpoint supports the full
// M2 window. Compact reserves either the default 20k summary output
// tokens or 8k when the slot-reservation cap flag is enabled.
expect([184_800, 196_800]).toContain(
getEffectiveContextWindowSize('MiniMax-M2.7'),
)
} finally {
restoreEnv()
}
})
})
describe('getAutoCompactThreshold', () => {
test('returns positive threshold for known models', () => {
test('returns positive threshold for known models', async () => {
const { getAutoCompactThreshold } = await importAutoCompact()
const threshold = getAutoCompactThreshold('claude-sonnet-4')
expect(threshold).toBeGreaterThan(0)
})
test('never returns negative threshold even for unknown 3P models (issue #635)', () => {
test('never returns negative threshold even for unknown 3P models (issue #635)', async () => {
const { getAutoCompactThreshold } = await importAutoCompact()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
const threshold = getAutoCompactThreshold('some-unknown-3p-model')
expect(threshold).toBeGreaterThan(0)
try {
const threshold = getAutoCompactThreshold('some-unknown-3p-model')
expect(threshold).toBeGreaterThan(0)
} finally {
restoreEnv()
}
})
})
+8 -7
View File
@@ -258,12 +258,12 @@ test('gpt-5.4 family keeps large max output overrides within provider limits', (
expect(getMaxOutputTokensForModel('gpt-5.4-nano')).toBe(128_000)
})
test('MiniMax-M2.7 uses explicit provider-specific context and output caps', () => {
test('MiniMax-M2.7 uses the shared gateway-safe context cap by default', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(196_608)
expect(getModelMaxOutputTokens('MiniMax-M2.7')).toEqual({
default: 131_072,
upperLimit: 131_072,
@@ -278,6 +278,7 @@ test('env-only MiniMax key uses provider-specific context and output caps before
delete process.env.OPENAI_MODEL
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.5')).toBe(204_800)
expect(getModelMaxOutputTokens('MiniMax-M2.7')).toEqual({
default: 131_072,
upperLimit: 131_072,
@@ -436,15 +437,15 @@ test('OpenAI-compatible legacy aliases keep their migrated limits', () => {
})
})
test('MiniMax-M2.5 and M2.1 use explicit provider-specific context and output caps', () => {
test('MiniMax-M2.5 and M2.1 use shared gateway-safe context caps by default', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
expect(getContextWindowForModel('MiniMax-M2.5')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.5-highspeed')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.1')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.1-highspeed')).toBe(204_800)
expect(getContextWindowForModel('MiniMax-M2.5')).toBe(196_608)
expect(getContextWindowForModel('MiniMax-M2.5-highspeed')).toBe(196_608)
expect(getContextWindowForModel('MiniMax-M2.1')).toBe(196_608)
expect(getContextWindowForModel('MiniMax-M2.1-highspeed')).toBe(196_608)
expect(getModelMaxOutputTokens('MiniMax-M2.5')).toEqual({
default: 131_072,
upperLimit: 131_072,
+1
View File
@@ -71,6 +71,7 @@ function shouldUseIntegrationRuntimeLimits(
return (
transportKind === 'openai-compatible' ||
transportKind === 'anthropic-proxy' ||
transportKind === 'local' ||
transportKind === 'gemini-native'
)
+12 -17
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
import { describe, expect, it, beforeEach, afterEach, afterAll } from 'bun:test'
import {
addGlobalEntity,
addGlobalSummary,
@@ -18,7 +18,8 @@ import { getFsImplementation } from './fsOperations.js'
describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalOrama = process.env.OPENCLAUDE_KNOWLEDGE_ORAMA
let configDir: string | undefined
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-'))
const cwd = getFsImplementation().cwd()
const removeDirWithRetry = (dir: string) => {
for (let attempt = 0; attempt < 5; attempt++) {
@@ -46,7 +47,6 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
beforeEach(async () => {
await acquireEnvMutex()
configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-'))
process.env.CLAUDE_CONFIG_DIR = configDir
process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = '1'
setClaudeConfigHomeDirForTesting(configDir)
@@ -69,18 +69,14 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
}
setClaudeConfigHomeDirForTesting(undefined)
} finally {
const dirToRemove = configDir
configDir = undefined
try {
if (dirToRemove) {
removeDirWithRetry(dirToRemove)
}
} finally {
releaseEnvMutex()
}
releaseEnvMutex()
}
})
afterAll(() => {
removeDirWithRetry(configDir)
})
it('handles high-volume entity insertion (Stress Test)', async () => {
const count = 50
@@ -113,7 +109,6 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
// 1. Create a valid DB
await addGlobalEntity('type', 'valid', { val: '1' })
const { getOramaPersistencePath } = await import('./knowledgeGraph.js')
const cwd = getFsImplementation().cwd()
const oramaPath = getOramaPersistencePath(cwd)
expect(existsSync(oramaPath)).toBe(true)
@@ -131,9 +126,9 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
// 5. Verify the corrupted file was moved
const { readdirSync } = await import('fs')
const projectDir = dirname(oramaPath)
expect(existsSync(projectDir)).toBe(true)
// Search recursively for the corrupted file
const oramaDir = dirname(oramaPath)
expect(existsSync(oramaDir)).toBe(true)
// Search from the actual persistence directory for the corrupted file.
const findCorrupted = (dir: string): boolean => {
const entries = readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
@@ -145,7 +140,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
}
return false
}
expect(findCorrupted(projectDir)).toBe(true)
expect(findCorrupted(oramaDir)).toBe(true)
})
it('maintains consistency between JSON and Orama', async () => {
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, expect, test } from 'bun:test'
const ORIGINAL_ENV = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
}
function restoreEnv(): void {
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
afterEach(() => {
restoreEnv()
})
test('benchmark support does not advertise direct MiniMax Anthropic-compatible env', async () => {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_MODEL = 'MiniMax-M2.7'
const { isBenchmarkSupported } = await import(
`./benchmark.js?ts=${Date.now()}-${Math.random()}`
)
expect(isBenchmarkSupported()).toBe(false)
})
+2 -2
View File
@@ -34,8 +34,8 @@ function getBenchmarkEndpoint(): string | null {
if (provider === 'openai' || provider === 'firstParty') {
return `${baseUrl || 'https://api.openai.com/v1'}/chat/completions`
}
// NVIDIA NIM or MiniMax via OPENAI_BASE_URL
if (baseUrl?.includes('nvidia') || baseUrl?.includes('minimax')) {
// NVIDIA NIM via OPENAI_BASE_URL
if (baseUrl?.includes('nvidia')) {
return `${baseUrl}/chat/completions`
}
return null
@@ -50,6 +50,7 @@ const SAVED_ENV = {
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
NVIDIA_NIM: process.env.NVIDIA_NIM,
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
MIMO_API_KEY: process.env.MIMO_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
@@ -82,6 +83,7 @@ beforeEach(async () => {
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.NVIDIA_NIM
delete process.env.MINIMAX_API_KEY
delete process.env.ANTHROPIC_MODEL
delete process.env.MIMO_API_KEY
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_BASE_URL
+10 -5
View File
@@ -34,6 +34,10 @@ export type ModelShortName = string
export type ModelName = string
export type ModelSetting = ModelName | ModelAlias | null
function getMiniMaxModelEnv(): string | undefined {
return process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL
}
function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
@@ -70,7 +74,7 @@ export function getSmallFastModel(): ModelName {
// MiniMax — OPENAI_MODEL carries the active MiniMax model; fall back to
// the fastest tier (M2.5-highspeed) when missing.
if (getAPIProvider() === 'minimax') {
return process.env.OPENAI_MODEL || 'MiniMax-M2.5-highspeed'
return getMiniMaxModelEnv() || 'MiniMax-M2.5-highspeed'
}
// Xiaomi MiMo — OPENAI_MODEL carries the active MiMo model; fall back to
// the fast tier when missing.
@@ -136,6 +140,7 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
specifiedModel =
(provider === 'gemini' ? process.env.GEMINI_MODEL : undefined) ||
(provider === 'mistral' ? process.env.MISTRAL_MODEL : undefined) ||
(provider === 'minimax' ? getMiniMaxModelEnv() : undefined) ||
(isOpenAIShimProvider ? process.env.OPENAI_MODEL : undefined) ||
(provider === 'firstParty' ? process.env.ANTHROPIC_MODEL : undefined) ||
setting ||
@@ -205,7 +210,7 @@ export function getDefaultOpusModel(): ModelName {
}
// MiniMax — flagship tier for "opus"-equivalent.
if (getAPIProvider() === 'minimax') {
return process.env.OPENAI_MODEL || 'MiniMax-M2.7'
return getMiniMaxModelEnv() || 'MiniMax-M2.7'
}
// Xiaomi MiMo — flagship tier for "opus"-equivalent.
if (getAPIProvider() === 'xiaomi-mimo') {
@@ -255,7 +260,7 @@ export function getDefaultSonnetModel(): ModelName {
}
// MiniMax — mid tier for "sonnet"-equivalent.
if (getAPIProvider() === 'minimax') {
return process.env.OPENAI_MODEL || 'MiniMax-M2.5'
return getMiniMaxModelEnv() || 'MiniMax-M2.5'
}
// Xiaomi MiMo — flagship model for "sonnet"-equivalent.
if (getAPIProvider() === 'xiaomi-mimo') {
@@ -303,7 +308,7 @@ export function getDefaultHaikuModel(): ModelName {
}
// MiniMax — fastest tier for "haiku"-equivalent.
if (getAPIProvider() === 'minimax') {
return process.env.OPENAI_MODEL || 'MiniMax-M2.5-highspeed'
return getMiniMaxModelEnv() || 'MiniMax-M2.5-highspeed'
}
// Xiaomi MiMo — fast tier for "haiku"-equivalent.
if (getAPIProvider() === 'xiaomi-mimo') {
@@ -387,7 +392,7 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
}
// MiniMax provider: always use the configured MiniMax model
if (getAPIProvider() === 'minimax') {
return process.env.OPENAI_MODEL || 'MiniMax-M2.7'
return getMiniMaxModelEnv() || 'MiniMax-M2.7'
}
// Xiaomi MiMo provider: always use the configured MiMo model
if (getAPIProvider() === 'xiaomi-mimo') {
+18
View File
@@ -14,6 +14,9 @@ const originalEnv = {
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
NVIDIA_NIM: process.env.NVIDIA_NIM,
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
@@ -58,6 +61,9 @@ function clearProviderEnv(): void {
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.NVIDIA_NIM
delete process.env.MINIMAX_API_KEY
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_API_KEY
delete process.env.ANTHROPIC_MODEL
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
@@ -219,6 +225,18 @@ test('env-only MiniMax API key resolves to the minimax provider', async () => {
expect(getAPIProvider()).toBe('minimax')
})
test('Anthropic-compatible MiniMax profile resolves to the minimax provider', async () => {
clearProviderEnv()
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_API_KEY = 'minimax-key'
process.env.ANTHROPIC_MODEL = 'MiniMax-M2.7'
const { getAPIProvider, usesAnthropicAccountFlow } =
await importFreshProvidersModule()
expect(getAPIProvider()).toBe('minimax')
expect(usesAnthropicAccountFlow()).toBe(false)
})
test('conflicting OpenAI base prevents env-only MiniMax provider label', async () => {
clearProviderEnv()
process.env.MINIMAX_API_KEY = 'minimax-key'
+10 -3
View File
@@ -326,12 +326,19 @@ describe('applyProviderFlag - descriptor-backed openai-compatible routes', () =>
describe('applyProviderFlag - minimax', () => {
test('preserves MiniMax default base URL and model semantics', () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
process.env.ANTHROPIC_MODEL = 'claude-sonnet-4-5'
const result = applyProviderFlag('minimax', [])
expect(result.error).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(process.env.OPENAI_BASE_URL).toBe('https://api.minimax.io/v1')
expect(process.env.OPENAI_MODEL).toBe('MiniMax-M2.7')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.minimax.io/anthropic')
expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M2.7')
expect(process.env.OPENAI_BASE_URL).toBeUndefined()
expect(process.env.OPENAI_MODEL).toBeUndefined()
})
})
+19
View File
@@ -282,6 +282,25 @@ export function applyProviderFlag(
}
break
case 'minimax':
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
process.env.ANTHROPIC_BASE_URL = defaultBaseUrl ?? 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_MODEL = defaultModel ?? 'MiniMax-M2.7'
if (model) process.env.ANTHROPIC_MODEL = model
if (process.env.MINIMAX_API_KEY && !process.env.ANTHROPIC_API_KEY) {
process.env.ANTHROPIC_API_KEY = process.env.MINIMAX_API_KEY
}
if (copiedOpenAIKeyProvider === 'minimax') {
delete process.env.OPENAI_API_KEY
}
break
default:
process.env.CLAUDE_CODE_USE_OPENAI = '1'
if (defaultBaseUrl) {
+14 -6
View File
@@ -186,6 +186,7 @@ export type ProfileFile = {
type SecretValueSource = Partial<
Record<
| 'OPENAI_API_KEY'
| 'ANTHROPIC_API_KEY'
| 'OPENAI_AUTH_HEADER_VALUE'
| 'CODEX_API_KEY'
| 'GEMINI_API_KEY'
@@ -448,22 +449,29 @@ export function buildMiniMaxProfileEnv(options: {
if (!defaultBaseUrl || !defaultModel) {
throw new Error('MiniMax route defaults are missing from integration metadata.')
}
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
const secretSource: SecretValueSource = {
ANTHROPIC_API_KEY: key,
MINIMAX_API_KEY: key,
OPENAI_API_KEY: key,
}
return {
OPENAI_BASE_URL:
ANTHROPIC_BASE_URL:
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
sanitizeProviderConfigValue(processEnv.ANTHROPIC_BASE_URL, secretSource) ||
defaultBaseUrl,
OPENAI_MODEL:
ANTHROPIC_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
normalizeProfileModel(
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource),
sanitizeProviderConfigValue(
processEnv.ANTHROPIC_MODEL ?? processEnv.OPENAI_MODEL,
secretSource,
),
) ||
defaultModel,
OPENAI_API_KEY: key,
ANTHROPIC_API_KEY: key,
MINIMAX_API_KEY: key,
MINIMAX_BASE_URL: defaultBaseUrl,
MINIMAX_MODEL: defaultModel,
+34 -5
View File
@@ -470,7 +470,7 @@ describe('applyProviderProfileToProcessEnv', () => {
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'minimax',
baseUrl: 'https://api.minimax.io/v1',
baseUrl: 'https://api.minimax.io/anthropic',
model: 'MiniMax-M2.7',
apiKey: 'minimax-live-key',
apiFormat: 'responses',
@@ -483,10 +483,11 @@ describe('applyProviderProfileToProcessEnv', () => {
}),
)
expect(process.env.OPENAI_BASE_URL).toBe('https://api.minimax.io/v1')
expect(process.env.OPENAI_MODEL).toBe('MiniMax-M2.7')
expect(process.env.OPENAI_API_KEY).toBe('minimax-live-key')
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.minimax.io/anthropic')
expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M2.7')
expect(process.env.ANTHROPIC_API_KEY).toBe('minimax-live-key')
expect(process.env.MINIMAX_API_KEY).toBe('minimax-live-key')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined()
@@ -830,6 +831,34 @@ describe('applyActiveProviderProfileFromConfig', () => {
expect(process.env.OPENAI_MODEL).toBe('gpt-4o-mini')
})
test('does not override explicit env-only MiniMax selection with saved profile', async () => {
const { applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
process.env.MINIMAX_API_KEY = 'minimax-live-key'
process.env.ANTHROPIC_BASE_URL = 'https://api.minimax.io/anthropic'
process.env.ANTHROPIC_MODEL = 'MiniMax-M2.7'
const applied = applyActiveProviderProfileFromConfig({
providerProfiles: [
buildProfile({
id: 'saved_openai',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
}),
],
activeProviderProfileId: 'saved_openai',
} as any)
expect(applied).toBeUndefined()
expect(process.env.MINIMAX_API_KEY).toBe('minimax-live-key')
expect(process.env.ANTHROPIC_BASE_URL).toBe(
'https://api.minimax.io/anthropic',
)
expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M2.7')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.OPENAI_BASE_URL).toBeUndefined()
})
test('does not override explicit startup selection when profile marker is stale', async () => {
const { applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
@@ -1129,7 +1158,7 @@ describe('getProviderPresetDefaults', () => {
expect(defaults.provider).toBe('minimax')
expect(defaults.name).toBe('MiniMax')
expect(defaults.baseUrl).toBe('https://api.minimax.io/v1')
expect(defaults.baseUrl).toBe('https://api.minimax.io/anthropic')
expect(defaults.model).toBe('MiniMax-M2.7')
expect(defaults.requiresApiKey).toBe(true)
})
+31 -4
View File
@@ -42,6 +42,7 @@ import {
type ResolvedProfileRoute,
type ProviderPreset,
} from '../integrations/index.js'
import { resolveEnvOnlyProviderRouteId } from '../integrations/routeMetadata.js'
import { logForDebugging } from './debug.js'
import {
sanitizeProfileCustomHeaders,
@@ -98,6 +99,9 @@ function resolveProfileCompatibility(provider: string): {
if (route.vendorId === 'anthropic') {
return { route, compatibilityMode: 'anthropic' }
}
if (route.vendorId === 'minimax') {
return { route, compatibilityMode: 'anthropic' }
}
if (route.vendorId === 'gemini') {
return { route, compatibilityMode: 'gemini' }
}
@@ -345,6 +349,7 @@ function hasProviderSelectionFlags(
function hasCompleteProviderSelection(
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
if (resolveEnvOnlyProviderRouteId(processEnv) !== null) return true
if (!hasProviderSelectionFlags(processEnv)) return false
if (processEnv.CLAUDE_CODE_USE_OPENAI !== undefined) {
return (
@@ -581,10 +586,20 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
}
if (compatibilityMode === 'anthropic') {
profileEnv = {
ANTHROPIC_BASE_URL: profile.baseUrl,
ANTHROPIC_MODEL: primaryModel,
...(profile.apiKey ? { ANTHROPIC_API_KEY: profile.apiKey } : {}),
if (route.vendorId === 'minimax') {
profileEnv =
buildMiniMaxProfileEnv({
model: primaryModel,
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
processEnv: process.env,
}) ?? {}
} else {
profileEnv = {
ANTHROPIC_BASE_URL: profile.baseUrl,
ANTHROPIC_MODEL: primaryModel,
...(profile.apiKey ? { ANTHROPIC_API_KEY: profile.apiKey } : {}),
}
}
} else if (compatibilityMode === 'mistral') {
profileEnv = {
@@ -990,6 +1005,18 @@ function buildStartupProfileFromActiveProfile(
switch (compatibilityMode) {
case 'anthropic':
if (route.vendorId === 'minimax') {
const env =
buildMiniMaxProfileEnv({
model: getPrimaryModel(activeProfile.model),
baseUrl: activeProfile.baseUrl,
apiKey: activeProfile.apiKey,
processEnv: process.env,
}) ?? null
return env
? { profile: 'minimax', env: applySupportedProfileCustomHeaders(activeProfile, env) }
: null
}
return {
profile: 'anthropic',
env: applySupportedProfileCustomHeaders(activeProfile, {