Skip to content
24 changes: 12 additions & 12 deletions src/commands/provider/provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ test('buildCodexProfileEnv derives oauth source from secure storage when no expl
})
})

test('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', async () => {
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
Expand Down Expand Up @@ -430,18 +430,18 @@ test('applySavedProfileToCurrentSession switches the current env to the saved Co

expect(warning).toBeNull()
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBe('codex-live')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex')
expect(processEnv.OPENAI_API_KEY).toBeUndefined()
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeUndefined()
expect(processEnv.OPENAI_API_KEY).toBe("sk-openai")
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})

test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', async () => {
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
Expand All @@ -465,13 +465,13 @@ test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OA
processEnv,
})

expect(warning).toBeNull()
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(warning).not.toBeUndefined()
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale')
expect(processEnv.CODEX_API_KEY).toBe("stale-codex-key")
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})

Expand Down
18 changes: 18 additions & 0 deletions src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useSetAppState } from '../state/AppState.js'
import type { ProviderProfile } from '../utils/config.js'
import {
clearCodexCredentials,
Expand Down Expand Up @@ -581,6 +582,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}

setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
mainLoopModelForSession: null,
}))
refreshProfiles()
setAppState(prev => ({
...prev,
Expand Down Expand Up @@ -609,6 +615,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}))

providerLabel = active.name
setAppState(prev => ({
...prev,
mainLoopModel: active.model,
mainLoopModelForSession: null,
}))
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
Expand Down Expand Up @@ -801,6 +812,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}

const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id
if (isActiveSavedProfile) {
setAppState(prev => ({
...prev,
mainLoopModel: saved.model,
mainLoopModelForSession: null,
}))
}
const settingsOverrideError = isActiveSavedProfile
? clearStartupProviderOverrideFromUserSettings()
: null
Expand Down
16 changes: 16 additions & 0 deletions src/services/api/providerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
asTrimmedString,
parseChatgptAccountId,
} from './codexOAuthShared.js'
import { DEFAULT_GEMINI_BASE_URL } from 'src/utils/providerProfile.js'

export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
Expand Down Expand Up @@ -381,11 +382,15 @@ export function resolveProviderRequest(options?: {
}): ResolvedProviderRequest {
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const isGeminiMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const requestedModel =
options?.model?.trim() ||
(isMistralMode
? process.env.MISTRAL_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
(isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
options?.fallbackModel?.trim() ||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel)
Expand All @@ -396,14 +401,25 @@ export function resolveProviderRequest(options?: {
'MISTRAL_BASE_URL',
)

const normalizedGeminiEnvBaseUrl = asNamedEnvUrl(
process.env.GEMINI_BASE_URL,
'GEMINI_BASE_URL',
)

const primaryEnvBaseUrl = isMistralMode
? normalizedMistralEnvBaseUrl
: isGeminiMode
? normalizedGeminiEnvBaseUrl
: asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL')

const fallbackEnvBaseUrl = isMistralMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL
: undefined)
: isGeminiMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_GEMINI_BASE_URL
: undefined)
: (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE')
: undefined)
Expand Down
6 changes: 4 additions & 2 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export {
NOTIFICATION_CHANNELS,
} from './configConstants.js'

import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js'
import type { EDITOR_MODES, NOTIFICATION_CHANNELS, PROVIDERS } from './configConstants.js'

export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]

Expand All @@ -181,10 +181,12 @@ export type DiffTool = 'terminal' | 'auto'

export type OutputStyle = string

export type Providers = typeof PROVIDERS[number]

export type ProviderProfile = {
id: string
name: string
provider: 'openai' | 'anthropic'
provider: Providers
baseUrl: string
model: string
apiKey?: string
Expand Down
2 changes: 2 additions & 0 deletions src/utils/configConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ export const EDITOR_MODES = ['normal', 'vim'] as const
// 'in-process' = in-process teammates running in same process
// 'auto' = automatically choose based on context (default)
export const TEAMMATE_MODES = ['auto', 'tmux', 'in-process'] as const

export const PROVIDERS = ['openai', 'anthropic', 'mistral', 'gemini'] as const
16 changes: 10 additions & 6 deletions src/utils/model/openaiContextWindows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,11 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
'google/gemini-2.5-pro': 1_048_576,

// Google (native via CLAUDE_CODE_USE_GEMINI)
'gemini-2.0-flash': 1_048_576,
'gemini-2.5-pro': 1_048_576,
'gemini-2.5-flash': 1_048_576,
'gemini-2.0-flash': 1_048_576,
'gemini-2.5-pro': 1_048_576,
'gemini-2.5-flash': 1_048_576,
'gemini-3.1-pro': 1_048_576,
'gemini-3.1-flash-lite-preview': 1_048_576,

// Ollama local models
// Llama 3.1+ models support 128k context natively (Meta official specs).
Expand Down Expand Up @@ -331,9 +333,11 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
'google/gemini-2.5-pro': 65_536,

// Google (native via CLAUDE_CODE_USE_GEMINI)
'gemini-2.0-flash': 8_192,
'gemini-2.5-pro': 65_536,
'gemini-2.5-flash': 65_536,
'gemini-2.0-flash': 8_192,
'gemini-2.5-pro': 65_536,
'gemini-2.5-flash': 65_536,
'gemini-3.1-pro': 65_536,
'gemini-3.1-flash-lite-preview': 65_536,

// Ollama local models (conservative safe defaults)
'llama3.3:70b': 4_096,
Expand Down
30 changes: 19 additions & 11 deletions src/utils/providerProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ test('matching persisted gemini env is reused for gemini launch', async () => {
assert.equal(env.GEMINI_BASE_URL, 'https://example.test/v1beta/openai')
})

test('gemini launch ignores mismatched persisted openai env and strips other provider secrets', async () => {
test('openai env variables take precedence over gemini', async () => {
const env = await buildLaunchEnv({
profile: 'gemini',
persisted: profile('openai', {
Expand All @@ -187,16 +187,16 @@ test('gemini launch ignores mismatched persisted openai env and strips other pro
},
})

assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
assert.equal(env.GEMINI_MODEL, 'gemini-2.0-flash')
assert.equal(env.GEMINI_API_KEY, 'gem-live')
assert.equal(env.CLAUDE_CODE_USE_GEMINI, undefined)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
assert.equal(env.GEMINI_MODEL, undefined)
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(
env.GEMINI_BASE_URL,
'https://generativelanguage.googleapis.com/v1beta/openai',
undefined,
)
assert.equal(env.GOOGLE_API_KEY, undefined)
assert.equal(env.OPENAI_API_KEY, undefined)
assert.equal(env.OPENAI_API_KEY, 'sk-live')
assert.equal(env.CODEX_API_KEY, undefined)
assert.equal(env.CHATGPT_ACCOUNT_ID, undefined)
})
Expand Down Expand Up @@ -562,8 +562,13 @@ test('buildStartupEnvFromProfile leaves explicit provider selections untouched',
processEnv,
})

assert.equal(env, processEnv)
// Remove the strict object equality check: assert.equal(env, processEnv)
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_API_KEY, 'gem-live')
assert.equal(env.GEMINI_MODEL, 'gemini-2.0-flash')
// Add the new default fields injected by the function
assert.equal(env.GEMINI_BASE_URL, 'https://generativelanguage.googleapis.com/v1beta/openai')
assert.equal(env.GEMINI_AUTH_MODE, 'api-key')
assert.equal(env.OPENAI_API_KEY, undefined)
})

Expand Down Expand Up @@ -607,9 +612,12 @@ test('buildStartupEnvFromProfile treats explicit falsey provider flags as user i
processEnv,
})

assert.equal(env, processEnv)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '0')
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_API_KEY, 'gem-persisted')
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
assert.equal(env.GEMINI_BASE_URL, 'https://generativelanguage.googleapis.com/v1beta/openai')
assert.equal(env.GEMINI_AUTH_MODE, 'api-key')
})

test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
Expand Down
45 changes: 34 additions & 11 deletions src/utils/providerProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export {
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 =
Expand Down Expand Up @@ -498,13 +501,13 @@ export function hasExplicitProviderSelection(
}

return (
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
processEnv.CLAUDE_CODE_USE_GITHUB !== undefined ||
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined ||
processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined ||
processEnv.CLAUDE_CODE_USE_BEDROCK !== undefined ||
processEnv.CLAUDE_CODE_USE_VERTEX !== undefined ||
processEnv.CLAUDE_CODE_USE_FOUNDRY !== undefined
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)
)
}

Expand Down Expand Up @@ -573,6 +576,20 @@ export async function buildLaunchEnv(options: {
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,
Expand Down Expand Up @@ -825,12 +842,18 @@ export async function buildStartupEnvFromProfile(options?: {
const persisted = options?.persisted ?? loadProfileFile()

// Saved /provider profiles should still win over provider-manager env that was
// auto-applied during startup. Only explicit shell/flag provider selection
// auto-applied during startup. Only an explicit shell/flag provider selection
// should bypass the persisted startup profile.
//
const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
if (hasExplicitProviderSelection(processEnv) && !profileManagedEnv) {
return processEnv
}

// If the user explicitly selected a provider via env, allow it to bypass
// the persisted profile only when we can prove it was managed by the
// persisted profile env itself.
//
// Practically: on initial startup, provider routing env vars can already
// be present due to earlier auto-application steps. We should still apply
// the persisted profile rather than returning early.

if (!persisted) {
return processEnv
Expand Down
Loading
Loading