diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 2d8c579c77..c443954c1c 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -372,7 +372,6 @@ async function getOpenAIDiscoveryRequestOptions( options?: { refreshXaiOAuth?: boolean }, ): Promise<{ apiKey?: string - cacheKey?: string baseUrl?: string headers?: Record }> { @@ -381,14 +380,15 @@ async function getOpenAIDiscoveryRequestOptions( baseUrl: process.env.OPENAI_BASE_URL, }) + let apiKey = firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl: request.baseUrl, + processEnv: process.env, + }), + ) return resolveDiscoveryRequestOptions(routeId ?? 'custom', { - apiKey: firstUsableCredential( - resolveRouteCredentialValue({ - routeId, - baseUrl: request.baseUrl, - processEnv: process.env, - }), - ), + apiKey, baseUrl: request.baseUrl, headers: parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS), }, options) diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index ed3d545d4f..9a2bfe24c9 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -998,6 +998,160 @@ describe('discoverModelsForRoute', () => { { id: 'llama-3.3-70b', apiName: 'llama-3.3-70b', label: 'llama-3.3-70b', contextWindow: 131072 }, ]) }) + + test('does not send stored xAI OAuth credentials to an overridden discovery URL', async () => { + const originalXaiKey = process.env.XAI_API_KEY + const xaiCredentials = await import('../utils/xaiCredentials.js') + const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'oauth-token', + ) + try { + delete process.env.XAI_API_KEY + let authorization: string | null | undefined + setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => { + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response(JSON.stringify({ data: [] }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + }) as unknown as typeof globalThis.fetch) + + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + await discoverModelsForRoute('xai', { + baseUrl: 'https://proxy.example/v1', + forceRefresh: true, + }) + + expect(tokenSpy).not.toHaveBeenCalled() + expect(authorization).not.toBe('Bearer oauth-token') + } finally { + tokenSpy.mockRestore() + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + } + }) + + test('uses the canonical xAI URL for an empty discovery override', async () => { + const originalXaiKey = process.env.XAI_API_KEY + try { + process.env.XAI_API_KEY = 'xai-api-key' + let url: string | undefined + let authorization: string | null | undefined + setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => { + url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response(JSON.stringify({ data: [] }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + }) as unknown as typeof globalThis.fetch) + + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + await discoverModelsForRoute('xai', { baseUrl: '', forceRefresh: true }) + + expect(url).toBe('https://api.x.ai/v1/models') + expect(authorization).toBe('Bearer xai-api-key') + } finally { + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + } + }) + + test('does not refresh xAI OAuth credentials when nonessential traffic is disabled', async () => { + const xaiCredentials = await import('../utils/xaiCredentials.js') + const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'oauth-token', + ) + try { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + + const result = await discoverModelsForRoute('xai', { forceRefresh: true }) + + expect(tokenSpy).not.toHaveBeenCalled() + expect(result?.source).toBe('static') + } finally { + tokenSpy.mockRestore() + } + }) + + test('uses the stored OAuth token to read a fresh xAI discovery cache before refreshing', async () => { + const xaiCredentials = await import('../utils/xaiCredentials.js') + const readSpy = spyOn(xaiCredentials, 'readXaiCredentialsAsync').mockResolvedValue( + { + accessToken: 'cached-oauth-token', + refreshToken: 'refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + }, + ) + const refreshSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'refreshed-oauth-token', + ) + try { + const { discoverModelsForRoute, getDiscoveryCacheKey } = + await loadDiscoveryServiceModule() + await setCachedModels( + getDiscoveryCacheKey('xai', { + baseUrl: 'https://api.x.ai/v1', + apiKey: 'cached-oauth-token', + cacheKey: 'refresh-token', + }), + { models: [{ id: 'cached-grok', apiName: 'cached-grok', label: 'cached-grok' }] }, + ) + + const result = await discoverModelsForRoute('xai') + + expect(result?.source).toBe('cache') + expect(refreshSpy).not.toHaveBeenCalled() + } finally { + readSpy.mockRestore() + refreshSpy.mockRestore() + } + }) + + test('does not send xAI credentials to an insecure xAI URL', async () => { + const originalXaiKey = process.env.XAI_API_KEY + const xaiCredentials = await import('../utils/xaiCredentials.js') + const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'oauth-token', + ) + try { + process.env.XAI_API_KEY = 'xai-api-key' + let authorization: string | null | undefined + setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => { + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response(JSON.stringify({ data: [] }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + }) as unknown as typeof globalThis.fetch) + + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + await discoverModelsForRoute('xai', { + baseUrl: 'http://api.x.ai/v1', + forceRefresh: true, + }) + + expect(tokenSpy).not.toHaveBeenCalled() + expect(authorization).toBeNull() + } finally { + tokenSpy.mockRestore() + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + } + }) }) describe('probeRouteReadiness', () => { diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 4487936a1b..1cb4a98993 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -32,13 +32,13 @@ import { } from '../utils/providerDiscovery.js' import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' -import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' -import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' import { getXaiDiscoveryCacheIdentity, readXaiCredentialsAsync, resolveXaiAccessToken, } from '../utils/xaiCredentials.js' +import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' +import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' export type RouteDiscoveryResult = { routeId: string @@ -124,10 +124,10 @@ const FNV1A_128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn const FNV1A_128_PRIME = 0x0000000001000000000000000000013bn function fingerprintDiscoveryCachePartition(value: unknown): string { - // Discovery results can be account-specific. This only needs a stable, - // opaque local cache namespace; it is not password storage, authentication, - // integrity protection, or a security boundary. Keep raw credentials out of - // the cache key without retaining them in a process-wide memoization cache. + // Cache partitions need a stable opaque namespace, not password hashing or + // an authentication boundary. This runs while preparing every request, so + // keep it constant-time with respect to credential-stretching work and do + // not retain the serialized value in a process-wide memoization cache. let fingerprint = FNV1A_128_OFFSET_BASIS const serialized = JSON.stringify(value) ?? '' @@ -167,7 +167,11 @@ function getRouteBaseUrl( routeId: string, options?: { baseUrl?: string }, ): string | undefined { - return options?.baseUrl ?? getRouteDescriptor(routeId)?.defaultBaseUrl + const configuredBaseUrl = options?.baseUrl?.trim() + if (configuredBaseUrl) { + return configuredBaseUrl + } + return getRouteDescriptor(routeId)?.defaultBaseUrl } function getRouteDiscoveryApiKey( @@ -189,6 +193,10 @@ function getRouteDiscoveryApiKey( return undefined } + if (routeId === 'xai' && !isCanonicalXaiInferenceBaseUrl(baseUrl)) { + return undefined + } + if (hasInvalidCredentialPlaceholder(options?.apiKey)) { return undefined } @@ -208,12 +216,7 @@ function getRouteDiscoveryApiKey( } export async function resolveDiscoveryRequestOptions< - T extends { - apiKey?: string - cacheKey?: string - baseUrl?: string - headers?: Record - }, + T extends { apiKey?: string; cacheKey?: string; baseUrl?: string; headers?: Record }, >( routeId: string, options?: T, @@ -223,11 +226,17 @@ export async function resolveDiscoveryRequestOptions< if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai') { return next } - - if (!isCanonicalXaiInferenceBaseUrl(getRouteBaseUrl(routeId, next))) { + const baseUrl = getRouteBaseUrl(routeId, next) + // Route identity selects a catalog, but it must never authorize sending a + // stored OAuth credential to an overridden endpoint. The xAI OAuth token is + // valid only for xAI's API host. + if (!isCanonicalXaiInferenceBaseUrl(baseUrl)) { return next } - + // Cache lookup uses the currently stored credential identity. + // Refresh only when a network discovery request is actually necessary: a + // refresh rotates the token and would otherwise turn a fresh cache entry + // into an avoidable miss. let credentials = await readXaiCredentialsAsync() const cacheOnly = shouldSkipNonessentialDiscoveryTraffic() || @@ -236,14 +245,12 @@ export async function resolveDiscoveryRequestOptions< cacheOnly ? credentials?.accessToken : await resolveXaiAccessToken(), ) if (!cacheOnly) { - // A refresh can rotate the refresh token. Re-read the persisted blob so - // discovery writes under the same stable identity subsequent readers use. credentials = (await readXaiCredentialsAsync()) ?? credentials } if (token) { next.apiKey = token - // The access token can rotate while the OAuth account does not. Keep the - // cache partition tied to a stable account identity, not a bearer token. + // Access tokens rotate, while refresh tokens and account IDs identify the + // same OAuth account. Keep cache partitions stable across token refreshes. next.cacheKey = getXaiDiscoveryCacheIdentity(credentials) ?? token } return next @@ -418,9 +425,6 @@ export async function discoverModelsForRoute( } const ttlMs = getDiscoveryCacheTtlMs(routeId) - // Cache-only reads must not refresh an OAuth token: discovery can be - // disabled by privacy policy, and a refresh can rotate the bearer before a - // fresh cached result is checked. const cachedOptions = await resolveDiscoveryRequestOptions(routeId, options, { refreshXaiOAuth: false, }) diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 8ab72799d0..53a914d9a6 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -91,6 +91,16 @@ test('isLongcatBaseUrl requires the documented HTTPS OpenAI API path', () => { expect(isLongcatBaseUrl('https://api.longcat.chat.evil.test/openai/v1')).toBe(false) }) +test('resolveActiveRouteIdFromEnv retains xAI identity for a retargeted proxy URL', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://proxy.example/v1', + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'xai', + }), + ).toBe('xai') +}) + test('resolveActiveRouteIdFromEnv keeps generic OpenAI credentials ahead of env-only LongCat', () => { expect(resolveActiveRouteIdFromEnv({ OPENAI_API_KEY: 'generic-key', diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index 99260dbf6e..37a661dc79 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -276,11 +276,20 @@ export function isXaiBaseUrl(value: string | undefined): boolean { } } +/** + * xAI-specific credentials may only be sent to the documented HTTPS API + * origin. Route identity is intentionally more permissive because profiles + * can be retargeted, but a retargeted route must not carry a xAI credential. + */ export function isCanonicalXaiInferenceBaseUrl(value: string | undefined): boolean { if (!value?.trim()) return true try { - const parsed = new URL(value) - return parsed.protocol === 'https:' && parsed.hostname.toLowerCase() === 'api.x.ai' + const url = new URL(value) + return ( + url.protocol === 'https:' && + url.hostname.toLowerCase() === 'api.x.ai' && + (url.port === '' || url.port === '443') + ) } catch { return false } @@ -1023,6 +1032,14 @@ export function resolveRouteCredentialValue( return undefined } + if ( + routeId === 'xai' && + options?.baseUrl !== undefined && + !isCanonicalXaiInferenceBaseUrl(options.baseUrl) + ) { + return undefined + } + return getRouteCredentialValue(routeId, processEnv) } @@ -1242,6 +1259,17 @@ export function resolveActiveRouteIdFromEnv( return matchedRoute } + const retainedRouteId = processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim() + // Only xAI needs this env stamp to survive a proxy URL. ApiSmart's + // dedicated key never rides OPENAI_API_KEY after persist/relaunch, and + // it has no OAuth injector keyed off route id. xAI still must keep + // identity so OAuth stays off the proxy and XAI_API_KEY values can be + // filtered from generic pools. Host matching already returned above + // when the URL is canonical. + if (retainedRouteId === 'xai') { + return 'xai' + } + if (options?.activeProfileProvider) { const route = resolveProfileRoute(options.activeProfileProvider) if ( diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 83695888c9..b391937429 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', @@ -291,6 +291,67 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(2_000_000) }) }) + + it('uses stored xAI OAuth credentials when reading discovered runtime limits', async () => { + await withTempConfigDir(async () => { + 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, 'readXaiCredentials').mockImplementation( + () => + ({ + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + tokenEndpoint: 'https://auth.x.ai/oauth/token', + }) as ReturnType, + ) + try { + delete process.env.XAI_API_KEY + delete process.env.OPENAI_API_KEY + const baseUrl = 'https://api.x.ai/v1' + await setCachedModels( + getDiscoveryCacheKey('xai', { + baseUrl, + apiKey: 'oauth-token', + cacheKey: 'refresh-token', + }), + { + models: [ + { + id: 'grok-4.7', + apiName: 'grok-4.7', + label: 'grok-4.7', + contextWindow: 500_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'grok-4.7', + baseUrl, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }).contextWindow, + ).toBe(500_000) + } finally { + readSpy.mockRestore() + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + if (originalOpenAIKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIKey + } + } + }) + }) }) describe('AIMLAPI runtime attribution', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index aaa109ba01..db2527b069 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -27,11 +27,11 @@ import { } from './routeMetadata.js' import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' import { firstUsableCredential } from '../services/api/credentialPool.js' -import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { - getCachedXaiCredentials, getXaiDiscoveryCacheIdentity, + readXaiCredentials, } from '../utils/xaiCredentials.js' +import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js' import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js' function resolveRouteOpenAIShimConfig( @@ -460,21 +460,41 @@ function findCachedCatalogEntryForApiName( const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE let apiKey = firstUsableCredential( - resolveRouteCredentialValue({ routeId, baseUrl, processEnv: runtimeEnv }), + resolveRouteCredentialValue({ + routeId, + baseUrl, + processEnv: runtimeEnv, + }), ) - let cacheIdentity: string | undefined - if (!apiKey && routeId === 'xai' && isCanonicalXaiInferenceBaseUrl(baseUrl)) { - // Runtime limit resolution is synchronous request planning. Discovery - // populates this memory cache asynchronously; do not launch a credential - // store subprocess here merely to recover optional dynamic metadata. - const credentials = getCachedXaiCredentials() + // Mirror picker/discovery OAuth injection so hybrid cache partitions match + // 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 = readXaiCredentials() apiKey = firstUsableCredential(credentials?.accessToken) - cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey + const cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey + const cacheKey = getDiscoveryCacheKey(routeId, { + baseUrl, + apiKey, + cacheKey: cacheIdentity, + headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), + }) + const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) + + return ( + cached?.models.find(entry => + matchesCatalogEntryModel(routeId, entry, normalizedModel), + ) ?? null + ) } const cacheKey = getDiscoveryCacheKey(routeId, { baseUrl, apiKey, - cacheKey: cacheIdentity, headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS), }) const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId)) diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index fd055f4153..2e13bb6e53 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, spyOn, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import xai from './xai.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' const mapModel = xai.catalog?.discovery?.mapModel @@ -55,8 +63,6 @@ describe('xAI vendor hybrid catalog', () => { 'grok-voice-think-fast-2.0', 'grok-stt-1.0', 'grok-tts-1.0', - // This model requires xAI's Responses API and does not support the - // generic OpenAI-compatible chat transport used by live discovery. 'grok-4.20-multi-agent-0309', ] for (const id of drop) { @@ -107,4 +113,67 @@ describe('xAI vendor hybrid catalog', () => { contextWindow: 500000, }) }) + + test('hybrid discovery authenticates with xAI OAuth when env keys are absent', async () => { + await acquireSharedMutationLock('xai.test.ts-oauth-discovery') + const originalFetch = globalThis.fetch + const originalXaiKey = process.env.XAI_API_KEY + const originalOpenAIKey = process.env.OPENAI_API_KEY + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-xai-discovery-')) + const xaiCredentials = await import('../../utils/xaiCredentials.js') + const tokenSpy = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockImplementation( + async () => 'oauth-token', + ) + const readSpy = spyOn(xaiCredentials, 'readXaiCredentialsAsync').mockResolvedValue( + undefined, + ) + setClaudeConfigHomeDirForTesting(tempDir) + try { + delete process.env.XAI_API_KEY + delete process.env.OPENAI_API_KEY + const captured: { authorization: string | null } = { authorization: null } + globalThis.fetch = mock((_input, init) => { + captured.authorization = new Headers( + init?.headers as HeadersInit | undefined, + ).get('Authorization') + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: 'grok-4.7', context_window: 500000 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + }) as unknown as typeof globalThis.fetch + + const { discoverModelsForRoute } = await import( + `../discoveryService.js?ts=${Date.now()}-${Math.random()}` + ) + const result = await discoverModelsForRoute('xai', { forceRefresh: true }) + expect(captured.authorization).toBe('Bearer oauth-token') + expect(result?.source).toBe('network') + expect(result?.models.map(model => model.apiName)).toContain('grok-4.6') + expect(result?.models.map(model => model.apiName)).toContain('grok-4.7') + } finally { + readSpy.mockRestore() + tokenSpy.mockRestore() + globalThis.fetch = originalFetch + setClaudeConfigHomeDirForTesting(undefined) + if (originalXaiKey === undefined) { + delete process.env.XAI_API_KEY + } else { + process.env.XAI_API_KEY = originalXaiKey + } + if (originalOpenAIKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIKey + } + rmSync(tempDir, { recursive: true, force: true }) + releaseSharedMutationLock() + } + }) }) diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index b3b921451c..1a3de81ecd 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 @@ -998,6 +1003,65 @@ test('env-only xAI fallback preserves xAI OPENAI_API_BASE host overrides', async expect(process.env.OPENAI_BASE_URL).toBe('https://api.x.ai/v1') }) +test('env-only xAI fallback does not copy XAI_API_KEY onto 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 = 'stale-openai-key' + process.env.OPENAI_BASE_URL = 'http://api.x.ai/v1' + + await getAnthropicClient({ + maxRetries: 0, + model: 'grok-4', + }) + + expect(process.env.OPENAI_BASE_URL).toBe('http://api.x.ai/v1') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + 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 + 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' + delete process.env.OPENAI_API_KEY + process.env.OPENAI_BASE_URL = 'https://api.x.ai/v1' + + await getAnthropicClient({ + maxRetries: 0, + model: 'grok-4', + }) + + expect({ ...process.env }.OPENAI_API_KEY).toBe('xai-test-key') +}) + test('env-only xAI fallback drops unsupported OpenAI shim options', async () => { let capturedUrl: string | undefined let capturedHeaders: Headers | undefined diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 3b6b92f039..d38b40f0bf 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -44,6 +44,7 @@ import { getMiniMaxBaseUrlOverride, getNearaiBaseUrlOverride, isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, getRouteDefaultBaseUrl, getRouteDefaultModel, getXaiBaseUrlOverride, @@ -56,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' @@ -250,7 +251,26 @@ function applyXaiEnvOnlyDefaults(): void { ? modelOverride : undefined) ?? getRouteDefaultModel('xai') - process.env.OPENAI_API_KEY = process.env.XAI_API_KEY + // Dedicated xAI credentials belong only to the documented HTTPS API origin. + // An api.x.ai host override can still be HTTP or a non-443 port; do not + // copy XAI_API_KEY onto that URL as OPENAI_API_KEY. + if ( + process.env.XAI_API_KEY?.trim() && + isCanonicalXaiInferenceBaseUrl(process.env.OPENAI_BASE_URL) + ) { + 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 delete process.env.OPENAI_AUTH_HEADER diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6f561e3393..8c6b7c4fab 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -53,7 +53,7 @@ import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js' import { getRouteDescriptor, isLongcatBaseUrl, - isXaiBaseUrl, + isCanonicalXaiInferenceBaseUrl, resolveRouteCredentialValue, } from '../../integrations/routeMetadata.js' import { getSessionId } from '../../bootstrap/state.js' @@ -523,7 +523,7 @@ class OpenAIShimMessages { getRouteDescriptor(runtimeShimContext.routeId)?.setup .dedicatedCredentialsOnly !== true, getCredentialPool: value => this.getCredentialPool(value), - filterAnthropicHeaders, isGeminiMode, resolveRouteCredentialValue, isXaiBaseUrl, isLongcatBaseUrl, parseCredentialList, resolveXaiAccessToken, hasInvalidCredentialPlaceholder, buildOpenAICompatibilityErrorMessage, isAzureStyleBaseUrl, resolveGeminiCredential, COPILOT_HEADERS, getSessionId, getLocalProviderRetryBaseUrls, buildOllamaChatUrl, logForDebugging, redactUrlForDiagnostics, redactSecretValueForDisplay, headersWithRequestUrl, classifyOpenAINetworkFailure, classifyOpenAIHttpFailure, markOpenAIRequestNonReplayable, fetchRequest: (url, init) => fetchWithHeadersDeadline(url, init, { callerSignal: options?.signal, timeoutMs: apiTimeoutMs }), isResponseHeadersTimeout: error => error instanceof ResponseHeadersTimeoutError, requestBodyContainsImages, formatRetryAfterHint, redactUrlsInMessage, sleepMs, shouldAttemptLocalToollessRetry, refreshCopilotTokenOn401, isCopilotTokenExpiredError, convertOllamaStreamingResponse, convertOllamaNonStreamingResponse, logApiCallStart, logApiCallEnd, stableStringifyJson, APIError, GITHUB_429_MAX_RETRIES, GITHUB_429_BASE_DELAY_SEC, GITHUB_429_MAX_DELAY_SEC, request, params, options, requestProcessEnv, fastPath, shimConfig, runtimeShimContext, body, effectiveTransport, useNativeOllamaChat, buildResponsesBody, serializeBody, isLocal, isGithub, isGithubCopilot, isGithubModels, omitTools, + filterAnthropicHeaders, isGeminiMode, resolveRouteCredentialValue, isCanonicalXaiInferenceBaseUrl, isLongcatBaseUrl, parseCredentialList, resolveXaiAccessToken, hasInvalidCredentialPlaceholder, buildOpenAICompatibilityErrorMessage, isAzureStyleBaseUrl, resolveGeminiCredential, COPILOT_HEADERS, getSessionId, getLocalProviderRetryBaseUrls, buildOllamaChatUrl, logForDebugging, redactUrlForDiagnostics, redactSecretValueForDisplay, headersWithRequestUrl, classifyOpenAINetworkFailure, classifyOpenAIHttpFailure, markOpenAIRequestNonReplayable, fetchRequest: (url, init) => fetchWithHeadersDeadline(url, init, { callerSignal: options?.signal, timeoutMs: apiTimeoutMs }), isResponseHeadersTimeout: error => error instanceof ResponseHeadersTimeoutError, requestBodyContainsImages, formatRetryAfterHint, redactUrlsInMessage, sleepMs, shouldAttemptLocalToollessRetry, refreshCopilotTokenOn401, isCopilotTokenExpiredError, convertOllamaStreamingResponse, convertOllamaNonStreamingResponse, logApiCallStart, logApiCallEnd, stableStringifyJson, APIError, GITHUB_429_MAX_RETRIES, GITHUB_429_BASE_DELAY_SEC, GITHUB_429_MAX_DELAY_SEC, request, params, options, requestProcessEnv, fastPath, shimConfig, runtimeShimContext, body, effectiveTransport, useNativeOllamaChat, buildResponsesBody, serializeBody, isLocal, isGithub, isGithubCopilot, isGithubModels, omitTools, }) } diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index 1f9d06801b..08cd7c046f 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -1,5 +1,5 @@ import { APIError } from '@anthropic-ai/sdk' -import { afterEach, beforeEach, expect, jest, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, jest, mock, spyOn, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../../test/sharedMutationLock.js' import { asMockFetch } from '../../../test/typedMocks.js' import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from '../../../integrations/index.ts' @@ -12,6 +12,7 @@ import { import { createOpenAIShimClient, hasMistralApiHost } from '../openaiShim.ts' import { formatRetryAfterHint, sleepMs } from './requestExecutor.js' import * as realGithubModelsCredentials from '../../../utils/githubModelsCredentials.js' +import * as xaiCredentials from '../../../utils/xaiCredentials.js' type FetchType = typeof globalThis.fetch @@ -53,6 +54,8 @@ const originalEnv = { OPENGATEWAY_API_KEY: process.env.OPENGATEWAY_API_KEY, OPENGATEWAY_BASE_URL: process.env.OPENGATEWAY_BASE_URL, OPENCODE_API_KEY: process.env.OPENCODE_API_KEY, + XAI_API_KEY: process.env.XAI_API_KEY, + CLAUDE_CODE_PROVIDER_ROUTE_ID: process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 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, CLAUDE_STREAM_IDLE_TIMEOUT_MS: process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS, @@ -435,6 +438,154 @@ function makeCodexSseResponse(responseData: Record): Response { return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) } +test('uses stored xAI OAuth credentials only for the canonical HTTPS API endpoint', async () => { + const resolveToken = spyOn(xaiCredentials, 'resolveXaiAccessToken').mockResolvedValue( + 'oauth-token', + ) + const cases = [ + { baseUrl: 'https://api.x.ai/v1', sendsOAuth: true }, + { baseUrl: 'http://api.x.ai/v1', sendsOAuth: false }, + { baseUrl: 'https://proxy.example/v1', sendsOAuth: false }, + { baseUrl: '', sendsOAuth: false }, + ] + + try { + for (const { baseUrl, sendsOAuth } of cases) { + if (baseUrl) { + process.env.OPENAI_BASE_URL = baseUrl + } else { + delete process.env.OPENAI_BASE_URL + } + delete process.env.OPENAI_API_KEY + let headers: Headers | undefined + globalThis.fetch = (async (_input, init) => { + headers = new Headers(init?.headers) + return makeChatCompletionResponse('grok-4.6') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'grok-4.6', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(headers?.get('authorization')).toBe( + sendsOAuth ? 'Bearer oauth-token' : null, + ) + expect(headers?.has('x-grok-conv-id')).toBe(sendsOAuth) + } + expect(resolveToken).toHaveBeenCalledTimes(1) + } finally { + resolveToken.mockRestore() + } +}) + +test('does not send an xAI key mirrored into OPENAI_API_KEY to an untrusted endpoint', async () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-api-key' + process.env.OPENAI_API_KEY = 'xai-api-key' + let headers: Headers | undefined + globalThis.fetch = (async (_input, init) => { + headers = new Headers(init?.headers) + return makeChatCompletionResponse('grok-4.6') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'grok-4.6', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(headers?.get('authorization')).toBeNull() +}) + +test('filters a mirrored xAI key from generic credential pools on an untrusted endpoint', async () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-api-key' + process.env.OPENAI_API_KEY = 'xai-api-key,other-key' + process.env.OPENAI_API_KEYS = 'xai-api-key,pooled-key' + const authorizations: string[] = [] + globalThis.fetch = (async (_input, init) => { + authorizations.push(new Headers(init?.headers).get('authorization') ?? '') + return makeChatCompletionResponse('grok-4.6') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'grok-4.6', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(authorizations).toEqual(['Bearer pooled-key']) +}) + +test('filters comma-separated xAI keys mirrored into generic credential lists on an untrusted endpoint', async () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-key-a,xai-key-b' + process.env.OPENAI_API_KEY = 'xai-key-a,xai-key-b' + process.env.OPENAI_API_KEYS = 'xai-key-a,xai-key-b' + + const captured = await captureChatCompletionRequest('grok-4.6') + + expect(captured.authorization).toBeNull() +}) + +test('sends a distinct proxy-owned OPENAI_API_KEY on a retargeted xAI route', async () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + process.env.OPENAI_API_KEY = 'proxy-owned-key' + delete process.env.XAI_API_KEY + let headers: Headers | undefined + globalThis.fetch = (async (_input, init) => { + headers = new Headers(init?.headers) + return makeChatCompletionResponse('grok-4.6') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'grok-4.6', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(headers?.get('authorization')).toBe('Bearer proxy-owned-key') +}) + +test('sends profile-owned custom auth on a retargeted xAI proxy', async () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + process.env.OPENAI_AUTH_HEADER = 'X-Proxy-Auth' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'profile-own-proxy-secret' + delete process.env.OPENAI_API_KEY + delete process.env.XAI_API_KEY + let headers: Headers | undefined + globalThis.fetch = (async (_input, init) => { + headers = new Headers(init?.headers) + return makeChatCompletionResponse('grok-4.6') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'grok-4.6', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(headers?.get('authorization')).toBeNull() + expect(headers?.get('x-proxy-auth')).toBe('profile-own-proxy-secret') +}) + beforeEach(async () => { await acquireSharedMutationLock('openaiShim.test.ts') process.env.OPENAI_BASE_URL = 'http://example.test/v1' @@ -474,6 +625,8 @@ beforeEach(async () => { delete process.env.OPENGATEWAY_API_KEY delete process.env.OPENGATEWAY_BASE_URL delete process.env.OPENCODE_API_KEY + delete process.env.XAI_API_KEY + delete process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID delete process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS @@ -520,6 +673,8 @@ afterEach(() => { restoreEnv('OPENGATEWAY_API_KEY', originalEnv.OPENGATEWAY_API_KEY) restoreEnv('OPENGATEWAY_BASE_URL', originalEnv.OPENGATEWAY_BASE_URL) restoreEnv('OPENCODE_API_KEY', originalEnv.OPENCODE_API_KEY) + restoreEnv('XAI_API_KEY', originalEnv.XAI_API_KEY) + restoreEnv('CLAUDE_CODE_PROVIDER_ROUTE_ID', originalEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID) 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) restoreEnv('CLAUDE_STREAM_IDLE_TIMEOUT_MS', originalEnv.CLAUDE_STREAM_IDLE_TIMEOUT_MS) diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index 08aa05d720..a6fba46bc9 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -57,7 +57,7 @@ type RequestExecutorContext = { baseUrl: string processEnv: NodeJS.ProcessEnv }) => string | undefined - isXaiBaseUrl: (baseUrl: string) => boolean + isCanonicalXaiInferenceBaseUrl: (baseUrl: string) => boolean isLongcatBaseUrl: (baseUrl: string) => boolean parseCredentialList: (value?: string) => string[] resolveXaiAccessToken: () => Promise @@ -185,7 +185,7 @@ export async function executeOpenAIRequest( filterAnthropicHeaders, isGeminiMode, resolveRouteCredentialValue, - isXaiBaseUrl, + isCanonicalXaiInferenceBaseUrl, isLongcatBaseUrl, parseCredentialList, resolveXaiAccessToken, @@ -253,25 +253,46 @@ export async function executeOpenAIRequest( baseUrl: request.baseUrl, processEnv: requestProcessEnv, }) - // xAI OAuth: when the active route is xAI and no API key is set, fall - // back to a stored OAuth access token (auto-refreshed). The token is - // sent as a Bearer to api.x.ai/v1 — same surface as an API key. - const isXaiRoute = - runtimeShimContext.routeId === 'xai' || isXaiBaseUrl(request.baseUrl) + // xAI OAuth and XAI_API_KEY are valid only for xAI's API host. A retained + // xAI route id is not authorization to send those secrets to a proxy. + // A distinct OPENAI_API_KEY on that proxy is a proxy credential, not an + // xAI secret — drop only values that also appear in XAI_API_KEY. + const isXaiRoute = Boolean(request.baseUrl?.trim()) && + isCanonicalXaiInferenceBaseUrl(request.baseUrl) + const xaiCredentials = new Set( + parseCredentialList(requestProcessEnv.XAI_API_KEY), + ) + const excludeXaiCredentials = xaiCredentials.size > 0 && !isXaiRoute + const withoutUntrustedXaiCredential = (values: string[]): string[] => + excludeXaiCredentials + ? values.filter(value => !xaiCredentials.has(value)) + : values + const openAIApiKeyValues = withoutUntrustedXaiCredential( + parseCredentialList(requestProcessEnv.OPENAI_API_KEY?.trim()), + ) + const openAIApiKeysPoolValues = withoutUntrustedXaiCredential( + parseCredentialList(requestProcessEnv.OPENAI_API_KEYS), + ) const openAIApiKeysPoolRaw = - routeAcceptsGenericOpenAICredentials && - parseCredentialList(requestProcessEnv.OPENAI_API_KEYS).length > 0 - ? requestProcessEnv.OPENAI_API_KEYS + routeAcceptsGenericOpenAICredentials && openAIApiKeysPoolValues.length > 0 + ? openAIApiKeysPoolValues.join(',') : undefined - const openAIApiKeyRaw = requestProcessEnv.OPENAI_API_KEY?.trim() - const openAIApiKeyValues = parseCredentialList(openAIApiKeyRaw) const openAIApiKey = openAIApiKeyValues[0] const openAIApiKeyRawUsable = - openAIApiKeyValues.length > 0 ? openAIApiKeyRaw : undefined + openAIApiKeyValues.length > 0 ? openAIApiKeyValues.join(',') : undefined + // The xAI env-only path mirrors XAI_API_KEY into OpenAI-compatible + // credential variables. Remove that value from every generic source when + // an xAI profile is retargeted away from the canonical HTTPS API endpoint. + const requestRouteCredential = + excludeXaiCredentials && + routeCredential !== undefined && + xaiCredentials.has(routeCredential) + ? undefined + : routeCredential const xaiOAuthToken = isXaiRoute && !providerOverride?.apiKey && - !routeCredential && + !requestRouteCredential && !openAIApiKeysPoolRaw && !openAIApiKey ? await resolveXaiAccessToken() @@ -294,22 +315,22 @@ export async function executeOpenAIRequest( ].some((value) => value?.trim() === openAIApiKeyRawUsable), ) const routeCredentialIsCopiedProviderKey = Boolean( - routeCredential && + requestRouteCredential && openAIApiKeyRawUsable && - routeCredential === openAIApiKeyRawUsable && + requestRouteCredential === openAIApiKeyRawUsable && openAIApiKeyIsCopiedProviderKey, ) const routeCredentialIsProviderSpecific = Boolean( - routeCredential && + requestRouteCredential && (!openAIApiKeyRawUsable || - routeCredential !== openAIApiKeyRawUsable || + requestRouteCredential !== openAIApiKeyRawUsable || routeCredentialIsCopiedProviderKey), ) const routeCredentialIsGenericOpenAIFallback = Boolean( !routeCredentialIsProviderSpecific && - routeCredential && + requestRouteCredential && openAIApiKeyRawUsable && - routeCredential === openAIApiKeyRawUsable, + requestRouteCredential === openAIApiKeyRawUsable, ) const apiKeyRaw = providerOverride?.apiKey ?? @@ -317,9 +338,11 @@ export async function executeOpenAIRequest( routeAcceptsGenericOpenAICredentials ? openAIApiKeyRawUsable : undefined) ?? - (routeCredentialIsGenericOpenAIFallback ? undefined : routeCredential) ?? + (routeCredentialIsGenericOpenAIFallback + ? undefined + : requestRouteCredential) ?? openAIApiKeysPoolRaw ?? - routeCredential ?? + requestRouteCredential ?? (routeAcceptsGenericOpenAICredentials ? openAIApiKeyRawUsable || xaiOAuthToken || '' : '') diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index f027f6476d..8267858016 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -25,6 +25,8 @@ const ENV_KEYS = [ 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_KEY', + 'OPENAI_API_KEYS', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_MODEL', 'OPENAI_API_FORMAT', 'OPENAI_AZURE_STYLE', @@ -76,6 +78,8 @@ const RESET_KEYS = [ 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_KEY', + 'OPENAI_API_KEYS', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 'OPENAI_MODEL', 'OPENAI_API_FORMAT', 'OPENAI_AZURE_STYLE', @@ -1143,6 +1147,69 @@ describe('applyProviderFlag - xai', () => { expect(process.env.OPENAI_API_KEY).toBe('existing-openai-key') }) + + test('does not copy XAI_API_KEY onto a retargeted proxy URL', () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-secret-key' + delete process.env.OPENAI_API_KEY + + 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' + process.env.OPENAI_API_KEY = 'xai-secret-key' + + applyProviderFlag('xai', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + test('keeps a distinct OPENAI_API_KEY on a retargeted xAI proxy URL', () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-secret-key' + process.env.OPENAI_API_KEY = 'proxy-owned-key' + + applyProviderFlag('xai', []) + + expect(process.env.OPENAI_API_KEY).toBe('proxy-owned-key') + }) + + test('filters a mirrored xAI key from OPENAI_API_KEYS on a retargeted proxy URL', () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.XAI_API_KEY = 'xai-secret-key' + process.env.OPENAI_API_KEYS = 'xai-secret-key,proxy-pool-key' + + applyProviderFlag('xai', []) + + expect(process.env.OPENAI_API_KEYS).toBe('proxy-pool-key') + }) + + test('does not mirror XAI_API_KEY onto an insecure or non-443 xAI URL', () => { + for (const baseUrl of ['http://api.x.ai/v1', 'https://api.x.ai:8443/v1']) { + process.env.OPENAI_BASE_URL = baseUrl + process.env.XAI_API_KEY = 'xai-secret-key' + delete process.env.OPENAI_API_KEY + + applyProviderFlag('xai', []) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + } + }) }) describe('applyProviderFlag - invalid provider', () => { diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 11264f82f3..728a40f157 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -29,8 +29,11 @@ import { resolveRouteIdFromBaseUrl, } from '../integrations/index.js' import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js' -import { isCanonicalApismartInferenceBaseUrl } from '../integrations/routeMetadata.js' -import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' +import { + isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, +} from '../integrations/routeMetadata.js' +import { hasUsableOpenAICredential, parseCredentialList } from '../services/api/credentialPool.js' import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js' const PREFERRED_PROVIDER_ORDER = [ @@ -570,7 +573,38 @@ 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 - if (process.env.XAI_API_KEY && !process.env.OPENAI_API_KEY) { + // 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 + // is left for the proxy. + if (!isCanonicalXaiInferenceBaseUrl(getConfiguredOpenAIBaseUrl())) { + const xaiSecrets = new Set(parseCredentialList(process.env.XAI_API_KEY)) + const withoutXaiSecrets = (value: string | undefined): string | undefined => { + if (!value || xaiSecrets.size === 0) { + return value + } + const filtered = parseCredentialList(value).filter( + item => !xaiSecrets.has(item), + ) + return filtered.length > 0 ? filtered.join(',') : undefined + } + const nextOpenAIApiKey = withoutXaiSecrets(process.env.OPENAI_API_KEY) + if (nextOpenAIApiKey) { + process.env.OPENAI_API_KEY = nextOpenAIApiKey + } else { + delete process.env.OPENAI_API_KEY + } + const nextOpenAIApiKeys = withoutXaiSecrets(process.env.OPENAI_API_KEYS) + if (nextOpenAIApiKeys) { + process.env.OPENAI_API_KEYS = nextOpenAIApiKeys + } else { + delete process.env.OPENAI_API_KEYS + } + } else if (process.env.XAI_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.XAI_API_KEY } break diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index e9fcc004cf..1639cc5bc7 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -1641,6 +1641,294 @@ test('xai non-OAuth profile (legacy api-key flow) still accepts OPENAI_API_KEY f assert.equal(env.OPENAI_API_KEY, 'xai-disguised-as-openai-key') }) +test('openai launch withholds ambient xAI credentials from a keyless proxy profile on restart', 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', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'ambient-xai-key', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.XAI_API_KEY, undefined) + + const canonical = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'xai', + OPENAI_BASE_URL: 'https://api.x.ai/v1', + OPENAI_MODEL: 'grok-4.6', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://api.x.ai/v1', + OPENAI_API_KEY: 'ambient-xai-key', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + assert.equal(canonical.OPENAI_API_KEY, 'ambient-xai-key') + assert.equal(canonical.XAI_API_KEY, 'ambient-xai-key') +}) + +test('openai launch drops legacy persisted xAI keys from a retargeted proxy profile', 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', + XAI_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('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', + persisted: profile('xai', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.3', + XAI_CREDENTIAL_SOURCE: 'oauth', + }), + goal: 'balanced', + processEnv: { + ANTHROPIC_CUSTOM_HEADERS: 'X-Ambient-Secret: ambient-header-secret', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1') + assert.equal(env.ANTHROPIC_CUSTOM_HEADERS, undefined) +}) + +test('legacy xai launch withholds dedicated credentials from a retargeted proxy URL', async () => { + const env = await buildLaunchEnv({ + profile: 'xai', + persisted: profile('xai', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.3', + XAI_API_KEY: 'xai-persisted-key', + OPENAI_API_KEY: 'xai-persisted-key', + }), + goal: 'balanced', + processEnv: { + XAI_API_KEY: 'ambient-xai-key', + OPENAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1') + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.XAI_API_KEY, undefined) + 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', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + XAI_CREDENTIAL_SOURCE: 'oauth', + }), + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + XAI_API_KEY: 'ambient-xai-key', + OPENAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1') + assert.equal(env.XAI_API_KEY, undefined) + assert.equal(env.OPENAI_API_KEY, undefined) +}) + +test('xai launch keeps a distinct proxy OPENAI_API_KEY on a retargeted OAuth profile', 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', + }), + goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'proxy-owned-key', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.XAI_CREDENTIAL_SOURCE, 'oauth') + assert.equal(env.OPENAI_API_KEY, 'proxy-owned-key') + assert.equal(env.XAI_API_KEY, undefined) +}) + +test('xai launch keeps a distinct proxy OPENAI_API_KEYS pool on a retargeted OAuth profile', 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', + }), + goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEYS: 'xai-secret-key,proxy-pool-key', + XAI_API_KEY: 'xai-secret-key', + }, + }) + + assert.equal(env.OPENAI_API_KEYS, 'proxy-pool-key') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.XAI_API_KEY, undefined) +}) + +test('xai launch keeps a distinct proxy OPENAI_API_KEY on a retargeted api-key profile', async () => { + const env = await buildLaunchEnv({ + profile: 'xai', + persisted: profile('xai', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + XAI_API_KEY: 'xai-persisted-key', + }), + goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'proxy-owned-key', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_API_KEY, 'proxy-owned-key') + assert.equal(env.XAI_API_KEY, undefined) +}) + +test('openai launch keeps a distinct proxy OPENAI_API_KEY on a retargeted xAI profile', 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', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'proxy-owned-key', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_API_KEY, 'proxy-owned-key') + assert.equal(env.XAI_API_KEY, undefined) +}) + +test('openai launch infers retargeted xAI identity from a legacy persisted dedicated key', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'grok-4.6', + OPENAI_API_KEY: 'legacy-xai-key', + XAI_API_KEY: 'legacy-xai-key', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'legacy-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, undefined) + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.XAI_API_KEY, undefined) +}) + +test('openai launch restores profile-owned custom auth on a retargeted xAI proxy', 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_AUTH_HEADER: 'X-Proxy-Auth', + OPENAI_AUTH_SCHEME: 'raw', + OPENAI_AUTH_HEADER_VALUE: 'profile-own-proxy-secret', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_AUTH_HEADER: 'X-Ambient-Auth', + OPENAI_AUTH_HEADER_VALUE: 'ambient-canonical-secret', + XAI_API_KEY: 'ambient-xai-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'xai') + assert.equal(env.OPENAI_AUTH_HEADER, 'X-Proxy-Auth') + assert.equal(env.OPENAI_AUTH_HEADER_VALUE, 'profile-own-proxy-secret') + assert.equal(env.XAI_API_KEY, undefined) +}) + test('codex launch clears openai-compatible format and custom auth env', async () => { const env = await buildLaunchEnv({ profile: 'codex', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 8891e0d6cc..f1b37e01a8 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -26,6 +26,7 @@ import { getRouteDefaultBaseUrl, getRouteDefaultModel, isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, resolveRouteCredentialValue, @@ -1092,7 +1093,12 @@ function buildXaiProfileEnv(options: { defaultModel, } - if (key) { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + // Dedicated xAI credentials are valid only for api.x.ai. A retargeted + // profile keeps route identity so request-time withholding can refuse + // leftover generic keys, but must not attach XAI_API_KEY / a mirrored + // OPENAI_API_KEY to a proxy URL. + if (key && isCanonicalXaiInferenceBaseUrl(env.OPENAI_BASE_URL)) { env.OPENAI_API_KEY = key env.XAI_API_KEY = key } @@ -1484,6 +1490,61 @@ function isSameConfiguredEndpoint( return normalizedLeft !== null && normalizedLeft === normalize(right) } +function collectXaiCredentialValues( + ...sources: Array +): Set { + return new Set(sources.flatMap(value => parseCredentialList(value))) +} + +function withoutXaiCredentialValues( + value: string | undefined, + xaiSecrets: Set, +): string[] { + return parseCredentialList(value).filter(item => !xaiSecrets.has(item)) +} + +export function assignDistinctXaiProxyGenericCredential( + env: ProfileEnv, + processEnv: SecretValueSource, + persistedEnv: SecretValueSource, + additionalXaiSecrets: Array = [], +): void { + delete env.OPENAI_API_KEY + delete env.OPENAI_API_KEYS + // Drop mirrored xAI secrets, but keep a distinct generic key — that is + // the proxy credential `--provider xai` also preserves. Callers that + // already know the withheld dedicated key (profile.apiKey) must pass it + // here: it may not be in XAI_API_KEY after rotation or a keyless env. + const xaiSecrets = collectXaiCredentialValues( + processEnv.XAI_API_KEY, + persistedEnv.XAI_API_KEY, + ...additionalXaiSecrets, + ) + const restoreGeneric = ( + selection: OpenAICredentialEnvSelection | undefined, + ) => { + if (!selection || selection.kind !== 'usable') { + return false + } + const filtered = withoutXaiCredentialValues(selection.value, xaiSecrets) + if (filtered.length === 0) { + return false + } + 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))) { + if (xaiSecrets.size > 0) { + restoreGeneric(resolveOpenAICredentialEnvSelection(persistedEnv)) + } + } +} + export async function buildLaunchEnv(options: { profile: ProviderProfile persisted: ProfileFile | null @@ -1787,13 +1848,22 @@ export async function buildLaunchEnv(options: { // override; otherwise leave the env keyless and let openaiShim // resolve the stored OAuth access token at request time. const isOAuthProfile = persistedEnv.XAI_CREDENTIAL_SOURCE === 'oauth' - const xaiKey = isOAuthProfile - ? sanitizeApiKey(processEnv.XAI_API_KEY) || - sanitizeApiKey(persistedEnv.XAI_API_KEY) - : sanitizeApiKey(processEnv.XAI_API_KEY) || - sanitizeApiKey(persistedEnv.XAI_API_KEY) || - sanitizeApiKey(processEnv.OPENAI_API_KEY) || - sanitizeApiKey(persistedEnv.OPENAI_API_KEY) + const resolvedXaiBaseUrl = shellOpenAIBaseUrl || persistedOpenAIBaseUrl + const isCanonicalXaiLaunch = + !resolvedXaiBaseUrl?.trim() || + isCanonicalXaiInferenceBaseUrl(resolvedXaiBaseUrl) + // Retargeted xAI launch files must not inherit a dedicated or mirrored + // key. buildXaiProfileEnv also withholds on the resolved URL; skip the + // sources here so a proxy profile cannot pick OPENAI_API_KEY as xAI. + const xaiKey = !isCanonicalXaiLaunch + ? undefined + : isOAuthProfile + ? sanitizeApiKey(processEnv.XAI_API_KEY) || + sanitizeApiKey(persistedEnv.XAI_API_KEY) + : sanitizeApiKey(processEnv.XAI_API_KEY) || + sanitizeApiKey(persistedEnv.XAI_API_KEY) || + sanitizeApiKey(processEnv.OPENAI_API_KEY) || + sanitizeApiKey(persistedEnv.OPENAI_API_KEY) const env = buildXaiProfileEnv({ model: shellOpenAIModel || persistedOpenAIModel, @@ -1801,10 +1871,8 @@ export async function buildLaunchEnv(options: { apiKey: xaiKey, // Scrub OPENAI_API_KEY before buildXaiProfileEnv reads processEnv // so it can't be re-introduced via the internal fallback inside - // that helper. The shell key still survives in the wider - // processEnv copy returned by buildCompatibilityProcessEnv, but - // it won't be promoted into XAI_API_KEY / OPENAI_API_KEY for - // this profile's env. + // that helper. Distinct proxy keys are restored onto `env` below + // after withholding; they are not left to survive clearManagedProfileEnv. processEnv: isOAuthProfile ? { ...processEnv, @@ -1814,36 +1882,55 @@ export async function buildLaunchEnv(options: { } : processEnv, }) - const customHeaders = shellCustomHeaders || persistedCustomHeaders + const customHeaders = isCanonicalXaiLaunch + ? shellCustomHeaders || persistedCustomHeaders + : persistedCustomHeaders if (customHeaders) { env.ANTHROPIC_CUSTOM_HEADERS = customHeaders } + if (!isCanonicalXaiLaunch) { + if (persistedOpenAIAuthHeader) { + env.OPENAI_AUTH_HEADER = persistedOpenAIAuthHeader + } + if ( + persistedOpenAIAuthScheme === 'bearer' || + persistedOpenAIAuthScheme === 'raw' + ) { + env.OPENAI_AUTH_SCHEME = persistedOpenAIAuthScheme + } + if (persistedOpenAIAuthHeaderValue) { + env.OPENAI_AUTH_HEADER_VALUE = persistedOpenAIAuthHeaderValue + } + } // Preserve the OAuth credential-source marker so startup validation // accepts an xAI OAuth profile (no XAI_API_KEY needed; openaiShim // resolves the stored access token at request time). if (!env.XAI_API_KEY && isOAuthProfile) { env.XAI_CREDENTIAL_SOURCE = 'oauth' } + if (!isCanonicalXaiLaunch) { + assignDistinctXaiProxyGenericCredential(env, processEnv, persistedEnv) + } - // For OAuth profiles, also clear any ambient OpenAI credentials from - // the returned compatibility env. openaiShim's resolver checks - // process.env.OPENAI_API_KEYS / OPENAI_API_KEY before falling back - // to the stored OAuth token; leaving shell credentials there would - // short-circuit OAuth and send the wrong bearer to api.x.ai/v1. - const compatibilityProcessEnv = - isOAuthProfile && !env.XAI_API_KEY - ? { - ...processEnv, - OPENAI_API_KEYS: undefined, - OPENAI_API_KEY: undefined, - } - : processEnv + // On canonical api.x.ai, openaiShim checks OPENAI_API_KEYS / + // OPENAI_API_KEY before the stored OAuth token. Wipe those so OAuth + // is not short-circuited. On a proxy URL the distinct generic key is + // proxy auth and must survive clearManagedProfileEnv via profileEnv. + const shouldClearAmbientOpenAIForOAuth = + isCanonicalXaiLaunch && isOAuthProfile && !env.XAI_API_KEY + const compatibilityProcessEnv = shouldClearAmbientOpenAIForOAuth + ? { + ...processEnv, + OPENAI_API_KEYS: undefined, + OPENAI_API_KEY: undefined, + } + : processEnv const result = buildCompatibilityProcessEnv({ processEnv: compatibilityProcessEnv, compatibilityMode: 'openai', profileEnv: env, }) - if (isOAuthProfile && !env.XAI_API_KEY) { + if (shouldClearAmbientOpenAIForOAuth) { delete result.OPENAI_API_KEYS delete result.OPENAI_API_KEY } @@ -2080,14 +2167,35 @@ export async function buildLaunchEnv(options: { effectiveOpenAIRouteId === 'apismart' && !!env.OPENAI_BASE_URL?.trim() && !isCanonicalApismartInferenceBaseUrl(env.OPENAI_BASE_URL) + // Older xAI proxy files persisted XAI_API_KEY without a route id. OAuth + // startup files use profile: 'xai' with no dedicated key. Infer withholding + // from those signals so relaunch drops xAI secrets instead of treating the + // file as a generic OpenAI proxy. Do not stamp route identity from a leftover + // key — that would attach xAI catalog/metadata to an unrelated proxy. + const inferredRetargetedXaiIdentity = + !!env.OPENAI_BASE_URL?.trim() && + !isCanonicalXaiInferenceBaseUrl(env.OPENAI_BASE_URL) && + (Boolean(sanitizeApiKey(persistedEnv.XAI_API_KEY)) || + options.persisted?.profile === 'xai') + const isNoncanonicalXaiLaunch = + (effectiveOpenAIRouteId === 'xai' || inferredRetargetedXaiIdentity) && + !!env.OPENAI_BASE_URL?.trim() && + !isCanonicalXaiInferenceBaseUrl(env.OPENAI_BASE_URL) const isNoncanonicalDedicatedOpenAILaunch = - isNoncanonicalAimlapiLaunch || isNoncanonicalApismartLaunch + isNoncanonicalAimlapiLaunch || + isNoncanonicalApismartLaunch || + isNoncanonicalXaiLaunch if (isNoncanonicalDedicatedOpenAILaunch) { delete env.OPENAI_API_KEY delete env.OPENAI_API_KEYS - const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv) - if (persistedCredential) { - env[persistedCredential.envVar] = persistedCredential.value + if (isNoncanonicalXaiLaunch) { + assignDistinctXaiProxyGenericCredential(env, processEnv, persistedEnv) + } else { + // Aimlapi/apismart may restore a profile-owned proxy credential. + const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv) + if (persistedCredential) { + env[persistedCredential.envVar] = persistedCredential.value + } } // Custom authentication is a second credential channel, not just transport // metadata: the OpenAI shim sends OPENAI_AUTH_HEADER_VALUE as the request @@ -2122,6 +2230,7 @@ export async function buildLaunchEnv(options: { 'MIMO_API_KEY', 'NVIDIA_API_KEY', 'VENICE_API_KEY', + 'XAI_API_KEY', ] as const) { // AI/ML API accepts the generic OPENAI_API_KEY, so it does not need an // unconditional carry-over. Only mirror AIMLAPI_API_KEY when the launch @@ -2133,6 +2242,16 @@ export async function buildLaunchEnv(options: { if (dedicatedKey === 'APISMART_API_KEY' && effectiveOpenAIRouteId !== 'apismart') { continue } + if (dedicatedKey === 'XAI_API_KEY' && effectiveOpenAIRouteId !== 'xai') { + continue + } + if ( + dedicatedKey === 'XAI_API_KEY' && + !!env.OPENAI_BASE_URL?.trim() && + !isCanonicalXaiInferenceBaseUrl(env.OPENAI_BASE_URL) + ) { + continue + } if (dedicatedKey === 'NVIDIA_API_KEY' && effectiveOpenAIRouteId !== 'nvidia-nim') { continue } @@ -2285,7 +2404,18 @@ export async function buildStartupEnvFromProfile(options?: { persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'apismart' && !!persisted.env.OPENAI_BASE_URL?.trim() && !isCanonicalApismartInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) - if (hasConcreteProviderSelection(processEnv) && !persistedApismartProxy) { + const persistedXaiProxy = + (persisted?.profile === 'openai' || persisted?.profile === 'xai') && + !!persisted.env.OPENAI_BASE_URL?.trim() && + !isCanonicalXaiInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) && + (persisted.profile === 'xai' || + persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'xai' || + Boolean(sanitizeApiKey(persisted.env.XAI_API_KEY))) + if ( + hasConcreteProviderSelection(processEnv) && + !persistedApismartProxy && + !persistedXaiProxy + ) { return processEnv } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 823d77bede..37966c8c59 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -1495,6 +1495,166 @@ describe('applyProviderProfileToProcessEnv', () => { expect(getFreshAPIProvider()).toBe('xai') }) + test('retargeted xAI profiles keep route identity without applying dedicated credentials', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }), + ) + + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + expect(process.env.XAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1') + }) + + test('retargeted xAI profiles keep a distinct proxy OPENAI_API_KEY', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + process.env.OPENAI_API_KEY = 'proxy-owned-key' + process.env.XAI_API_KEY = 'xai-test-key' + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }), + ) + + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + expect(process.env.OPENAI_API_KEY).toBe('proxy-owned-key') + expect(process.env.XAI_API_KEY).toBeUndefined() + }) + + test('retargeted xAI profiles do not restore a matching profile.apiKey as a proxy credential', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + process.env.OPENAI_API_KEY = 'xai-secret-only' + delete process.env.XAI_API_KEY + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-secret-only', + }), + ) + + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.XAI_API_KEY).toBeUndefined() + }) + + test('retargeted xAI profiles filter a rotated profile.apiKey from OPENAI_API_KEY', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + process.env.OPENAI_API_KEY = 'old-xai-key' + process.env.XAI_API_KEY = 'new-xai-key' + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'old-xai-key', + }), + ) + + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.XAI_API_KEY).toBeUndefined() + }) + + test('does not re-apply a retargeted xAI profile when a distinct proxy key is aligned', async () => { + const { applyActiveProviderProfileFromConfig, applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + const xaiProxy = buildProfile({ + id: 'xai_proxy_aligned', + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }) + process.env.OPENAI_API_KEY = 'proxy-owned-key' + process.env.XAI_API_KEY = 'xai-test-key' + applyProviderProfileToProcessEnv(xaiProxy) + expect(process.env.OPENAI_API_KEY).toBe('proxy-owned-key') + + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [xaiProxy], + activeProviderProfileId: 'xai_proxy_aligned', + } as any) + + expect(applied?.id).toBe('xai_proxy_aligned') + expect(process.env.OPENAI_API_KEY).toBe('proxy-owned-key') + expect(process.env.XAI_API_KEY).toBeUndefined() + }) + + test('openai profiles pointed at an insecure xAI URL do not apply dedicated credentials', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'openai', + name: 'HTTP xAI', + baseUrl: 'http://api.x.ai/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }), + ) + + expect(process.env.XAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('http://api.x.ai/v1') + }) + + test('re-applies a retargeted xAI profile when withheld credentials drift back into env', async () => { + const { applyActiveProviderProfileFromConfig, applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + const xaiProxy = buildProfile({ + id: 'xai_proxy', + provider: 'xai', + name: 'xAI proxy', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }) + applyProviderProfileToProcessEnv(xaiProxy) + + process.env.OPENAI_API_KEY = 'xai-test-key' + process.env.OPENAI_API_KEYS = 'xai-test-key' + process.env.XAI_API_KEY = 'xai-test-key' + delete process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID + + const applied = applyActiveProviderProfileFromConfig({ + providerProfiles: [xaiProxy], + activeProviderProfileId: 'xai_proxy', + } as any) + + expect(applied?.id).toBe('xai_proxy') + expect({ ...process.env }.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEYS).toBeUndefined() + expect(process.env.XAI_API_KEY).toBeUndefined() + }) + test('does not mirror XAI_API_KEY for a lookalike host containing "x.ai"', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -3354,6 +3514,77 @@ describe('setActiveProviderProfile', () => { } }) + test('retargeted xAI profiles keep route identity but persist without their dedicated credential', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const xaiProfile = buildProfile({ + id: 'xai_proxy', + name: 'xAI proxy', + provider: 'xai', + baseUrl: 'https://proxy.example/v1', + model: 'grok-4.6', + apiKey: 'xai-test-key', + }) + + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [xaiProfile], + })) + + const result = setActiveProviderProfile('xai_proxy', { configDir }) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + + expect(result?.id).toBe('xai_proxy') + expect(persisted.profile).toBe('openai') + expect(persisted.env).toEqual({ + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'xai', + OPENAI_BASE_URL: 'https://proxy.example/v1', + OPENAI_MODEL: 'grok-4.6', + }) + + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: { + XAI_API_KEY: 'ambient-xai-key', + OPENAI_API_KEY: 'ambient-xai-key', + }, + }) + + expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('xai') + expect(startupEnv.XAI_API_KEY).toBeUndefined() + expect(startupEnv.OPENAI_API_KEY).toBeUndefined() + + const legacyStartupEnv = await buildStartupEnvFromProfile({ + persisted: { + ...persisted, + env: { + ...persisted.env, + OPENAI_API_KEY: 'xai-test-key', + XAI_API_KEY: 'xai-test-key', + }, + }, + processEnv: {}, + }) + expect(legacyStartupEnv.OPENAI_API_KEY).toBeUndefined() + expect(legacyStartupEnv.XAI_API_KEY).toBeUndefined() + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('persists Xiaomi MiMo 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 4cc1aa0f04..989747fdef 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -14,6 +14,7 @@ import { import type { ModelOption } from './model/modelOptions.js' import { getPrimaryModel, parseModelList } from './providerModels.js' import { + assignDistinctXaiProxyGenericCredential, buildCompatibilityProcessEnv, createProfileFile, saveProfileFile, @@ -50,11 +51,13 @@ import { type ResolvedProfileRoute, type ProviderPreset, } from '../integrations/index.js' +import { parseCredentialList } from '../services/api/credentialPool.js' import { getRouteDefaultBaseUrl, isCloudflareBaseUrl, isClinePassBaseUrl, isCanonicalApismartInferenceBaseUrl, + isCanonicalXaiInferenceBaseUrl, isFireworksBaseUrl, isLongcatBaseUrl, isNearaiBaseUrl, @@ -161,6 +164,38 @@ function isApismartProfile(profile: ProviderProfile): boolean { return !baseUrl || isCanonicalApismartInferenceBaseUrl(baseUrl) } +function isCanonicalXaiProfile(profile: ProviderProfile): boolean { + const baseUrl = profile.baseUrl?.trim() + // Missing base URL resolves to api.x.ai, which is canonical. HTTP, non-443, + // and proxy hosts must not carry the dedicated xAI credential. + return !baseUrl || isCanonicalXaiInferenceBaseUrl(baseUrl) +} + +function withheldXaiGenericKeysAreAligned( + processEnv: NodeJS.ProcessEnv, + profile: ProviderProfile, +): boolean { + if (trimOrUndefined(processEnv.XAI_API_KEY)) { + return false + } + const withheld = new Set(parseCredentialList(profile.apiKey)) + return [ + ...parseCredentialList(processEnv.OPENAI_API_KEY), + ...parseCredentialList(processEnv.OPENAI_API_KEYS), + ].every(value => !withheld.has(value)) +} + +function withholdRetargetedXaiCredential(profile: ProviderProfile): boolean { + // Identity (provider=xai) and host matching (api.x.ai) both attach the + // dedicated secret. Withhold on the same set: an openai-shaped profile + // pointed at http://api.x.ai would otherwise still mirror XAI_API_KEY. + return ( + (resolveProfileRoute(profile.provider).routeId === 'xai' || + isXaiBaseUrl(profile.baseUrl)) && + !isCanonicalXaiProfile(profile) + ) +} + function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined { if (!baseUrl?.trim()) return undefined try { @@ -649,6 +684,7 @@ function isProcessEnvAlignedWithProfile( const includeApiKey = options?.includeApiKey ?? true const primaryModel = options?.primaryModel ?? getPrimaryModel(profile.model) const { compatibilityMode } = resolveProfileCompatibility(profile.provider) + const withholdXaiKey = withholdRetargetedXaiCredential(profile) if (processEnv[PROFILE_ENV_APPLIED_FLAG] !== '1') { return false @@ -784,14 +820,23 @@ function isProcessEnvAlignedWithProfile( expectedContextWindows, ) && (!includeApiKey || - sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey)) && + (withholdXaiKey + ? withheldXaiGenericKeysAreAligned(processEnv, profile) + : sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey))) && (profile.baseUrl?.toLowerCase().includes('bankr') ? !includeApiKey || sameOptionalEnvValue(processEnv.BNKR_API_KEY, profile.apiKey) : true) && - (isXaiBaseUrl(profile.baseUrl) + (resolveProfileRoute(profile.provider).routeId === 'xai' ? !includeApiKey || - sameOptionalEnvValue(processEnv.XAI_API_KEY, profile.apiKey) + processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'xai' + : true) && + ((isXaiBaseUrl(profile.baseUrl) || + resolveProfileRoute(profile.provider).routeId === 'xai') + ? !includeApiKey || + (withholdXaiKey + ? !trimOrUndefined(processEnv.XAI_API_KEY) + : sameOptionalEnvValue(processEnv.XAI_API_KEY, profile.apiKey)) : true) && (isAimlapiRoute ? !includeApiKey || @@ -1001,7 +1046,12 @@ export function applyProviderProfileToProcessEnv( const withholdRetargetedApismartCredential = route.routeId === 'apismart' && !isApismartProfile(profile) - if (profile.apiKey && !withholdRetargetedApismartCredential) { + const withholdXaiKey = withholdRetargetedXaiCredential(profile) + if ( + profile.apiKey && + !withholdRetargetedApismartCredential && + !withholdXaiKey + ) { openAIProfileEnv.OPENAI_API_KEY = profile.apiKey if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) { openAIProfileEnv.MINIMAX_API_KEY = profile.apiKey @@ -1083,6 +1133,22 @@ export function applyProviderProfileToProcessEnv( openAIProfileEnv.AIMLAPI_API_KEY ?? ambientAimlapiKey } } + if (route.routeId === 'xai') { + // Keep xAI identity on retargeted/proxy session env so request-time + // withholding can refuse mirrored OPENAI_API_KEY / OAuth even when + // dedicated keys were not applied. + openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + } + if (withholdXaiKey) { + // Same restore as buildLaunchEnv, plus profile.apiKey: that dedicated + // secret is withheld above and may no longer be in XAI_API_KEY. + assignDistinctXaiProxyGenericCredential( + openAIProfileEnv, + process.env, + {}, + [profile.apiKey], + ) + } // Keep ApiSmart route identity even when the profile is retargeted to a // proxy. Dedicated credentials stay withheld above; the route id is what // lets buildLaunchEnv refuse ambient APISMART_API_KEY / mirrored @@ -1384,11 +1450,16 @@ function buildOpenAICompatibleStartupEnv( const withholdRetargetedApismartCredential = resolveProfileRoute(activeProfile.provider).routeId === 'apismart' && !isApismartProfile(activeProfile) + const withholdXaiKey = withholdRetargetedXaiCredential(activeProfile) const isAimlapiProfile = activeProfile.provider === 'aimlapi' || resolveRouteIdFromBaseUrl(activeProfile.baseUrl) === 'aimlapi' - if (activeProfile.apiKey && !withholdRetargetedApismartCredential) { + if ( + activeProfile.apiKey && + !withholdRetargetedApismartCredential && + !withholdXaiKey + ) { const strictEnv = buildOpenAIProfileEnv({ goal: 'balanced', model: activeProfile.model, @@ -1403,6 +1474,10 @@ function buildOpenAICompatibleStartupEnv( processEnv: {}, }) if (strictEnv) { + if (resolveProfileRoute(activeProfile.provider).routeId === 'xai') { + strictEnv.XAI_API_KEY = activeProfile.apiKey + strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + } if (isAimlapiProfile) { strictEnv.AIMLAPI_API_KEY = activeProfile.apiKey strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi' @@ -1468,12 +1543,22 @@ function buildOpenAICompatibleStartupEnv( if (resolveProfileRoute(activeProfile.provider).routeId === 'apismart') { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'apismart' } - if (activeProfile.apiKey && !withholdRetargetedApismartCredential) { + // Preserve xAI identity on retargeted/proxy startup envs so relaunch + // withholding can refuse ambient dedicated credentials. Canonical profiles + // already stamp this via the keyed strict-env branch. + if (resolveProfileRoute(activeProfile.provider).routeId === 'xai') { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'xai' + } + if ( + activeProfile.apiKey && + !withholdRetargetedApismartCredential && + !withholdXaiKey + ) { env.OPENAI_API_KEY = activeProfile.apiKey if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) { env.BNKR_API_KEY = activeProfile.apiKey } - if (isXaiBaseUrl(activeProfile.baseUrl)) { + if (resolveProfileRoute(activeProfile.provider).routeId === 'xai') { env.XAI_API_KEY = activeProfile.apiKey } if (isAimlapiProfile) { @@ -1735,6 +1820,9 @@ function triggerStartupDiscoveryRefreshForProfile( if (route.routeId === 'apismart' && !isApismartProfile(profile)) { return } + if (withholdRetargetedXaiCredential(profile)) { + return + } void refreshStartupDiscoveryForRoute(route.routeId, { baseUrl: profile.baseUrl,