feat: GitHub provider lifecycle and onboarding hardening (#351)
* feat: improve GitHub provider onboarding and lifecycle * fix: address copilot review in provider manager * fix: address follow-up copilot review comments * test: resolve rebase conflict in provider profiles suite * fix: clear stale github hydrated marker * fix: harden github onboarding auth precedence * fix: remove merge markers from provider tests * fix: resolve latest copilot onboarding comments --------- Co-authored-by: KRATOS <84986124+gnanam1990@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,8 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
GH_TOKEN: process.env.GH_TOKEN,
|
||||
CLAUDE_CODE_GITHUB_TOKEN_HYDRATED:
|
||||
process.env.CLAUDE_CODE_GITHUB_TOKEN_HYDRATED,
|
||||
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
|
||||
}
|
||||
|
||||
@@ -43,11 +45,13 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
||||
)
|
||||
hydrateGithubModelsTokenFromSecureStorage()
|
||||
expect(process.env.GITHUB_TOKEN).toBe('stored-secret')
|
||||
expect(process.env.CLAUDE_CODE_GITHUB_TOKEN_HYDRATED).toBe('1')
|
||||
})
|
||||
|
||||
test('does not override existing GITHUB_TOKEN', async () => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
process.env.GITHUB_TOKEN = 'already'
|
||||
delete process.env.CLAUDE_CODE_GITHUB_TOKEN_HYDRATED
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
@@ -62,5 +66,6 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
||||
)
|
||||
hydrateGithubModelsTokenFromSecureStorage()
|
||||
expect(process.env.GITHUB_TOKEN).toBe('already')
|
||||
expect(process.env.CLAUDE_CODE_GITHUB_TOKEN_HYDRATED).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getSecureStorage } from './secureStorage/index.js'
|
||||
|
||||
/** JSON key in the shared OpenClaude secure storage blob. */
|
||||
export const GITHUB_MODELS_STORAGE_KEY = 'githubModels' as const
|
||||
export const GITHUB_MODELS_HYDRATED_ENV_MARKER =
|
||||
'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED' as const
|
||||
|
||||
export type GithubModelsCredentialBlob = {
|
||||
accessToken: string
|
||||
@@ -27,18 +29,28 @@ export function readGithubModelsToken(): string | undefined {
|
||||
*/
|
||||
export function hydrateGithubModelsTokenFromSecureStorage(): void {
|
||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||
return
|
||||
}
|
||||
if (process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()) {
|
||||
if (process.env.GH_TOKEN?.trim()) {
|
||||
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||
return
|
||||
}
|
||||
if (process.env.GITHUB_TOKEN?.trim()) {
|
||||
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||
return
|
||||
}
|
||||
if (isBareMode()) {
|
||||
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||
return
|
||||
}
|
||||
const t = readGithubModelsToken()
|
||||
if (t) {
|
||||
process.env.GITHUB_TOKEN = t
|
||||
process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER] = '1'
|
||||
return
|
||||
}
|
||||
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||
}
|
||||
|
||||
export function saveGithubModelsToken(token: string): {
|
||||
|
||||
@@ -80,7 +80,9 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
|
||||
const provider = getAPIProvider()
|
||||
specifiedModel =
|
||||
(provider === 'gemini' ? process.env.GEMINI_MODEL : undefined) ||
|
||||
(provider === 'openai' || provider === 'gemini' ? process.env.OPENAI_MODEL : undefined) ||
|
||||
(provider === 'openai' || provider === 'gemini' || provider === 'github'
|
||||
? process.env.OPENAI_MODEL
|
||||
: undefined) ||
|
||||
(provider === 'firstParty' ? process.env.ANTHROPIC_MODEL : undefined) ||
|
||||
settings.model ||
|
||||
undefined
|
||||
@@ -237,6 +239,10 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
if (getAPIProvider() === 'openai') {
|
||||
return process.env.OPENAI_MODEL || 'gpt-4o'
|
||||
}
|
||||
// GitHub provider: always use the configured GitHub model
|
||||
if (getAPIProvider() === 'github') {
|
||||
return process.env.OPENAI_MODEL || 'github:copilot'
|
||||
}
|
||||
// Codex provider: always use the configured Codex model (default gpt-5.4)
|
||||
if (getAPIProvider() === 'codex') {
|
||||
return process.env.OPENAI_MODEL || 'gpt-5.4'
|
||||
|
||||
46
src/utils/model/modelOptions.github.test.ts
Normal file
46
src/utils/model/modelOptions.github.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import { getModelOptions } from './modelOptions.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
||||
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY
|
||||
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION =
|
||||
originalEnv.ANTHROPIC_CUSTOM_MODEL_OPTION
|
||||
})
|
||||
|
||||
test('GitHub provider exposes only default + GitHub model in /model options', () => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
|
||||
process.env.OPENAI_MODEL = 'github:copilot'
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION
|
||||
|
||||
const options = getModelOptions(false)
|
||||
const nonDefault = options.filter(option => option.value !== null)
|
||||
|
||||
expect(nonDefault.length).toBe(1)
|
||||
expect(nonDefault[0]?.value).toBe('github:copilot')
|
||||
})
|
||||
@@ -352,6 +352,18 @@ function getCodexModelOptions(): ModelOption[] {
|
||||
// @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
|
||||
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
||||
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
if (getAPIProvider() === 'github') {
|
||||
const githubModel = process.env.OPENAI_MODEL?.trim() || 'github:copilot'
|
||||
return [
|
||||
getDefaultOptionForUser(fastMode),
|
||||
{
|
||||
value: githubModel,
|
||||
label: githubModel,
|
||||
description: 'GitHub Models default',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// When using Ollama, show models from the Ollama server instead of Claude models
|
||||
if (getAPIProvider() === 'openai' && isOllamaProvider()) {
|
||||
const defaultOption = getDefaultOptionForUser(fastMode)
|
||||
@@ -579,6 +591,10 @@ function getKnownModelOption(model: string): ModelOption | null {
|
||||
}
|
||||
|
||||
export function getModelOptions(fastMode = false): ModelOption[] {
|
||||
if (getAPIProvider() === 'github') {
|
||||
return filterModelOptionsByAllowlist(getModelOptionsBase(fastMode))
|
||||
}
|
||||
|
||||
const options = getModelOptionsBase(fastMode)
|
||||
|
||||
// Add the custom model from the ANTHROPIC_CUSTOM_MODEL_OPTION env var
|
||||
|
||||
54
src/utils/model/modelStrings.github.test.ts
Normal file
54
src/utils/model/modelStrings.github.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js'
|
||||
import { parseUserSpecifiedModel } from './model.js'
|
||||
import { getModelStrings } from './modelStrings.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
||||
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
|
||||
}
|
||||
|
||||
function clearProviderFlags(): void {
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY
|
||||
resetModelStringsForTestingOnly()
|
||||
})
|
||||
|
||||
test('GitHub provider model strings are concrete IDs', () => {
|
||||
clearProviderFlags()
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
|
||||
const modelStrings = getModelStrings()
|
||||
|
||||
for (const value of Object.values(modelStrings)) {
|
||||
expect(typeof value).toBe('string')
|
||||
expect(value.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('GitHub provider model strings are safe to parse', () => {
|
||||
clearProviderFlags()
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
|
||||
const modelStrings = getModelStrings()
|
||||
|
||||
expect(() => parseUserSpecifiedModel(modelStrings.sonnet46 as any)).not.toThrow()
|
||||
})
|
||||
@@ -25,7 +25,7 @@ const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[]
|
||||
function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
|
||||
// Codex piggybacks on the OpenAI provider transport for Anthropic tier aliases.
|
||||
// Reuse OpenAI mappings so model string lookups never return undefined.
|
||||
const providerKey = provider === 'codex' ? 'openai' : provider
|
||||
const providerKey = provider === 'codex' || provider === 'github' ? 'openai' : provider
|
||||
const out = {} as ModelStrings
|
||||
for (const key of MODEL_KEYS) {
|
||||
out[key] = ALL_MODEL_CONFIGS[key][providerKey]
|
||||
|
||||
@@ -485,6 +485,26 @@ test('buildStartupEnvFromProfile leaves explicit provider selections untouched',
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile leaves profile-managed env untouched', async () => {
|
||||
const processEnv = {
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
}
|
||||
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('openai', {
|
||||
OPENAI_API_KEY: 'sk-persisted',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
processEnv,
|
||||
})
|
||||
|
||||
assert.equal(env, processEnv)
|
||||
assert.equal(env.ANTHROPIC_MODEL, 'claude-sonnet-4-6')
|
||||
assert.equal(env.OPENAI_MODEL, undefined)
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => {
|
||||
const processEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: '0',
|
||||
|
||||
@@ -407,6 +407,11 @@ export function deleteProfileFile(options?: ProfileFileLocation): string {
|
||||
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 (
|
||||
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
|
||||
processEnv.CLAUDE_CODE_USE_GITHUB !== undefined ||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { ProviderProfile } from './config.js'
|
||||
import { saveGlobalConfig, type ProviderProfile } from './config.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import {
|
||||
applyActiveProviderProfileFromConfig,
|
||||
applyProviderProfileToProcessEnv,
|
||||
deleteProviderProfile,
|
||||
getProviderProfiles,
|
||||
getProviderPresetDefaults,
|
||||
persistActiveProviderProfileModel,
|
||||
} from './providerProfiles.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
const RESTORED_KEYS = [
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_USE_GITHUB',
|
||||
@@ -22,7 +32,6 @@ const RESTORED_KEYS = [
|
||||
] as const
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
for (const key of RESTORED_KEYS) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
@@ -30,6 +39,14 @@ afterEach(() => {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [],
|
||||
activeProviderProfileId: undefined,
|
||||
openaiAdditionalModelOptionsCache: [],
|
||||
openaiAdditionalModelOptionsCacheByProfile: {},
|
||||
}))
|
||||
})
|
||||
|
||||
function buildProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
|
||||
@@ -43,57 +60,25 @@ function buildProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile
|
||||
}
|
||||
}
|
||||
|
||||
async function importFreshProviderModules() {
|
||||
mock.restore()
|
||||
let configState = {
|
||||
providerProfiles: [] as ProviderProfile[],
|
||||
activeProviderProfileId: undefined as string | undefined,
|
||||
openaiAdditionalModelOptionsCache: [] as any[],
|
||||
openaiAdditionalModelOptionsCacheByProfile: {} as Record<string, any[]>,
|
||||
}
|
||||
|
||||
mock.module('./config.js', () => ({
|
||||
getGlobalConfig: () => configState,
|
||||
saveGlobalConfig: (
|
||||
updater: (current: typeof configState) => typeof configState,
|
||||
) => {
|
||||
configState = updater(configState)
|
||||
},
|
||||
}))
|
||||
|
||||
const providerProfiles = await import(
|
||||
`./providerProfiles.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const providers = await import(
|
||||
`./model/providers.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
|
||||
return {
|
||||
...providerProfiles,
|
||||
...providers,
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyProviderProfileToProcessEnv', () => {
|
||||
test('openai profile clears competing gemini/github flags', async () => {
|
||||
test('openai profile clears competing gemini/github flags', () => {
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
const { applyProviderProfileToProcessEnv, getAPIProvider } =
|
||||
await importFreshProviderModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(buildProfile())
|
||||
|
||||
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||
'provider_test',
|
||||
)
|
||||
expect(getAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test('anthropic profile clears competing gemini/github flags', async () => {
|
||||
test('anthropic profile clears competing gemini/github flags', () => {
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
const { applyProviderProfileToProcessEnv, getAPIProvider } =
|
||||
await importFreshProviderModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
@@ -111,12 +96,10 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
})
|
||||
|
||||
describe('applyActiveProviderProfileFromConfig', () => {
|
||||
test('does not override explicit startup provider selection', async () => {
|
||||
test('does not override explicit startup provider selection', () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||
const { applyActiveProviderProfileFromConfig } =
|
||||
await importFreshProviderModules()
|
||||
|
||||
const applied = applyActiveProviderProfileFromConfig({
|
||||
providerProfiles: [
|
||||
@@ -134,13 +117,11 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
||||
})
|
||||
|
||||
test('does not override explicit startup selection when profile marker is stale', async () => {
|
||||
test('does not override explicit startup selection when profile marker is stale', () => {
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||
const { applyActiveProviderProfileFromConfig } =
|
||||
await importFreshProviderModules()
|
||||
|
||||
const applied = applyActiveProviderProfileFromConfig({
|
||||
providerProfiles: [
|
||||
@@ -159,7 +140,63 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
||||
})
|
||||
|
||||
test('applies active profile when no explicit provider is selected', async () => {
|
||||
test('re-applies active profile when profile-managed env drifts', () => {
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
id: 'saved_openai',
|
||||
baseUrl: 'http://192.168.33.108:11434/v1',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
}),
|
||||
)
|
||||
|
||||
// Simulate settings/env merge clobbering the model while profile flags remain.
|
||||
process.env.OPENAI_MODEL = 'github:copilot'
|
||||
|
||||
const applied = applyActiveProviderProfileFromConfig({
|
||||
providerProfiles: [
|
||||
buildProfile({
|
||||
id: 'saved_openai',
|
||||
baseUrl: 'http://192.168.33.108:11434/v1',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
}),
|
||||
],
|
||||
activeProviderProfileId: 'saved_openai',
|
||||
} as any)
|
||||
|
||||
expect(applied?.id).toBe('saved_openai')
|
||||
expect(process.env.OPENAI_MODEL).toBe('kimi-k2.5:cloud')
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('http://192.168.33.108:11434/v1')
|
||||
})
|
||||
|
||||
test('does not re-apply active profile when flags conflict with current provider', () => {
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
id: 'saved_openai',
|
||||
baseUrl: 'http://192.168.33.108:11434/v1',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
}),
|
||||
)
|
||||
|
||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||
process.env.OPENAI_MODEL = 'github:copilot'
|
||||
|
||||
const applied = applyActiveProviderProfileFromConfig({
|
||||
providerProfiles: [
|
||||
buildProfile({
|
||||
id: 'saved_openai',
|
||||
baseUrl: 'http://192.168.33.108:11434/v1',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
}),
|
||||
],
|
||||
activeProviderProfileId: 'saved_openai',
|
||||
} as any)
|
||||
|
||||
expect(applied).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBe('1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('github:copilot')
|
||||
})
|
||||
|
||||
test('applies active profile when no explicit provider is selected', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
@@ -169,8 +206,6 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
||||
|
||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||
const { applyActiveProviderProfileFromConfig } =
|
||||
await importFreshProviderModules()
|
||||
|
||||
const applied = applyActiveProviderProfileFromConfig({
|
||||
providerProfiles: [
|
||||
@@ -190,10 +225,66 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistActiveProviderProfileModel', () => {
|
||||
test('updates active profile model and current env for profile-managed sessions', () => {
|
||||
const activeProfile = buildProfile({
|
||||
id: 'saved_openai',
|
||||
baseUrl: 'http://192.168.33.108:11434/v1',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
})
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [activeProfile],
|
||||
activeProviderProfileId: activeProfile.id,
|
||||
}))
|
||||
applyProviderProfileToProcessEnv(activeProfile)
|
||||
|
||||
const updated = persistActiveProviderProfileModel('minimax-m2.5:cloud')
|
||||
|
||||
expect(updated?.id).toBe(activeProfile.id)
|
||||
expect(updated?.model).toBe('minimax-m2.5:cloud')
|
||||
expect(process.env.OPENAI_MODEL).toBe('minimax-m2.5:cloud')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||
activeProfile.id,
|
||||
)
|
||||
|
||||
const saved = getProviderProfiles().find(
|
||||
profile => profile.id === activeProfile.id,
|
||||
)
|
||||
expect(saved?.model).toBe('minimax-m2.5:cloud')
|
||||
})
|
||||
|
||||
test('does not mutate process env when session is not profile-managed', () => {
|
||||
const activeProfile = buildProfile({
|
||||
id: 'saved_openai',
|
||||
model: 'kimi-k2.5:cloud',
|
||||
})
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [activeProfile],
|
||||
activeProviderProfileId: activeProfile.id,
|
||||
}))
|
||||
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_MODEL = 'cli-model'
|
||||
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||
|
||||
persistActiveProviderProfileModel('minimax-m2.5:cloud')
|
||||
|
||||
expect(process.env.OPENAI_MODEL).toBe('cli-model')
|
||||
const saved = getProviderProfiles().find(
|
||||
profile => profile.id === activeProfile.id,
|
||||
)
|
||||
expect(saved?.model).toBe('minimax-m2.5:cloud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProviderPresetDefaults', () => {
|
||||
test('ollama preset defaults to a local Ollama model', async () => {
|
||||
test('ollama preset defaults to a local Ollama model', () => {
|
||||
delete process.env.OPENAI_MODEL
|
||||
const { getProviderPresetDefaults } = await importFreshProviderModules()
|
||||
|
||||
const defaults = getProviderPresetDefaults('ollama')
|
||||
|
||||
@@ -203,23 +294,23 @@ describe('getProviderPresetDefaults', () => {
|
||||
})
|
||||
|
||||
describe('deleteProviderProfile', () => {
|
||||
test('deleting final profile clears provider env when active profile applied it', async () => {
|
||||
const {
|
||||
addProviderProfile,
|
||||
deleteProviderProfile,
|
||||
} =
|
||||
await importFreshProviderModules()
|
||||
const profile = addProviderProfile({
|
||||
name: 'Only Profile',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: 'sk-test',
|
||||
})
|
||||
test('deleting final profile clears provider env when active profile applied it', () => {
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
id: 'only_profile',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: 'sk-test',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(profile).not.toBeNull()
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [buildProfile({ id: 'only_profile' })],
|
||||
activeProviderProfileId: 'only_profile',
|
||||
}))
|
||||
|
||||
const result = deleteProviderProfile(profile!.id)
|
||||
const result = deleteProviderProfile('only_profile')
|
||||
|
||||
expect(result.removed).toBe(true)
|
||||
expect(result.activeProfileId).toBeUndefined()
|
||||
@@ -243,25 +334,18 @@ describe('deleteProviderProfile', () => {
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test('deleting final profile preserves explicit startup provider env', async () => {
|
||||
const { addProviderProfile, deleteProviderProfile } =
|
||||
await importFreshProviderModules()
|
||||
const profile = addProviderProfile({
|
||||
name: 'Only Profile',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
})
|
||||
|
||||
expect(profile).not.toBeNull()
|
||||
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = undefined
|
||||
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||
test('deleting final profile preserves explicit startup provider env', () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||
|
||||
const result = deleteProviderProfile(profile!.id)
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [buildProfile({ id: 'only_profile' })],
|
||||
activeProviderProfileId: 'only_profile',
|
||||
}))
|
||||
|
||||
const result = deleteProviderProfile('only_profile')
|
||||
|
||||
expect(result.removed).toBe(true)
|
||||
expect(result.activeProfileId).toBeUndefined()
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ProviderPresetDefaults = Omit<ProviderProfileInput, 'provider'> & {
|
||||
const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434/v1'
|
||||
const DEFAULT_OLLAMA_MODEL = 'llama3.1:8b'
|
||||
const PROFILE_ENV_APPLIED_FLAG = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED'
|
||||
const PROFILE_ENV_APPLIED_ID = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID'
|
||||
|
||||
function trimValue(value: string | undefined): string {
|
||||
return value?.trim() ?? ''
|
||||
@@ -264,6 +265,23 @@ function hasProviderSelectionFlags(
|
||||
)
|
||||
}
|
||||
|
||||
function hasConflictingProviderFlagsForProfile(
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
profile: ProviderProfile,
|
||||
): boolean {
|
||||
if (profile.provider === 'anthropic') {
|
||||
return hasProviderSelectionFlags(processEnv)
|
||||
}
|
||||
|
||||
return (
|
||||
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined ||
|
||||
processEnv.CLAUDE_CODE_USE_GITHUB !== undefined ||
|
||||
processEnv.CLAUDE_CODE_USE_BEDROCK !== undefined ||
|
||||
processEnv.CLAUDE_CODE_USE_VERTEX !== undefined ||
|
||||
processEnv.CLAUDE_CODE_USE_FOUNDRY !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function sameOptionalEnvValue(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
@@ -284,6 +302,10 @@ function isProcessEnvAlignedWithProfile(
|
||||
return false
|
||||
}
|
||||
|
||||
if (trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) !== profile.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (profile.provider === 'anthropic') {
|
||||
return (
|
||||
!hasProviderSelectionFlags(processEnv) &&
|
||||
@@ -339,11 +361,13 @@ export function clearProviderProfileEnvFromProcessEnv(
|
||||
delete processEnv.ANTHROPIC_MODEL
|
||||
delete processEnv.ANTHROPIC_API_KEY
|
||||
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
|
||||
delete processEnv[PROFILE_ENV_APPLIED_ID]
|
||||
}
|
||||
|
||||
export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void {
|
||||
clearProviderProfileEnvFromProcessEnv()
|
||||
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
||||
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
||||
|
||||
process.env.ANTHROPIC_MODEL = profile.model
|
||||
if (profile.provider === 'anthropic') {
|
||||
@@ -386,12 +410,24 @@ export function applyActiveProviderProfileFromConfig(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isCurrentEnvProfileManaged =
|
||||
processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' &&
|
||||
trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id
|
||||
|
||||
if (!options?.force && hasProviderSelectionFlags(processEnv)) {
|
||||
// Respect explicit startup provider intent. Re-apply only when the
|
||||
// current process env is already profile-managed and aligned.
|
||||
if (!isProcessEnvAlignedWithProfile(processEnv, activeProfile)) {
|
||||
// Respect explicit startup provider intent. Auto-heal only when this
|
||||
// exact active profile previously applied the current env.
|
||||
if (!isCurrentEnvProfileManaged) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (hasConflictingProviderFlagsForProfile(processEnv, activeProfile)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isProcessEnvAlignedWithProfile(processEnv, activeProfile)) {
|
||||
return activeProfile
|
||||
}
|
||||
}
|
||||
|
||||
applyProviderProfileToProcessEnv(activeProfile)
|
||||
@@ -496,6 +532,61 @@ export function updateProviderProfile(
|
||||
return updatedProfile
|
||||
}
|
||||
|
||||
export function persistActiveProviderProfileModel(
|
||||
model: string,
|
||||
): ProviderProfile | null {
|
||||
const nextModel = trimOrUndefined(model)
|
||||
if (!nextModel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeProfile = getActiveProviderProfile()
|
||||
if (!activeProfile) {
|
||||
return null
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => {
|
||||
const currentProfiles = getProviderProfiles(current)
|
||||
const profileIndex = currentProfiles.findIndex(
|
||||
profile => profile.id === activeProfile.id,
|
||||
)
|
||||
|
||||
if (profileIndex < 0) {
|
||||
return current
|
||||
}
|
||||
|
||||
const currentProfile = currentProfiles[profileIndex]
|
||||
if (currentProfile.model === nextModel) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextProfiles = [...currentProfiles]
|
||||
nextProfiles[profileIndex] = {
|
||||
...currentProfile,
|
||||
model: nextModel,
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
providerProfiles: nextProfiles,
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedProfile = getActiveProviderProfile()
|
||||
if (!resolvedProfile || resolvedProfile.id !== activeProfile.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
process.env[PROFILE_ENV_APPLIED_FLAG] === '1' &&
|
||||
trimOrUndefined(process.env[PROFILE_ENV_APPLIED_ID]) === resolvedProfile.id
|
||||
) {
|
||||
applyProviderProfileToProcessEnv(resolvedProfile)
|
||||
}
|
||||
|
||||
return resolvedProfile
|
||||
}
|
||||
|
||||
export function setActiveProviderProfile(
|
||||
profileId: string,
|
||||
): ProviderProfile | null {
|
||||
|
||||
Reference in New Issue
Block a user