mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
+2
-3
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
Vendored
+12
-16
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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
@@ -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() }),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 // 64–128k → 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 // 16–32k → 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -71,6 +71,7 @@ function shouldUseIntegrationRuntimeLimits(
|
||||
|
||||
return (
|
||||
transportKind === 'openai-compatible' ||
|
||||
transportKind === 'anthropic-proxy' ||
|
||||
transportKind === 'local' ||
|
||||
transportKind === 'gemini-native'
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user