diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index a431a971d..f96765a4a 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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 diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 1f43a2756..ba1a54844 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -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' diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 623e53573..1d55e570c 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -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', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index bf2c5cdae..b6144f948 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -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 } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 8a855bd3e..85439aaf1 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -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).MACRO + try { + ;(globalThis as Record).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).MACRO = originalMacro + } else { + delete (globalThis as Record).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-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 147d18dd7..be5a1cf75 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -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 { + const env: Record = {} + 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 } diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index a46fa8be3..0b3076539 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -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',