fix(provider): match MiniMax and Venice base URLs by hostname, not substring (#2099)

* fix(provider): match MiniMax and Venice base URLs by hostname, not substring

The credential-mirroring chains route xAI, Cloudflare and Xiaomi through their
exact-hostname predicates (isXaiBaseUrl, isCloudflareBaseUrl,
isXiaomiMimoBaseUrl), but the MiniMax and Venice branches were left on raw
baseUrl.toLowerCase().includes('minimax') / includes('api.venice.ai') even
though isMiniMaxBaseUrl / isVeniceBaseUrl already exist. A proxy or lookalike
host (minimax-proxy.example.com, api.venice.ai.evil.com) therefore mirrors the
profile key into MINIMAX_API_KEY / VENICE_API_KEY; for MiniMax, providerAutoDetect
then reroutes it to the real api.minimax.io the user never configured. Route all
four sites through the hostname predicates, consistent with the neighbouring
providers.

* fix(provider): centralize dedicated-key mirroring across the profile lifecycle

The host-detected dedicated credential mapping (xAI, MiniMax, Venice,
MiMo) was reconstructed independently in three places and had drifted:

- The startup env builder's strict-env branch returned before any of
  these mirrors, so a keyed generic OpenAI-compatible profile on a
  canonical Venice/MiniMax host persisted only OPENAI_API_KEY and
  relaunched without its dedicated credential; the fallback branch had
  no MiniMax mirror at all and matched MiMo by substring.
- isProcessEnvAlignedWithProfile never checked MINIMAX_API_KEY, so once
  that key drifted after activation the env was still considered aligned
  and applyActiveProviderProfileFromConfig skipped reapplying — leaving
  MiniMax's dedicated-credential transport misrouted.

Introduce getHostDedicatedCredentialEnv as the single source of truth
for the exact-host (and, where available, route) credential mapping, and
route live application, both startup-persistence branches, and the
alignment check through it. Lookalike hosts still receive no dedicated
key.

* fix(provider): carry MiniMax dedicated key through launch reconstruction

buildLaunchEnv rebuilt the startup environment from a fixed dedicated-key
carry-over list that omitted MINIMAX_API_KEY, so a clean restart of a keyed
MiniMax profile retained the generic OpenAI variables but dropped the
dedicated credential. The MiniMax client only enters its native setup while
MINIMAX_API_KEY is present, so the persisted profile did not deliver the
intended restart behavior.

Add MINIMAX_API_KEY to the carry-over contract alongside the other
native-client vendor keys (MIMO/VENICE), preserving the same
shell-over-persisted precedence. The client's own hostname/route detection
gates activation, so an ambient key stays inert in an unrelated session.

Regressions reconstruct the launch environment from a saved profile with an
empty shell and assert both the surviving credential and the selected
env-only route (via resolveEnvOnlyProviderRouteId), not just the persisted
file: canonical MiniMax carries and reselects, a live shell key overrides
the persisted one, and a generic profile never synthesizes the credential.

* fix(provider): preserve keyless credential reconciliation

* fix(provider): scope MiniMax launch credentials to the active route

* fix(provider): admit saved Venice and MiMo keys only on their routes

* test(provider): document saved dedicated-key precedence

* fix(provider): restore test globals and scope profile docs

* fix(provider): keep explicit MiniMax route credentials across reconcile

Reconcile reapply rebuilt a generic OpenAI profile from the hostname only, so a launch that had already kept CLAUDE_CODE_PROVIDER_ROUTE_ID=minimax on a proxy lost MINIMAX_API_KEY when another env field drifted. Reapply now reuses that same-profile route instead of URL substring matching.

* fix(provider): preserve saved credentials in pane teammates

---------

Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
0xfandom
2026-10-25 10:50:02 -07:00
committed by GitHub
co-authored by jatmn
parent da7e6631cc
commit cdd6af609b
7 changed files with 739 additions and 40 deletions
+12
View File
@@ -837,6 +837,18 @@ bun run dev:atomic-chat
If no profile exists yet, `dev:profile` uses the same goal-aware defaults when picking the initial model.
Profiles activated through `/provider` for canonical MiniMax, Venice, and MiMo
endpoints retain a provider-specific key, including profiles configured as generic
OpenAI-compatible providers. On the same route, that saved key takes precedence over an ambient
`OPENAI_API_KEY` or `OPENAI_API_KEYS` pool. To override it for a launch, set
`MINIMAX_API_KEY`, `VENICE_API_KEY`, or `MIMO_API_KEY` in your shell; the live
provider-specific value takes precedence over the saved value. This applies to
both MiMo API and token-plan routes.
Launch reconstruction retains these dedicated keys only for their matching
routes. A shell override to another provider's URL does not carry the saved
source provider's dedicated key into the new launch.
### Provider Profile Model Picker Mode
When a saved provider profile is active, `/model` can either show the provider's
+77
View File
@@ -8,6 +8,12 @@ import {
} from '../../integrations/index.js'
import { publicBuildVersion } from '../../utils/version.js'
import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js'
import { snapshotEnv } from '../../test/envSnapshot.js'
import {
buildLaunchEnv,
buildStartupEnvFromProfile,
createProfileFile,
} from '../../utils/providerProfile.js'
// bun:test keeps mock.module() registrations process-global across test files.
// Load and re-register the real module before importing the client so a prior
@@ -833,6 +839,77 @@ for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) {
}
}
for (const source of ['persisted', 'ambient'] as const) {
test(`profile launch uses the Venice credential despite a ${source} MiniMax alias`, async () => {
clearEnvForMiniMaxOnlyTest()
const env = await buildLaunchEnv({
profile: 'openai',
persisted: createProfileFile('openai', {
OPENAI_BASE_URL: 'https://api.minimax.io/v1',
OPENAI_MODEL: 'MiniMax-M3',
OPENAI_API_KEY: 'saved-minimax-key',
MINIMAX_API_KEY: 'saved-minimax-key',
}),
goal: 'coding',
processEnv: {
OPENAI_BASE_URL: 'https://api.venice.ai/api/v1',
OPENAI_MODEL: 'venice-uncensored',
VENICE_API_KEY: 'selected-venice-key',
...(source === 'ambient' ? {
OPENAI_API_KEY: 'ambient-minimax-key',
MINIMAX_API_KEY: 'ambient-minimax-key',
} : {}),
},
})
const restore = snapshotEnv(Object.keys(env))
try {
Object.assign(process.env, env)
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
globalThis.fetch = (async (input, init) => {
capturedUrl = String(input)
capturedHeaders = new Headers(init?.headers)
return new Response(JSON.stringify({
id: 'chat-venice', object: 'chat.completion', model: 'venice-uncensored',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = await getAnthropicClient({ maxRetries: 0, model: 'venice-uncensored' })
await client.beta.messages.create({
model: 'venice-uncensored', max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
expect(capturedUrl).toBe('https://api.venice.ai/api/v1/chat/completions')
expect(capturedHeaders?.get('authorization')).toBe('Bearer selected-venice-key')
expect(env.MINIMAX_API_KEY).toBeUndefined()
} finally {
restore()
}
})
}
test('saved OpenAI startup keeps its provider label despite an ambient MiniMax key', async () => {
clearEnvForMiniMaxOnlyTest()
const env = await buildStartupEnvFromProfile({
persisted: createProfileFile('openai', {
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_API_KEY: 'saved-openai-key',
}),
processEnv: { MINIMAX_API_KEY: 'ambient-minimax-key' },
})
const restore = snapshotEnv(Object.keys(env))
try {
Object.assign(process.env, env)
const { detectProvider } = await import('../../components/StartupScreen.js')
expect(detectProvider().name).toBe('OpenAI')
expect(env.MINIMAX_API_KEY).toBeUndefined()
} finally {
restore()
}
})
test('selected Gemini ignores dormant native MiniMax transport settings', async () => {
clearEnvForMiniMaxOnlyTest()
process.env.CLAUDE_CODE_USE_GEMINI = '1'
+92
View File
@@ -7,6 +7,7 @@ import test, { afterEach, beforeEach } from 'node:test'
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
import {
resolveActiveRouteIdFromEnv,
resolveEnvOnlyProviderRouteId,
resolveRouteCredentialValue,
} from '../integrations/routeMetadata.js'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
@@ -297,6 +298,97 @@ test('openai launch preserves a persisted GMI Cloud key and prefers a live one',
assert.equal(rotated.GMI_API_KEY, 'gmi-rotated-key')
})
for (const { baseUrl, routeId } of [
{ baseUrl: 'https://api.minimax.io/v1' },
{ baseUrl: 'https://api.minimaxi.com/v1' },
{ baseUrl: 'https://minimax-proxy.example.com/v1', routeId: 'minimax' },
]) {
test(`openai launch preserves a persisted MiniMax credential and route at ${baseUrl}`, async () => {
// Keep the dedicated key for canonical endpoints and an explicitly saved
// route identity without changing the OpenAI-compatible endpoint/protocol.
const env = await buildLaunchEnv({
profile: 'openai',
persisted: profile('openai', {
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: 'MiniMax-M2.5',
...(routeId ? { CLAUDE_CODE_PROVIDER_ROUTE_ID: routeId } : {}),
OPENAI_API_KEY: 'minimax-secret-key',
MINIMAX_API_KEY: 'minimax-secret-key',
}),
goal: 'coding',
processEnv: {},
})
assert.equal(env.OPENAI_BASE_URL, baseUrl)
assert.equal(env.MINIMAX_API_KEY, 'minimax-secret-key')
// Provider identity and wire protocol are separate contracts.
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'minimax')
})
}
for (const [routeId, key] of [
['venice', 'VENICE_API_KEY'],
['xiaomi-mimo', 'MIMO_API_KEY'],
['xiaomi-mimo-token', 'MIMO_API_KEY'],
] as const) {
test(`openai launch retains ${routeId} credentials for an explicitly saved route`, async () => {
const baseUrl = 'https://configured-proxy.example/v1'
const env = await buildLaunchEnv({
profile: 'openai',
persisted: profile('openai', {
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: 'configured-model',
CLAUDE_CODE_PROVIDER_ROUTE_ID: routeId,
OPENAI_API_KEY: 'saved-key',
[key]: 'saved-key',
}),
goal: 'coding',
processEnv: {},
})
assert.equal(env.OPENAI_BASE_URL, baseUrl)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, routeId)
assert.equal(env[key], 'saved-key')
})
}
test('openai launch prefers a live MiniMax key over the persisted one', async () => {
const env = await buildLaunchEnv({
profile: 'openai',
persisted: profile('openai', {
OPENAI_BASE_URL: 'https://api.minimax.io/v1',
OPENAI_MODEL: 'MiniMax-M2.5',
OPENAI_API_KEY: 'persisted-key',
MINIMAX_API_KEY: 'persisted-key',
}),
goal: 'coding',
processEnv: { MINIMAX_API_KEY: 'live-shell-key' },
})
assert.equal(env.MINIMAX_API_KEY, 'live-shell-key')
assert.equal(resolveEnvOnlyProviderRouteId(env), 'minimax')
})
test('openai launch does not synthesize a MiniMax credential for a generic profile', async () => {
// A generic OpenAI-compatible profile on an unrelated host never persisted a
// MINIMAX_API_KEY (the host guard on the persistence side withholds it), so a
// clean relaunch must not conjure one or route to MiniMax.
const env = await buildLaunchEnv({
profile: 'openai',
persisted: profile('openai', {
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_API_KEY: 'generic-key',
}),
goal: 'coding',
processEnv: {},
})
assert.equal(env.MINIMAX_API_KEY, undefined)
assert.notEqual(resolveEnvOnlyProviderRouteId(env), 'minimax')
})
test('openai launch preserves persisted ApiSmart dedicated credentials across restart', async () => {
const env = await buildLaunchEnv({
profile: 'openai',
+16
View File
@@ -2315,6 +2315,7 @@ export async function buildLaunchEnv(options: {
'FIREWORKS_API_KEY',
'LONGCAT_API_KEY',
'AIMLAPI_API_KEY',
'MINIMAX_API_KEY',
'MIMO_API_KEY',
'NVIDIA_API_KEY',
'VENICE_API_KEY',
@@ -2326,6 +2327,21 @@ export async function buildLaunchEnv(options: {
if (dedicatedKey === 'AIMLAPI_API_KEY' && effectiveOpenAIRouteId !== 'aimlapi') {
continue
}
// These aliases also affect copied-key precedence and provider display.
// Admit them only for the selected route, including after a URL override.
if (dedicatedKey === 'MINIMAX_API_KEY' && effectiveOpenAIRouteId !== 'minimax') {
continue
}
if (dedicatedKey === 'VENICE_API_KEY' && effectiveOpenAIRouteId !== 'venice') {
continue
}
if (
dedicatedKey === 'MIMO_API_KEY' &&
effectiveOpenAIRouteId !== 'xiaomi-mimo' &&
effectiveOpenAIRouteId !== 'xiaomi-mimo-token'
) {
continue
}
if (dedicatedKey === 'APISMART_API_KEY' && effectiveOpenAIRouteId !== 'apismart') {
continue
}
+373
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
import { resolveRouteCredentialValue } from '../integrations/routeMetadata.js'
import { snapshotEnv } from '../test/envSnapshot.js'
import type { ProviderProfile } from './config.js'
const nonce = `${Date.now()}-${Math.random()}`
@@ -2417,6 +2418,44 @@ describe('applyProviderProfileToProcessEnv', () => {
expect(getFreshAPIProvider()).not.toBe('xai')
})
test('does not mirror MINIMAX_API_KEY for a lookalike host containing "minimax"', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
// A bare includes('minimax') matched any URL carrying that word — a proxy
// host or a lookalike like `api.minimax.io.evil.com` — and mirrored the key
// into MINIMAX_API_KEY, which providerAutoDetect then redirects to the real
// api.minimax.io. Match by hostname (isMiniMaxBaseUrl), not substring.
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'openai',
baseUrl: 'https://minimax-proxy.example.com/v1',
model: 'some-model',
apiKey: 'not-a-minimax-key',
}),
)
expect(process.env.MINIMAX_API_KEY).toBeUndefined()
expect(process.env.OPENAI_API_KEY).toBe('not-a-minimax-key')
})
test('does not mirror VENICE_API_KEY for a lookalike host containing "api.venice.ai"', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'openai',
baseUrl: 'https://api.venice.ai.evil.com/v1',
model: 'some-model',
apiKey: 'not-a-venice-key',
}),
)
expect(process.env.VENICE_API_KEY).toBeUndefined()
expect(process.env.OPENAI_API_KEY).toBe('not-a-venice-key')
})
test('openai-compatible profile applies maxContextLength to every configured model', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
@@ -2912,6 +2951,147 @@ describe('applyActiveProviderProfileFromConfig', () => {
expect(process.env.OPENAI_MODEL!).toBe('kimi-k2.6')
})
test('reconciliation restores a cleared MINIMAX_API_KEY (dedicated cred is part of the alignment contract)', async () => {
// P2b: MiniMax's transport needs the dedicated key. After activation, if
// MINIMAX_API_KEY drifts (cleared/replaced), the env is no longer aligned
// with the profile, so reconciliation must reapply and restore it. The
// alignment check previously ignored MINIMAX_API_KEY, so it considered the
// env aligned and left the wrong/absent credential in place.
const { applyProviderProfileToProcessEnv, applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
const profile = buildProfile({
id: 'minimax_generic',
name: 'MiniMax generic',
provider: 'openai',
baseUrl: 'https://api.minimax.io/v1',
model: 'MiniMax-M2',
apiKey: 'minimax-real-key',
})
applyProviderProfileToProcessEnv(profile)
expect(process.env.MINIMAX_API_KEY).toBe('minimax-real-key')
// Credential drift after activation.
delete process.env.MINIMAX_API_KEY
const applied = applyActiveProviderProfileFromConfig({
providerProfiles: [profile],
activeProviderProfileId: 'minimax_generic',
} as any)
expect(applied?.id).toBe('minimax_generic')
// Cast past control-flow narrowing: TS pins this to `undefined` after the
// delete above, since the reconciliation call is opaque to it.
expect(process.env.MINIMAX_API_KEY as string | undefined).toBe(
'minimax-real-key',
)
})
for (const { baseUrl, key } of [
{ baseUrl: 'https://api.x.ai/v1', key: 'XAI_API_KEY' },
{ baseUrl: 'https://api.venice.ai/api/v1', key: 'VENICE_API_KEY' },
{ baseUrl: 'https://api.xiaomimimo.com/v1', key: 'MIMO_API_KEY' },
]) {
test(`reconciliation clears unexpected ${key} for a keyless profile`, async () => {
const { applyProviderProfileToProcessEnv, applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
const profile = buildProfile({ provider: 'openai', baseUrl, apiKey: undefined })
const restoreEnv = snapshotEnv([key])
try {
applyProviderProfileToProcessEnv(profile)
expect(process.env[key]).toBeUndefined()
process.env[key] = 'unexpected-key'
applyActiveProviderProfileFromConfig({
...realConfig.getGlobalConfig(),
providerProfiles: [profile],
activeProviderProfileId: profile.id,
})
expect(process.env[key]).toBeUndefined()
} finally {
restoreEnv()
}
})
}
test('reconciliation keeps an explicit MiniMax route credential on a proxy host', async () => {
const { applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
const { buildLaunchEnv, createProfileFile } = await import(
'./providerProfile.js'
)
const profile = buildProfile({
id: 'minimax_proxy',
name: 'MiniMax proxy',
provider: 'openai',
baseUrl: 'https://minimax-proxy.example.com/v1',
model: 'MiniMax-M2.5',
apiKey: 'correct-key',
})
const launch = await buildLaunchEnv({
profile: 'openai',
goal: 'coding',
processEnv: {},
persisted: createProfileFile('openai', {
OPENAI_BASE_URL: profile.baseUrl,
OPENAI_MODEL: profile.model,
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'minimax',
OPENAI_API_KEY: 'correct-key',
MINIMAX_API_KEY: 'correct-key',
}),
})
const restoreEnv = snapshotEnv([
...Object.keys(process.env),
...Object.keys(launch),
])
try {
Object.assign(process.env, launch)
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = profile.id
process.env.OPENAI_API_KEY = 'drift-openai'
process.env.MINIMAX_API_KEY = 'wrong-minimax'
applyActiveProviderProfileFromConfig({
providerProfiles: [profile],
activeProviderProfileId: profile.id,
} as any)
expect(process.env.OPENAI_API_KEY).toBe('correct-key')
expect(process.env.MINIMAX_API_KEY).toBe('correct-key')
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('minimax')
expect(
resolveRouteCredentialValue({
routeId: 'minimax',
baseUrl: process.env.OPENAI_BASE_URL,
}),
).toBe('correct-key')
} finally {
restoreEnv()
}
})
test('reconciliation never mirrors a MiniMax key for a lookalike host', async () => {
const { applyProviderProfileToProcessEnv, applyActiveProviderProfileFromConfig } =
await importFreshProviderProfileModules()
const profile = buildProfile({
id: 'minimax_lookalike',
name: 'MiniMax lookalike',
provider: 'openai',
baseUrl: 'https://api.minimax.io.attacker.example/v1',
model: 'gpt-4o',
apiKey: 'attacker-key',
})
applyProviderProfileToProcessEnv(profile)
applyActiveProviderProfileFromConfig({
providerProfiles: [profile],
activeProviderProfileId: 'minimax_lookalike',
} as any)
expect(process.env.MINIMAX_API_KEY).toBeUndefined()
})
test('still respects complete shell selection with USE flag + BASE_URL', async () => {
// Counter-example: when the user really did set both the flag AND a
// concrete BASE_URL, that IS explicit intent and wins over the saved
@@ -3888,6 +4068,84 @@ describe('getProviderPresetDefaults', () => {
})
describe('setActiveProviderProfile', () => {
// P2a: a keyed generic OpenAI-compatible profile on a canonical dedicated
// host must persist its dedicated credential in the startup file, so a
// restart re-authenticates. The strict-env branch previously returned before
// mirroring these, so Venice persisted only OPENAI_API_KEY and MiniMax had no
// mirror at all.
for (const vendor of [
{ name: 'Venice', route: 'venice', baseUrl: 'https://api.venice.ai/api/v1', key: 'VENICE_API_KEY' },
{ name: 'MiniMax', route: 'minimax', baseUrl: 'https://api.minimax.io/v1', key: 'MINIMAX_API_KEY' },
]) {
test(`persists ${vendor.key} for a generic profile on the canonical ${vendor.name} host`, async () => {
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
process.env.CLAUDE_CONFIG_DIR = configDir
try {
const { setActiveProviderProfile } =
await importFreshProviderProfileModules()
const profile = buildProfile({
id: 'vendor_url_prof',
name: `${vendor.name} via URL`,
provider: 'openai',
baseUrl: vendor.baseUrl,
apiKey: 'vendor-url-key',
})
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [profile],
}))
const result = setActiveProviderProfile('vendor_url_prof', { configDir })
const persisted = JSON.parse(
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
)
expect(result?.id).toBe('vendor_url_prof')
expect(persisted.env[vendor.key]).toBe('vendor-url-key')
const { buildStartupEnvFromProfile } = await import('./providerProfile.js')
const restarted = await buildStartupEnvFromProfile({ persisted, processEnv: {} })
expect(restarted[vendor.key]).toBe('vendor-url-key')
expect(restarted.OPENAI_API_KEY).toBe('vendor-url-key')
expect(restarted.OPENAI_BASE_URL).toBe(vendor.baseUrl)
expect(restarted.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe(vendor.route)
expect(restarted.CLAUDE_CODE_USE_OPENAI).toBe('1')
} finally {
rmSync(configDir, { recursive: true, force: true })
}
})
}
test('does not persist a dedicated key for a canonical-lookalike host', async () => {
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
process.env.CLAUDE_CONFIG_DIR = configDir
try {
const { setActiveProviderProfile } =
await importFreshProviderProfileModules()
const profile = buildProfile({
id: 'lookalike_prof',
name: 'MiniMax lookalike',
provider: 'openai',
baseUrl: 'https://api.minimax.io.attacker.example/v1',
apiKey: 'attacker-key',
})
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [profile],
}))
const result = setActiveProviderProfile('lookalike_prof', { configDir })
const persisted = JSON.parse(
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
)
expect(result?.id).toBe('lookalike_prof')
expect(persisted.env.MINIMAX_API_KEY).toBeUndefined()
expect(persisted.env.VENICE_API_KEY).toBeUndefined()
} finally {
rmSync(configDir, { recursive: true, force: true })
}
})
test('sets OPENAI_MODEL env var when switching to an openai-type provider', async () => {
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
process.env.CLAUDE_CONFIG_DIR = configDir
@@ -4347,6 +4605,121 @@ describe('setActiveProviderProfile', () => {
}
})
for (const source of [
{ id: 'minimax', baseUrl: 'https://api.minimax.io/v1', model: 'MiniMax-M2.7', key: 'MINIMAX_API_KEY' },
{ id: 'venice', baseUrl: 'https://api.venice.ai/api/v1', model: 'venice-uncensored', key: 'VENICE_API_KEY' },
{ id: 'xiaomi-mimo', baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', key: 'MIMO_API_KEY' },
{ id: 'xiaomi-mimo-token', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', key: 'MIMO_API_KEY' },
]) {
test(`saved generic ${source.id} launch and pane teammate authenticate with the selected provider key`, async () => {
const {
setActiveProviderProfile,
applyActiveProviderProfileFromConfig,
clearProviderProfileEnvFromProcessEnv,
} = await importFreshProviderProfileModules()
const { buildStartupEnvFromProfile, clearManagedProfileEnv } =
await import('./providerProfile.js')
const { buildInheritedEnvVars } = await import('./swarm/spawnUtils.js')
const { tryParseShellCommand } = await import('./bash/shellQuote.js')
const profile = buildProfile({
baseUrl: source.baseUrl, model: source.model, apiKey: 'saved-source-key',
})
saveMockGlobalConfig(current => ({ ...current, providerProfiles: [profile] }))
globalThis.fetch = (async (_input, _init) => new Response(JSON.stringify({ data: [] }))) as typeof fetch
setActiveProviderProfile(profile.id, { configDir: testConfigDir! })
const persisted = JSON.parse(
readFileSync(join(testConfigDir!, '.openclaude-profile.json'), 'utf8'),
)
const target = source.id === 'venice'
? { baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', key: 'MIMO_API_KEY' }
: { baseUrl: 'https://api.venice.ai/api/v1', model: 'venice-uncensored', key: 'VENICE_API_KEY' }
// Saved dedicated keys outrank ambient generic keys, including pools.
// Exercise restarts, dedicated key rotation and endpoint overrides through
// the same saved profile and actual client (including MiniMax's /v1 path).
for (const launch of [
{ target: source, shell: {}, expectedKey: 'saved-source-key' },
{ target: source, shell: { OPENAI_API_KEY: 'shell-generic-key' }, expectedKey: 'saved-source-key' },
{ target: source, shell: { OPENAI_API_KEYS: 'shell-pool-key-1,shell-pool-key-2' }, expectedKey: 'saved-source-key' },
{ target: source, shell: { [source.key]: 'rotated-source-key' }, expectedKey: 'rotated-source-key' },
{ target, shell: { OPENAI_BASE_URL: target.baseUrl, OPENAI_MODEL: target.model, [target.key]: 'selected-target-key' }, expectedKey: 'selected-target-key' },
]) {
const env = await buildStartupEnvFromProfile({ persisted, processEnv: launch.shell })
const restore = snapshotEnv([
...Object.keys(process.env), ...Object.keys(env),
'CLAUDECODE', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
])
const hadOwnMacro = Object.hasOwn(globalThis, 'MACRO')
const originalMacro = (globalThis as Record<string, unknown>).MACRO
try {
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
clearManagedProfileEnv(process.env)
Object.assign(process.env, env)
let capturedUrl: string | undefined
let capturedHeaders: Headers | undefined
let capturedRequests = 0
globalThis.fetch = (async (input, init) => {
capturedRequests++
capturedUrl = String(input)
capturedHeaders = new Headers(init?.headers)
return new Response(JSON.stringify({
id: 'chat-profile', object: 'chat.completion', model: launch.target.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as typeof fetch
const { getAnthropicClient } = await import('../services/api/client.js')
const assertRequest = async () => {
const previousRequests = capturedRequests
const client = await getAnthropicClient({ maxRetries: 0, model: launch.target.model })
await client.beta.messages.create({
model: launch.target.model, max_tokens: 16,
messages: [{ role: 'user', content: 'hello' }],
})
expect(capturedRequests).toBe(previousRequests + 1)
expect(capturedUrl).toBe(`${launch.target.baseUrl}/chat/completions`)
expect(capturedHeaders?.get(launch.target.key === 'MIMO_API_KEY' ? 'api-key' : 'authorization'))
.toBe(launch.target.key === 'MIMO_API_KEY' ? launch.expectedKey : `Bearer ${launch.expectedKey}`)
}
await assertRequest()
// PaneBackendExecutor sends only these explicit assignments to a
// tmux/login shell; it cannot rely on the parent's other env vars.
const parsed = tryParseShellCommand(buildInheritedEnvVars())
if (!parsed.success) throw new Error(parsed.error)
const teammateEnv = Object.fromEntries(parsed.tokens.map(token => {
if (typeof token !== 'string' || !token.includes('=')) {
throw new Error('Expected a teammate environment assignment')
}
const separator = token.indexOf('=')
return [token.slice(0, separator), token.slice(separator + 1)]
}))
// Follow the child's plural-profile then startup-file stages. Its
// complete incoming selection must preserve the parent's credential.
expect(applyActiveProviderProfileFromConfig({
...realConfig.getGlobalConfig(),
providerProfiles: [profile],
activeProviderProfileId: profile.id,
}, { processEnv: teammateEnv })).toBeUndefined()
const childEnv = { ...await buildStartupEnvFromProfile({
persisted, processEnv: teammateEnv,
}) }
clearProviderProfileEnvFromProcessEnv()
Object.assign(process.env, childEnv)
await assertRequest()
} finally {
if (hadOwnMacro) {
;(globalThis as Record<string, unknown>).MACRO = originalMacro
} else {
delete (globalThis as Record<string, unknown>).MACRO
}
restore()
}
}
})
}
test('persists Venice profiles using a legacy-compatible openai startup profile', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
+164 -40
View File
@@ -65,7 +65,9 @@ import {
isFireworksBaseUrl,
isGmiCloudBaseUrl,
isLongcatBaseUrl,
isMiniMaxBaseUrl,
isNearaiBaseUrl,
isVeniceBaseUrl,
isXaiBaseUrl,
isXiaomiMimoBaseUrl,
resolveEnvOnlyProviderRouteId,
@@ -792,6 +794,125 @@ function serializeProfileContextWindows(
)
}
/**
* Dedicated provider credential env vars a profile's API key must be mirrored
* into, for the family of vendors detected by exact base-URL host (and, where
* the caller has it, the resolved route id / vendor id). Single source of truth
* so live application, startup-env persistence, and env/profile alignment agree
* on which host owns which dedicated key — previously each path reconstructed
* the mapping independently and drifted (MiniMax was absent from the startup
* builder and the alignment check; MiMo used a substring match). Credentials
* detected by route id or profile shape (aimlapi, apismart, atlas, cloudflare,
* bankr, nvidia, …) keep their own predicates at each site.
*/
const HOST_DEDICATED_ROUTE_IDS = new Set([
'minimax',
'venice',
'xai',
'xiaomi-mimo',
'xiaomi-mimo-token',
])
/**
* Route identity already established for this exact profile in the managed
* process env (launch reconstruction). Honor it on reconcile reapply when the
* profile URL does not resolve to a different provider. Do not infer it from
* a URL substring, and do not copy it onto a different profile.
*/
function managedExplicitHostRouteId(
profile: ProviderProfile,
processEnv: NodeJS.ProcessEnv,
): string | undefined {
if (trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) !== profile.id) {
return undefined
}
const routeId = trimOrUndefined(processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID)
if (!routeId || !HOST_DEDICATED_ROUTE_IDS.has(routeId)) {
return undefined
}
const resolvedFromUrl = resolveRouteIdFromBaseUrl(profile.baseUrl)
if (
resolvedFromUrl &&
resolvedFromUrl !== 'openai' &&
resolvedFromUrl !== routeId
) {
return undefined
}
return routeId
}
function hostDedicatedRouteForProfile(
profile: ProviderProfile,
processEnv: NodeJS.ProcessEnv,
profileRoute: { routeId?: string; vendorId?: string },
): { routeId?: string; vendorId?: string } {
const managedRouteId = managedExplicitHostRouteId(profile, processEnv)
if (
!managedRouteId ||
(profileRoute.routeId && HOST_DEDICATED_ROUTE_IDS.has(profileRoute.routeId))
) {
return profileRoute
}
return { routeId: managedRouteId, vendorId: profileRoute.vendorId }
}
function getHostDedicatedCredentialEnv(
baseUrl: string | undefined,
apiKey: string,
route?: { routeId?: string; vendorId?: string },
): Record<string, string> {
const env: Record<string, string> = {}
if (route?.routeId === 'xai' || isXaiBaseUrl(baseUrl)) {
env.XAI_API_KEY = apiKey
}
if (
route?.routeId === 'minimax' ||
route?.vendorId === 'minimax' ||
isMiniMaxBaseUrl(baseUrl)
) {
env.MINIMAX_API_KEY = apiKey
}
if (route?.routeId === 'venice' || isVeniceBaseUrl(baseUrl)) {
env.VENICE_API_KEY = apiKey
}
if (
route?.routeId === 'xiaomi-mimo' ||
route?.routeId === 'xiaomi-mimo-token' ||
isXiaomiMimoBaseUrl(baseUrl)
) {
env.MIMO_API_KEY = apiKey
}
return env
}
/**
* Whether the process env carries the profile-owned value for every dedicated
* host credential the profile expects. Uses getHostDedicatedCredentialEnv so
* the alignment contract can never fall behind what application/persistence
* mirror, including absent credentials on keyless profiles.
*/
function hostDedicatedCredentialsAligned(
processEnv: NodeJS.ProcessEnv,
profile: ProviderProfile,
includeApiKey: boolean,
): boolean {
if (!includeApiKey) {
return true
}
const dedicated = getHostDedicatedCredentialEnv(
profile.baseUrl,
profile.apiKey ?? '',
hostDedicatedRouteForProfile(
profile,
processEnv,
resolveProfileRoute(profile.provider),
),
)
return Object.entries(dedicated).every(([key, value]) =>
sameOptionalEnvValue(processEnv[key], value),
)
}
function isProcessEnvAlignedWithProfile(
processEnv: NodeJS.ProcessEnv,
profile: ProviderProfile,
@@ -945,23 +1066,11 @@ function isProcessEnvAlignedWithProfile(
? !includeApiKey ||
sameOptionalEnvValue(processEnv.BNKR_API_KEY, profile.apiKey)
: true) &&
(isXaiBaseUrl(profile.baseUrl)
? !includeApiKey ||
sameOptionalEnvValue(processEnv.XAI_API_KEY, profile.apiKey)
: true) &&
hostDedicatedCredentialsAligned(processEnv, profile, includeApiKey) &&
(isAimlapiRoute
? !includeApiKey ||
sameOptionalEnvValue(processEnv.AIMLAPI_API_KEY, profile.apiKey)
: true) &&
(profile.baseUrl?.toLowerCase().includes('api.venice.ai')
? !includeApiKey ||
sameOptionalEnvValue(processEnv.VENICE_API_KEY, profile.apiKey)
: true) &&
(profile.baseUrl?.toLowerCase().includes('api.xiaomimimo.com') ||
profile.baseUrl?.toLowerCase().includes('api.mimo-v2.com')
? !includeApiKey ||
sameOptionalEnvValue(processEnv.MIMO_API_KEY, profile.apiKey)
: true) &&
(profile.baseUrl?.toLowerCase().includes('atlascloud')
? !includeApiKey ||
sameOptionalEnvValue(processEnv.ATLAS_CLOUD_API_KEY, profile.apiKey)
@@ -1206,8 +1315,30 @@ export function applyProviderProfileToProcessEnv(
!withholdRetargetedGmiCloudCredential
) {
openAIProfileEnv.OPENAI_API_KEY = profile.apiKey
if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) {
openAIProfileEnv.MINIMAX_API_KEY = profile.apiKey
// Host/route-detected dedicated keys (xai, minimax, venice, mimo) come
// from the shared mapping so this path, startup persistence, and env
// alignment stay in lockstep. A generic OpenAI profile keeps an explicit
// host route only when this same profile's managed env already has it
// (launch reconstruction); reconcile must not drop that credential.
const dedicatedRoute = hostDedicatedRouteForProfile(
profile,
process.env,
route,
)
Object.assign(
openAIProfileEnv,
getHostDedicatedCredentialEnv(
profile.baseUrl,
profile.apiKey,
dedicatedRoute,
),
)
if (
dedicatedRoute.routeId &&
HOST_DEDICATED_ROUTE_IDS.has(dedicatedRoute.routeId) &&
route.routeId !== dedicatedRoute.routeId
) {
openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = dedicatedRoute.routeId
}
if (
route.gatewayId === 'nvidia-nim' ||
@@ -1219,22 +1350,9 @@ export function applyProviderProfileToProcessEnv(
if (route.routeId === 'bankr' || normalizedProfileBaseUrl.toLowerCase().includes('bankr')) {
openAIProfileEnv.BNKR_API_KEY = profile.apiKey
}
if (route.routeId === 'xai' || isXaiBaseUrl(profile.baseUrl)) {
openAIProfileEnv.XAI_API_KEY = profile.apiKey
}
if (isAimlapiProfile) {
openAIProfileEnv.AIMLAPI_API_KEY = profile.apiKey
}
if (route.routeId === 'venice' || normalizedProfileBaseUrl.toLowerCase().includes('api.venice.ai')) {
openAIProfileEnv.VENICE_API_KEY = profile.apiKey
}
if (
route.routeId === 'xiaomi-mimo' ||
route.routeId === 'xiaomi-mimo-token' ||
isXiaomiMimoBaseUrl(profile.baseUrl)
) {
openAIProfileEnv.MIMO_API_KEY = profile.apiKey
}
if (route.routeId === 'atlas-cloud' || normalizedProfileBaseUrl.toLowerCase().includes('atlascloud')) {
openAIProfileEnv.ATLAS_CLOUD_API_KEY = profile.apiKey
}
@@ -1739,6 +1857,17 @@ function buildOpenAICompatibleStartupEnv(
activeProfile.maxContextLength,
)
}
// The strict env omitted the host-detected dedicated keys, so a keyed
// generic profile on a canonical MiniMax/Venice/xAI/MiMo host persisted
// only OPENAI_API_KEY and relaunched without its dedicated credential.
// Mirror them from the shared mapping, same as the fallback path below.
Object.assign(
strictEnv,
getHostDedicatedCredentialEnv(
activeProfile.baseUrl,
activeProfile.apiKey,
),
)
if (isAimlapiProfile) {
strictEnv.AIMLAPI_API_KEY = activeProfile.apiKey
strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
@@ -1858,24 +1987,19 @@ function buildOpenAICompatibleStartupEnv(
!withholdRetargetedCommandcodeCredential
) {
env.OPENAI_API_KEY = activeProfile.apiKey
// Host-detected dedicated keys (xai, minimax, venice, mimo) from the shared
// mapping — this adds the previously-absent MiniMax mirror and replaces the
// MiMo substring match with the exact-host predicate.
Object.assign(
env,
getHostDedicatedCredentialEnv(activeProfile.baseUrl, activeProfile.apiKey),
)
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
env.BNKR_API_KEY = activeProfile.apiKey
}
if (isXaiBaseUrl(activeProfile.baseUrl)) {
env.XAI_API_KEY = activeProfile.apiKey
}
if (isAimlapiProfile) {
env.AIMLAPI_API_KEY = activeProfile.apiKey
}
if (activeProfile.baseUrl?.toLowerCase().includes('api.venice.ai')) {
env.VENICE_API_KEY = activeProfile.apiKey
}
if (
activeProfile.baseUrl?.toLowerCase().includes('api.xiaomimimo.com') ||
activeProfile.baseUrl?.toLowerCase().includes('api.mimo-v2.com')
) {
env.MIMO_API_KEY = activeProfile.apiKey
}
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
env.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
}
+5
View File
@@ -109,6 +109,11 @@ const TEAMMATE_ENV_VARS = [
'GH_TOKEN',
'OPENAI_API_KEYS',
'OPENAI_API_KEY',
// Saved-profile launches can select these ahead of ambient generic keys.
// Preserve that selection in the pane's explicit environment.
'MINIMAX_API_KEY',
'VENICE_API_KEY',
'MIMO_API_KEY',
'LLMTR_API_KEY',
'MERGE_GATEWAY_API_KEY',
'CMD_API_KEY',