From 17d76df5762e2d7cf1a2af2d884e6fbaf88831bb Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 16 Aug 2026 15:44:27 -0700 Subject: [PATCH] fix(xai): stop restoring unidentifiable persisted proxy keys Retargeted xAI launch treated any persisted OPENAI_API_KEY as proxy-owned when XAI_API_KEY was already gone. Keep live shell keys, stamp --provider xai route identity, and read durable OAuth credentials for runtime-limit cache lookups. --- src/integrations/runtimeMetadata.test.ts | 6 ++-- src/integrations/runtimeMetadata.ts | 8 +++-- src/services/api/client.test.ts | 26 +++++++++++++++++ src/services/api/client.ts | 11 ++++++- src/utils/providerFlag.test.ts | 11 +++++++ src/utils/providerFlag.ts | 4 +++ src/utils/providerProfile.test.ts | 37 ++++++++++++++++++++++++ src/utils/providerProfile.ts | 9 +++++- 8 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 380f176d6..b39193742 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -126,7 +126,7 @@ describe('resolveModelRuntimeLimits', () => { it('uses the stable xAI OAuth cache identity for discovered runtime limits', async () => { await withTempConfigDir(async () => { const xaiCredentials = await import('../utils/xaiCredentials.js') - const readSpy = spyOn(xaiCredentials, 'getCachedXaiCredentials').mockReturnValue({ + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockReturnValue({ accessToken: 'rotating-access-token', refreshToken: 'stable-account-identity', tokenEndpoint: 'https://auth.x.ai/oauth/token', @@ -297,13 +297,13 @@ describe('resolveModelRuntimeLimits', () => { const originalXaiKey = process.env.XAI_API_KEY const originalOpenAIKey = process.env.OPENAI_API_KEY const xaiCredentials = await import('../utils/xaiCredentials.js') - const readSpy = spyOn(xaiCredentials, 'getCachedXaiCredentials').mockImplementation( + const readSpy = spyOn(xaiCredentials, 'readXaiCredentials').mockImplementation( () => ({ accessToken: 'oauth-token', refreshToken: 'refresh-token', tokenEndpoint: 'https://auth.x.ai/oauth/token', - }) as ReturnType, + }) as ReturnType, ) try { delete process.env.XAI_API_KEY diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 206d58984..db2527b06 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -28,8 +28,8 @@ import { import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' import { - getCachedXaiCredentials, getXaiDiscoveryCacheIdentity, + readXaiCredentials, } from '../utils/xaiCredentials.js' import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' @@ -467,13 +467,15 @@ function findCachedCatalogEntryForApiName( }), ) // Mirror picker/discovery OAuth injection so hybrid cache partitions match - // for xAI sessions that have a stored token but no env API key. + // for xAI sessions that have a stored token but no env API key. Use the + // synchronous storage reader (not the 30s memory-only getter) so a cold + // request-planning path still finds the OAuth-partitioned discovery entry. if ( !apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl) ) { - const credentials = getCachedXaiCredentials() + const credentials = readXaiCredentials() apiKey = firstUsableCredential(credentials?.accessToken) const cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey const cacheKey = getDiscoveryCacheKey(routeId, { diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 9e3bb1f10..1a3de81ec 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -53,6 +53,7 @@ const originalEnv = { GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, @@ -109,6 +110,7 @@ function clearEnvForMiniMaxOnlyTest(): void { delete process.env.GEMINI_AUTH_MODE delete process.env.GOOGLE_API_KEY delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.OPENAI_MODEL @@ -152,6 +154,7 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_USE_MISTRAL delete process.env.GOOGLE_API_KEY delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_FORMAT @@ -199,6 +202,7 @@ afterEach(() => { restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE) restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT) @@ -253,6 +257,7 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_MISTRAL delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.OPENAI_MODEL @@ -1018,6 +1023,27 @@ test('env-only xAI fallback does not copy XAI_API_KEY onto an insecure xAI URL', expect(process.env.XAI_API_KEY).toBe('xai-test-key') }) +test('env-only xAI fallback filters mirrored keys out of OPENAI_API_KEYS on an insecure xAI URL', 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.XAI_API_KEY = 'xai-test-key' + process.env.OPENAI_API_KEY = 'xai-test-key' + process.env.OPENAI_API_KEYS = 'xai-test-key,pooled-other-key' + process.env.OPENAI_BASE_URL = 'http://api.x.ai/v1' + + await getAnthropicClient({ + maxRetries: 0, + model: 'grok-4', + }) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEYS).toBe('pooled-other-key') + expect(process.env.XAI_API_KEY).toBe('xai-test-key') +}) + test('env-only xAI fallback copies XAI_API_KEY on the canonical HTTPS URL', async () => { delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.GEMINI_API_KEY diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 76237d42b..d38b40f0b 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -57,7 +57,7 @@ import { shouldUseFirstPartyAnthropicAuthForProvider, type ProviderOverride, } from './authRouting.js' -import { hasUsableOpenAICredential } from './credentialPool.js' +import { hasUsableOpenAICredential, parseCredentialList } from './credentialPool.js' import { AnthropicVertex } from './vertexClient.js' import { importOptionalRuntimeModule } from '../../utils/optionalRuntimeModule.js' @@ -261,6 +261,15 @@ function applyXaiEnvOnlyDefaults(): void { process.env.OPENAI_API_KEY = process.env.XAI_API_KEY } else { delete process.env.OPENAI_API_KEY + const xaiSecrets = new Set(parseCredentialList(process.env.XAI_API_KEY)) + const pooled = parseCredentialList(process.env.OPENAI_API_KEYS).filter( + value => !xaiSecrets.has(value), + ) + if (pooled.length > 0) { + process.env.OPENAI_API_KEYS = pooled.join(',') + } else { + delete process.env.OPENAI_API_KEYS + } } delete process.env.OPENAI_API_FORMAT delete process.env.OPENAI_AZURE_STYLE diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 7a587ff2b..826785801 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -26,6 +26,7 @@ const ENV_KEYS = [ 'OPENAI_API_BASE', 'OPENAI_API_KEY', 'OPENAI_API_KEYS', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_MODEL', 'OPENAI_API_FORMAT', 'OPENAI_AZURE_STYLE', @@ -78,6 +79,7 @@ const RESET_KEYS = [ 'OPENAI_API_BASE', 'OPENAI_API_KEY', 'OPENAI_API_KEYS', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_MODEL', 'OPENAI_API_FORMAT', 'OPENAI_AZURE_STYLE', @@ -1154,10 +1156,19 @@ describe('applyProviderFlag - xai', () => { applyProviderFlag('xai', []) expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1') + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') expect(process.env.OPENAI_API_KEY).toBeUndefined() expect(process.env.XAI_API_KEY).toBe('xai-secret-key') }) + test('stamps xAI route identity on a retargeted proxy URL', () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + + applyProviderFlag('xai', []) + + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + }) + test('strips a mirrored XAI_API_KEY from OPENAI_API_KEY on a retargeted proxy URL', () => { process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' process.env.XAI_API_KEY = 'xai-secret-key' diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 140b8cc07..728a40f15 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -573,6 +573,10 @@ export function applyProviderFlag( process.env.OPENAI_BASE_URL ??= 'https://api.x.ai/v1' process.env.OPENAI_MODEL ??= defaultModel ?? 'grok-4.6' if (model) process.env.OPENAI_MODEL = model + // Same stamp as profile apply: a proxy URL cannot host-match api.x.ai, + // so retained route identity is what keeps catalog/runtime on xAI + // without authorizing dedicated credentials for that host. + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' // Keep a user proxy URL (`??=` above), but never turn the dedicated // xAI secret into a generic credential for that host. A mirrored copy // that already equals XAI_API_KEY is stripped; a distinct OPENAI_API_KEY diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 45a0b8da4..1639cc5bc 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -1698,6 +1698,24 @@ test('openai launch drops legacy persisted xAI keys from a retargeted proxy prof assert.equal(env.XAI_API_KEY, undefined) }) +test('openai launch drops a legacy persisted OPENAI_API_KEY from a retargeted xAI proxy without XAI_API_KEY', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'xai', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + OPENAI_API_KEY: 'legacy-xai-key', + }), + goal: 'coding', + processEnv: {}, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.XAI_API_KEY, undefined) +}) + test('legacy xai launch withholds ambient custom headers from a retargeted proxy URL', async () => { const env = await buildLaunchEnv({ profile: 'xai', @@ -1739,6 +1757,25 @@ test('legacy xai launch withholds dedicated credentials from a retargeted proxy assert.equal(env.OPENAI_API_KEY, undefined) }) +test('xai launch drops a legacy persisted OPENAI_API_KEY from a retargeted proxy without XAI_API_KEY', async () => { + const env = await buildLaunchEnv({ + profile: 'xai', + persisted: profile('xai', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + XAI_CREDENTIAL_SOURCE: 'oauth', + OPENAI_API_KEY: 'legacy-xai-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1') + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.XAI_API_KEY, undefined) +}) + test('startup env withholds ambient xAI keys from a persisted xai OAuth proxy profile', async () => { const env = await buildStartupEnvFromProfile({ persisted: profile('xai', { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 030b0c7af..f1b37e01a 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -1533,8 +1533,15 @@ export function assignDistinctXaiProxyGenericCredential( env[selection.envVar] = filtered.join(',') return true } + // Live shell keys can be a distinct proxy credential even when XAI_API_KEY + // is already gone. Persisted OPENAI_API_KEY is not: older xAI proxy files + // stored the dedicated secret there, and with an empty xaiSecrets set every + // persisted value looks "distinct". Only restore persisted generics when we + // can subtract known xAI secrets. if (!restoreGeneric(resolveOpenAICredentialEnvSelection(processEnv))) { - restoreGeneric(resolveOpenAICredentialEnvSelection(persistedEnv)) + if (xaiSecrets.size > 0) { + restoreGeneric(resolveOpenAICredentialEnvSelection(persistedEnv)) + } } }