Files
orcs-code/src/utils/providerProfile.ts
Kevin Codex 2586a9cddb feat: add xAI as official provider (#865)
* feat: add xAI as official provider

- Add xAI preset to ProviderManager (alphabetical order)
- Add xAI provider detection via XAI_API_KEY
- Add xAI startup screen heuristic (x.ai base URL or grok model)
- Add xAI status display properties
- Add grok-4 and grok-3 context windows
- Add xAI model fallbacks across all tiers
- Fix JSDoc priority order in providerAutoDetect

Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev>

* fix(xai): persist relaunch classification for xAI profiles

Addresses reviewer feedback on feat/xai-official-provider:
- isProcessEnvAlignedWithProfile now validates XAI_API_KEY for x.ai
  base URLs, mirroring the Bankr pattern. Without this, relaunch
  skips re-applying the profile, XAI_API_KEY stays unset, and
  getAPIProvider() falls back to 'openai'.
- buildOpenAICompatibleStartupEnv now sets XAI_API_KEY when syncing
  active xAI profile to the legacy fallback file.
- Adds 'xai' to VALID_PROVIDERS and --provider xai CLI flag support.
- Adds xAI detection to providerDiscovery label heuristics.
- Adds 'xai' to legacy ProviderProfile type/isProviderProfile guard.
- Adds targeted tests for relaunch alignment, flag application, and
  discovery labeling.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-26 21:26:44 +08:00

1115 lines
32 KiB
TypeScript

import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL,
isCodexBaseUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
type RecommendationGoal,
} from './providerRecommendation.js'
import { readGeminiAccessToken } from './geminiCredentials.js'
import { getOllamaChatBaseUrl } from './providerDiscovery.js'
import { getPrimaryModel } from './providerModels.js'
import { getProviderValidationError } from './providerValidation.js'
import {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.js'
export {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.js'
import { isEnvTruthy } from './envUtils.ts'
import { PROVIDERS } from './configConstants.js'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
export const DEFAULT_GEMINI_BASE_URL =
'https://generativelanguage.googleapis.com/v1beta/openai'
export const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash'
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
export const DEFAULT_MISTRAL_MODEL = 'devstral-latest'
const PROFILE_ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_MISTRAL',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'OPENAI_BASE_URL',
'OPENAI_MODEL',
'OPENAI_API_FORMAT',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
'OPENAI_API_KEY',
'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY',
'GEMINI_AUTH_MODE',
'GEMINI_ACCESS_TOKEN',
'GEMINI_MODEL',
'GEMINI_BASE_URL',
'GOOGLE_API_KEY',
'NVIDIA_NIM',
'NVIDIA_API_KEY',
'NVIDIA_MODEL',
'MINIMAX_API_KEY',
'MINIMAX_BASE_URL',
'MINIMAX_MODEL',
'MISTRAL_BASE_URL',
'MISTRAL_API_KEY',
'MISTRAL_MODEL',
'BANKR_BASE_URL',
'BNKR_API_KEY',
'BANKR_MODEL',
'XAI_API_KEY',
] as const
const SECRET_ENV_KEYS = [
'OPENAI_API_KEY',
'OPENAI_AUTH_HEADER_VALUE',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'NVIDIA_API_KEY',
'MINIMAX_API_KEY',
'MISTRAL_API_KEY',
'BNKR_API_KEY',
'XAI_API_KEY',
] as const
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'nvidia-nim' | 'minimax' | 'mistral' | 'xai'
export type ProfileEnv = {
OPENAI_BASE_URL?: string
OPENAI_MODEL?: string
OPENAI_API_FORMAT?: 'chat_completions' | 'responses'
OPENAI_AUTH_HEADER?: string
OPENAI_AUTH_SCHEME?: 'bearer' | 'raw'
OPENAI_AUTH_HEADER_VALUE?: string
OPENAI_API_KEY?: string
CODEX_API_KEY?: string
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
GEMINI_MODEL?: string
GEMINI_BASE_URL?: string
GOOGLE_API_KEY?: string
NVIDIA_NIM?: string
NVIDIA_API_KEY?: string
MINIMAX_API_KEY?: string
MINIMAX_BASE_URL?: string
MINIMAX_MODEL?: string
MISTRAL_BASE_URL?: string
MISTRAL_API_KEY?: string
MISTRAL_MODEL?: string
BANKR_BASE_URL?: string
BNKR_API_KEY?: string
BANKR_MODEL?: string
XAI_API_KEY?: string
}
export type ProfileFile = {
profile: ProviderProfile
env: ProfileEnv
createdAt: string
}
type SecretValueSource = Partial<
Record<
| 'OPENAI_API_KEY'
| 'OPENAI_AUTH_HEADER_VALUE'
| 'CODEX_API_KEY'
| 'GEMINI_API_KEY'
| 'GOOGLE_API_KEY'
| 'NVIDIA_API_KEY'
| 'MINIMAX_API_KEY'
| 'MISTRAL_API_KEY'
| 'BNKR_API_KEY',
string | undefined
>
>
type ProfileFileLocation = {
cwd?: string
filePath?: string
}
function resolveProfileFilePath(options?: ProfileFileLocation): string {
if (options?.filePath) {
return options.filePath
}
return resolve(options?.cwd ?? process.cwd(), PROFILE_FILE_NAME)
}
function normalizeProfileModel(
value: string | undefined | null,
): string | undefined {
const trimmed = value?.trim()
if (!trimmed) {
return undefined
}
const primary = getPrimaryModel(trimmed)
return primary.length > 0 ? primary : undefined
}
export function isProviderProfile(value: unknown): value is ProviderProfile {
return (
value === 'openai' ||
value === 'ollama' ||
value === 'codex' ||
value === 'gemini' ||
value === 'atomic-chat' ||
value === 'nvidia-nim' ||
value === 'minimax' ||
value === 'mistral' ||
value === 'xai'
)
}
export function buildOllamaProfileEnv(
model: string,
options: {
baseUrl?: string | null
getOllamaChatBaseUrl: (baseUrl?: string) => string
},
): ProfileEnv {
return {
OPENAI_BASE_URL: options.getOllamaChatBaseUrl(options.baseUrl ?? undefined),
OPENAI_MODEL: model,
}
}
export function buildAtomicChatProfileEnv(
model: string,
options: {
baseUrl?: string | null
getAtomicChatChatBaseUrl: (baseUrl?: string) => string
},
): ProfileEnv {
return {
OPENAI_BASE_URL: options.getAtomicChatChatBaseUrl(options.baseUrl ?? undefined),
OPENAI_MODEL: model,
}
}
export function buildNvidiaNimProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.NVIDIA_API_KEY)
if (!key) {
return null
}
const defaultBaseUrl = 'https://integrate.api.nvidia.com/v1'
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
return {
OPENAI_BASE_URL:
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
defaultBaseUrl,
OPENAI_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
normalizeProfileModel(
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource),
) ||
'nvidia/llama-3.1-nemotron-70b-instruct',
OPENAI_API_KEY: key,
NVIDIA_NIM: '1',
}
}
export function buildMiniMaxProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.MINIMAX_API_KEY)
if (!key) {
return null
}
const defaultBaseUrl = 'https://api.minimax.io/v1'
const defaultModel = 'MiniMax-M2.5'
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
return {
OPENAI_BASE_URL:
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
defaultBaseUrl,
OPENAI_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
normalizeProfileModel(
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource),
) ||
defaultModel,
OPENAI_API_KEY: key,
MINIMAX_API_KEY: key,
MINIMAX_BASE_URL: defaultBaseUrl,
MINIMAX_MODEL: defaultModel,
}
}
export function buildGeminiProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
authMode?: 'api-key' | 'access-token' | 'adc'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const authMode = options.authMode ?? 'api-key'
const key = sanitizeApiKey(
options.apiKey ??
processEnv.GEMINI_API_KEY ??
processEnv.GOOGLE_API_KEY,
)
if (authMode === 'api-key' && !key) {
return null
}
const secretSource: SecretValueSource = key ? { GEMINI_API_KEY: key } : {}
const env: ProfileEnv = {
GEMINI_AUTH_MODE: authMode,
GEMINI_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
normalizeProfileModel(
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource),
) ||
DEFAULT_GEMINI_MODEL,
}
if (authMode === 'api-key' && key) {
env.GEMINI_API_KEY = key
}
const baseUrl =
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
sanitizeProviderConfigValue(processEnv.GEMINI_BASE_URL, secretSource)
if (baseUrl) {
env.GEMINI_BASE_URL = baseUrl
}
return env
}
export function buildOpenAIProfileEnv(options: {
goal: RecommendationGoal
model?: string | null
baseUrl?: string | null
apiKey?: string | null
apiFormat?: 'chat_completions' | 'responses' | null
authHeader?: string | null
authScheme?: 'bearer' | 'raw' | null
authHeaderValue?: string | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.OPENAI_API_KEY)
const authHeaderValue = sanitizeApiKey(
options.authHeaderValue ?? processEnv.OPENAI_AUTH_HEADER_VALUE,
)
if (!key && !authHeaderValue) {
return null
}
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
const secretSource: SecretValueSource = {
OPENAI_API_KEY: key,
OPENAI_AUTH_HEADER_VALUE: authHeaderValue,
}
const shellOpenAIModel = normalizeProfileModel(
sanitizeProviderConfigValue(
processEnv.OPENAI_MODEL,
secretSource,
),
)
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
processEnv.OPENAI_BASE_URL,
secretSource,
)
const shellOpenAIRequest = resolveProviderRequest({
model: shellOpenAIModel,
baseUrl: shellOpenAIBaseUrl,
fallbackModel: defaultModel,
apiFormat: processEnv.OPENAI_API_FORMAT,
})
const useShellOpenAIConfig = shellOpenAIRequest.transport !== 'codex_responses'
return {
OPENAI_BASE_URL:
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
DEFAULT_OPENAI_BASE_URL,
OPENAI_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
defaultModel,
...(options.apiFormat ? { OPENAI_API_FORMAT: options.apiFormat } : {}),
...(options.authHeader ? { OPENAI_AUTH_HEADER: options.authHeader } : {}),
...(options.authScheme ? { OPENAI_AUTH_SCHEME: options.authScheme } : {}),
...(authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: authHeaderValue } : {}),
...(key ? { OPENAI_API_KEY: key } : {}),
}
}
export function buildCodexProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
credentialSource?: 'oauth' | 'existing'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.CODEX_API_KEY)
const credentialEnv = key
? ({ ...processEnv, CODEX_API_KEY: key } as NodeJS.ProcessEnv)
: processEnv
const credentials = resolveCodexApiCredentials(credentialEnv)
if (!credentials.apiKey || !credentials.accountId) {
return null
}
const credentialSource =
options.credentialSource ??
(credentials.source === 'secure-storage' ? 'oauth' : 'existing')
const env: ProfileEnv = {
OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: options.model || 'codexplan',
CODEX_CREDENTIAL_SOURCE: credentialSource,
}
if (key) {
env.CODEX_API_KEY = key
}
env.CHATGPT_ACCOUNT_ID = credentials.accountId
return env
}
export function buildMistralProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.MISTRAL_API_KEY)
if (!key) {
return null
}
const env: ProfileEnv = {
MISTRAL_API_KEY: key,
MISTRAL_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, { MISTRAL_API_KEY: key }),
) ||
normalizeProfileModel(
sanitizeProviderConfigValue(
processEnv.MISTRAL_MODEL,
{ MISTRAL_API_KEY: key },
),
) ||
DEFAULT_MISTRAL_MODEL,
}
const baseUrl =
sanitizeProviderConfigValue(options.baseUrl, { MISTRAL_API_KEY: key }) ||
sanitizeProviderConfigValue(
processEnv.MISTRAL_BASE_URL,
{ MISTRAL_API_KEY: key },
)
if (baseUrl) {
env.MISTRAL_BASE_URL = baseUrl
}
return env
}
export function buildBankrProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const key = sanitizeApiKey(options.apiKey ?? processEnv.BNKR_API_KEY)
if (!key) {
return null
}
const env: ProfileEnv = {
BNKR_API_KEY: key,
BANKR_MODEL:
sanitizeProviderConfigValue(options.model, { BNKR_API_KEY: key }) ||
sanitizeProviderConfigValue(
processEnv.BANKR_MODEL,
{ BNKR_API_KEY: key },
) ||
'claude-opus-4.6',
}
const baseUrl =
sanitizeProviderConfigValue(options.baseUrl, { BNKR_API_KEY: key }) ||
sanitizeProviderConfigValue(
processEnv.BANKR_BASE_URL,
{ BNKR_API_KEY: key },
)
if (baseUrl) {
env.BANKR_BASE_URL = baseUrl
}
return env
}
export function buildCodexOAuthProfileEnv(
tokens: {
accessToken: string
idToken?: string
accountId?: string
},
): ProfileEnv | null {
const accountId =
tokens.accountId ??
parseChatgptAccountId(tokens.idToken) ??
parseChatgptAccountId(tokens.accessToken)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth',
}
}
export function createProfileFile(
profile: ProviderProfile,
env: ProfileEnv,
): ProfileFile {
return {
profile,
env,
createdAt: new Date().toISOString(),
}
}
export function isPersistedCodexOAuthProfile(
persisted: ProfileFile | null,
): boolean {
return (
persisted?.profile === 'codex' &&
persisted.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
)
}
export function clearPersistedCodexOAuthProfile(
options?: ProfileFileLocation,
): string | null {
const persisted = loadProfileFile(options)
if (!isPersistedCodexOAuthProfile(persisted)) {
return null
}
return deleteProfileFile(options)
}
export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
const filePath = resolveProfileFilePath(options)
if (!existsSync(filePath)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<ProfileFile>
if (!isProviderProfile(parsed.profile) || !parsed.env || typeof parsed.env !== 'object') {
return null
}
return {
profile: parsed.profile,
env: parsed.env,
createdAt:
typeof parsed.createdAt === 'string'
? parsed.createdAt
: new Date().toISOString(),
}
} catch {
return null
}
}
export function saveProfileFile(
profileFile: ProfileFile,
options?: ProfileFileLocation,
): string {
const filePath = resolveProfileFilePath(options)
writeFileSync(filePath, JSON.stringify(profileFile, null, 2), {
encoding: 'utf8',
mode: 0o600,
})
return filePath
}
export function deleteProfileFile(options?: ProfileFileLocation): string {
const filePath = resolveProfileFilePath(options)
rmSync(filePath, { force: true })
return filePath
}
export function hasExplicitProviderSelection(
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
// If env was already applied from a provider profile, preserve it.
if (processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1') {
return true
}
return (
isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY)
)
}
export function selectAutoProfile(
recommendedOllamaModel: string | null,
): ProviderProfile {
return recommendedOllamaModel ? 'ollama' : 'openai'
}
export async function buildLaunchEnv(options: {
profile: ProviderProfile
persisted: ProfileFile | null
goal: RecommendationGoal
processEnv?: NodeJS.ProcessEnv
getOllamaChatBaseUrl?: (baseUrl?: string) => string
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
resolveAtomicChatDefaultModel?: () => Promise<string | null>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options.processEnv ?? process.env
const persistedEnv =
options.persisted?.profile === options.profile
? options.persisted.env ?? {}
: {}
const persistedOpenAIModel = normalizeProfileModel(
sanitizeProviderConfigValue(
persistedEnv.OPENAI_MODEL,
persistedEnv,
),
)
const persistedOpenAIBaseUrl = sanitizeProviderConfigValue(
persistedEnv.OPENAI_BASE_URL,
persistedEnv,
)
const persistedOpenAIApiFormat = persistedEnv.OPENAI_API_FORMAT
const persistedOpenAIAuthHeader = persistedEnv.OPENAI_AUTH_HEADER
const persistedOpenAIAuthScheme = persistedEnv.OPENAI_AUTH_SCHEME
const persistedOpenAIAuthHeaderValue = sanitizeApiKey(
persistedEnv.OPENAI_AUTH_HEADER_VALUE,
)
const shellOpenAIModel = normalizeProfileModel(
sanitizeProviderConfigValue(
processEnv.OPENAI_MODEL,
processEnv as SecretValueSource,
),
)
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
processEnv.OPENAI_BASE_URL,
processEnv as SecretValueSource,
)
const persistedGeminiModel = normalizeProfileModel(
sanitizeProviderConfigValue(
persistedEnv.GEMINI_MODEL,
persistedEnv,
),
)
const persistedGeminiBaseUrl = sanitizeProviderConfigValue(
persistedEnv.GEMINI_BASE_URL,
persistedEnv,
)
const shellGeminiModel = normalizeProfileModel(
sanitizeProviderConfigValue(
processEnv.GEMINI_MODEL,
processEnv as SecretValueSource,
),
)
const shellGeminiBaseUrl = sanitizeProviderConfigValue(
processEnv.GEMINI_BASE_URL,
processEnv as SecretValueSource,
)
const shellGeminiAccessToken =
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
const storedGeminiAccessToken =
options.readGeminiAccessToken?.() ?? readGeminiAccessToken()
const shellGeminiKey = sanitizeApiKey(
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
)
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
const persistedGeminiAuthMode = persistedEnv.GEMINI_AUTH_MODE
if (hasExplicitProviderSelection(processEnv)) {
for (let provider of PROVIDERS) {
if (provider === "anthropic") {
continue;
}
const env_key_name = `CLAUDE_CODE_USE_${provider.toUpperCase()}`
if (env_key_name in processEnv && isEnvTruthy(processEnv[env_key_name])) {
options.profile = provider;
}
}
}
if (options.profile === 'gemini') {
const env: NodeJS.ProcessEnv = {
...processEnv,
CLAUDE_CODE_USE_GEMINI: '1',
}
delete env.CLAUDE_CODE_USE_OPENAI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
env.GEMINI_MODEL =
shellGeminiModel ||
persistedGeminiModel ||
DEFAULT_GEMINI_MODEL
env.GEMINI_BASE_URL =
shellGeminiBaseUrl ||
persistedGeminiBaseUrl ||
DEFAULT_GEMINI_BASE_URL
const geminiAuthMode =
persistedGeminiAuthMode === 'access-token' ||
persistedGeminiAuthMode === 'adc'
? persistedGeminiAuthMode
: 'api-key'
const geminiKey = shellGeminiKey || persistedGeminiKey
if (geminiAuthMode === 'api-key' && geminiKey) {
env.GEMINI_API_KEY = geminiKey
} else {
delete env.GEMINI_API_KEY
}
env.GEMINI_AUTH_MODE = geminiAuthMode
if (geminiAuthMode === 'access-token') {
const geminiAccessToken =
shellGeminiAccessToken || storedGeminiAccessToken
if (geminiAccessToken) {
env.GEMINI_ACCESS_TOKEN = geminiAccessToken
} else {
delete env.GEMINI_ACCESS_TOKEN
}
} else {
delete env.GEMINI_ACCESS_TOKEN
}
delete env.GOOGLE_API_KEY
delete env.OPENAI_BASE_URL
delete env.OPENAI_MODEL
delete env.OPENAI_API_FORMAT
delete env.OPENAI_AUTH_HEADER
delete env.OPENAI_AUTH_SCHEME
delete env.OPENAI_AUTH_HEADER_VALUE
delete env.OPENAI_API_KEY
delete env.CODEX_API_KEY
delete env.CHATGPT_ACCOUNT_ID
delete env.CODEX_ACCOUNT_ID
return env
}
if (options.profile === 'mistral') {
const env: NodeJS.ProcessEnv = {
...processEnv,
CLAUDE_CODE_USE_MISTRAL: '1',
}
delete env.CLAUDE_CODE_USE_OPENAI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_BEDROCK
delete env.CLAUDE_CODE_USE_VERTEX
delete env.CLAUDE_CODE_USE_FOUNDRY
const shellMistralModel = normalizeProfileModel(
sanitizeProviderConfigValue(
processEnv.MISTRAL_MODEL,
),
)
const persistedMistralModel = normalizeProfileModel(
sanitizeProviderConfigValue(
persistedEnv.MISTRAL_MODEL,
),
)
const shellMistralBaseUrl = sanitizeProviderConfigValue(
processEnv.MISTRAL_BASE_URL,
)
const persistedMistralBaseUrl = sanitizeProviderConfigValue(
persistedEnv.MISTRAL_BASE_URL,
)
env.MISTRAL_MODEL =
shellMistralModel || persistedMistralModel || DEFAULT_MISTRAL_MODEL
const shellMistralKey = sanitizeApiKey(
processEnv.MISTRAL_API_KEY,
)
const persistedMistralKey = sanitizeApiKey(persistedEnv.MISTRAL_API_KEY)
const mistralKey = shellMistralKey || persistedMistralKey
if (mistralKey) {
env.MISTRAL_API_KEY = mistralKey
} else {
delete env.MISTRAL_API_KEY
}
if (shellMistralBaseUrl || persistedMistralBaseUrl) {
env.MISTRAL_BASE_URL = shellMistralBaseUrl || persistedMistralBaseUrl
} else {
delete env.MISTRAL_BASE_URL
}
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
delete env.GEMINI_MODEL
delete env.GEMINI_BASE_URL
delete env.GOOGLE_API_KEY
delete env.OPENAI_BASE_URL
delete env.OPENAI_MODEL
delete env.OPENAI_API_FORMAT
delete env.OPENAI_AUTH_HEADER
delete env.OPENAI_AUTH_SCHEME
delete env.OPENAI_AUTH_HEADER_VALUE
delete env.OPENAI_API_KEY
delete env.CODEX_API_KEY
delete env.CHATGPT_ACCOUNT_ID
delete env.CODEX_ACCOUNT_ID
return env
}
const env: NodeJS.ProcessEnv = {
...processEnv,
CLAUDE_CODE_USE_OPENAI: '1',
}
delete env.CLAUDE_CODE_USE_MISTRAL
delete env.CLAUDE_CODE_USE_BEDROCK
delete env.CLAUDE_CODE_USE_VERTEX
delete env.CLAUDE_CODE_USE_FOUNDRY
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
delete env.GEMINI_MODEL
delete env.GEMINI_BASE_URL
delete env.GOOGLE_API_KEY
if (options.profile === 'ollama') {
const getOllamaBaseUrl =
options.getOllamaChatBaseUrl ?? (() => 'http://localhost:11434/v1')
const resolveOllamaModel =
options.resolveOllamaDefaultModel ?? (async () => 'llama3.1:8b')
env.OPENAI_BASE_URL = persistedOpenAIBaseUrl || getOllamaBaseUrl()
env.OPENAI_MODEL =
persistedOpenAIModel ||
(await resolveOllamaModel(options.goal))
delete env.OPENAI_API_KEY
delete env.OPENAI_API_FORMAT
delete env.OPENAI_AUTH_HEADER
delete env.OPENAI_AUTH_SCHEME
delete env.OPENAI_AUTH_HEADER_VALUE
delete env.CODEX_API_KEY
delete env.CHATGPT_ACCOUNT_ID
delete env.CODEX_ACCOUNT_ID
return env
}
if (options.profile === 'atomic-chat') {
const getAtomicChatBaseUrl =
options.getAtomicChatChatBaseUrl ?? (() => 'http://127.0.0.1:1337/v1')
const resolveModel =
options.resolveAtomicChatDefaultModel ?? (async () => null as string | null)
env.OPENAI_BASE_URL = persistedEnv.OPENAI_BASE_URL || getAtomicChatBaseUrl()
env.OPENAI_MODEL =
persistedEnv.OPENAI_MODEL ||
(await resolveModel()) ||
''
delete env.OPENAI_API_KEY
delete env.OPENAI_API_FORMAT
delete env.OPENAI_AUTH_HEADER
delete env.OPENAI_AUTH_SCHEME
delete env.OPENAI_AUTH_HEADER_VALUE
delete env.CODEX_API_KEY
delete env.CHATGPT_ACCOUNT_ID
delete env.CODEX_ACCOUNT_ID
return env
}
if (options.profile === 'codex') {
env.OPENAI_BASE_URL =
persistedOpenAIBaseUrl && isCodexBaseUrl(persistedOpenAIBaseUrl)
? persistedOpenAIBaseUrl
: DEFAULT_CODEX_BASE_URL
env.OPENAI_MODEL = persistedOpenAIModel || 'codexplan'
delete env.OPENAI_API_KEY
delete env.OPENAI_API_FORMAT
delete env.OPENAI_AUTH_HEADER
delete env.OPENAI_AUTH_SCHEME
delete env.OPENAI_AUTH_HEADER_VALUE
const codexKey =
sanitizeApiKey(processEnv.CODEX_API_KEY) ||
sanitizeApiKey(persistedEnv.CODEX_API_KEY)
const liveCodexCredentials = resolveCodexApiCredentials(processEnv)
const codexAccountId =
processEnv.CHATGPT_ACCOUNT_ID ||
processEnv.CODEX_ACCOUNT_ID ||
liveCodexCredentials.accountId ||
persistedEnv.CHATGPT_ACCOUNT_ID ||
persistedEnv.CODEX_ACCOUNT_ID
if (codexKey) {
env.CODEX_API_KEY = codexKey
} else {
delete env.CODEX_API_KEY
}
if (codexAccountId) {
env.CHATGPT_ACCOUNT_ID = codexAccountId
} else {
delete env.CHATGPT_ACCOUNT_ID
}
delete env.CODEX_ACCOUNT_ID
return env
}
const defaultOpenAIModel = getGoalDefaultOpenAIModel(options.goal)
const shellOpenAIRequest = resolveProviderRequest({
model: shellOpenAIModel,
baseUrl: shellOpenAIBaseUrl,
fallbackModel: defaultOpenAIModel,
apiFormat: processEnv.OPENAI_API_FORMAT,
})
const persistedOpenAIRequest = resolveProviderRequest({
model: persistedOpenAIModel,
baseUrl: persistedOpenAIBaseUrl,
fallbackModel: defaultOpenAIModel,
apiFormat: persistedOpenAIApiFormat,
})
const useShellOpenAIConfig = shellOpenAIRequest.transport !== 'codex_responses'
const usePersistedOpenAIConfig =
(!persistedOpenAIModel && !persistedOpenAIBaseUrl) ||
persistedOpenAIRequest.transport !== 'codex_responses'
env.OPENAI_BASE_URL =
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
(usePersistedOpenAIConfig ? persistedOpenAIBaseUrl : undefined) ||
DEFAULT_OPENAI_BASE_URL
env.OPENAI_MODEL =
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
(usePersistedOpenAIConfig ? persistedOpenAIModel : undefined) ||
defaultOpenAIModel
const openAIApiFormat =
processEnv.OPENAI_API_FORMAT ||
(usePersistedOpenAIConfig ? persistedOpenAIApiFormat : undefined)
if (openAIApiFormat) {
env.OPENAI_API_FORMAT = openAIApiFormat
} else {
delete env.OPENAI_API_FORMAT
}
const openAIAuthHeader =
processEnv.OPENAI_AUTH_HEADER ||
(usePersistedOpenAIConfig ? persistedOpenAIAuthHeader : undefined)
if (openAIAuthHeader) {
env.OPENAI_AUTH_HEADER = openAIAuthHeader
} else {
delete env.OPENAI_AUTH_HEADER
}
const openAIAuthScheme =
processEnv.OPENAI_AUTH_SCHEME ||
(usePersistedOpenAIConfig ? persistedOpenAIAuthScheme : undefined)
if (openAIAuthScheme) {
env.OPENAI_AUTH_SCHEME = openAIAuthScheme
} else {
delete env.OPENAI_AUTH_SCHEME
}
const openAIAuthHeaderValue =
sanitizeApiKey(processEnv.OPENAI_AUTH_HEADER_VALUE) ||
(usePersistedOpenAIConfig ? persistedOpenAIAuthHeaderValue : undefined)
if (openAIAuthHeaderValue) {
env.OPENAI_AUTH_HEADER_VALUE = openAIAuthHeaderValue
} else {
delete env.OPENAI_AUTH_HEADER_VALUE
}
const openAIKey = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY
if (openAIKey) {
env.OPENAI_API_KEY = openAIKey
} else {
delete env.OPENAI_API_KEY
}
delete env.CODEX_API_KEY
delete env.CHATGPT_ACCOUNT_ID
delete env.CODEX_ACCOUNT_ID
return env
}
export async function buildStartupEnvFromProfile(options?: {
persisted?: ProfileFile | null
goal?: RecommendationGoal
processEnv?: NodeJS.ProcessEnv
getOllamaChatBaseUrl?: (baseUrl?: string) => string
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options?.processEnv ?? process.env
const persisted = options?.persisted ?? loadProfileFile()
const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
// The legacy single-profile file (~/.openclaude-profile.json) is a
// first-run / fallback mechanism. The newer plural provider-profile
// system (`/provider` presets + activeProviderProfileId in config) is
// applied earlier in the bootstrap via applyActiveProviderProfileFromConfig
// and signals completion with CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1.
//
// If the plural system has already set env, trust it — do NOT overlay the
// legacy file. addProviderProfile() does not sync the legacy file, so a
// stale legacy file (e.g. OpenAI defaults from an earlier manual setup)
// would otherwise overwrite the correct plural env and surface as the
// "banner shows gpt-4o / api.openai.com even though my saved profile is
// Moonshot" bug.
if (profileManagedEnv) {
return processEnv
}
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return processEnv
}
if (!persisted) {
return processEnv
}
return buildLaunchEnv({
profile: persisted.profile,
persisted,
goal:
options?.goal ??
normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
processEnv,
getOllamaChatBaseUrl:
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
readGeminiAccessToken: options?.readGeminiAccessToken,
})
}
export function applyProfileEnvToProcessEnv(
targetEnv: NodeJS.ProcessEnv,
nextEnv: NodeJS.ProcessEnv,
): void {
for (const key of PROFILE_ENV_KEYS) {
delete targetEnv[key]
}
Object.assign(targetEnv, nextEnv)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
const processEnv = options.processEnv ?? process.env
const baseEnv = { ...processEnv }
const isCodexOAuthProfile =
options.profileFile.profile === 'codex' &&
options.profileFile.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
if (isCodexOAuthProfile) {
delete baseEnv.CODEX_API_KEY
delete baseEnv.CODEX_ACCOUNT_ID
delete baseEnv.CHATGPT_ACCOUNT_ID
}
const nextEnv = await buildLaunchEnv({
profile: options.profileFile.profile,
persisted: options.profileFile,
goal: normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
processEnv: baseEnv,
getOllamaChatBaseUrl,
readGeminiAccessToken,
})
const validationError = await getProviderValidationError(nextEnv)
if (validationError) {
return validationError
}
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
applyProfileEnvToProcessEnv(processEnv, nextEnv)
return null
}