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:
@@ -2,6 +2,7 @@ import type { Command } from '../../commands.js'
|
|||||||
|
|
||||||
const onboardGithub: Command = {
|
const onboardGithub: Command = {
|
||||||
name: 'onboard-github',
|
name: 'onboard-github',
|
||||||
|
aliases: ['onboarding-github', 'onboardgithub', 'onboardinggithub'],
|
||||||
description:
|
description:
|
||||||
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
|
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
|
||||||
type: 'local-jsx',
|
type: 'local-jsx',
|
||||||
|
|||||||
148
src/commands/onboard-github/onboard-github.test.ts
Normal file
148
src/commands/onboard-github/onboard-github.test.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
activateGithubOnboardingMode,
|
||||||
|
applyGithubOnboardingProcessEnv,
|
||||||
|
buildGithubOnboardingSettingsEnv,
|
||||||
|
hasExistingGithubModelsLoginToken,
|
||||||
|
shouldForceGithubRelogin,
|
||||||
|
} from './onboard-github.js'
|
||||||
|
|
||||||
|
describe('shouldForceGithubRelogin', () => {
|
||||||
|
test.each(['force', '--force', 'relogin', '--relogin', 'reauth', '--reauth'])(
|
||||||
|
'treats %s as force re-login',
|
||||||
|
arg => {
|
||||||
|
expect(shouldForceGithubRelogin(arg)).toBe(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test('returns false for empty or unknown args', () => {
|
||||||
|
expect(shouldForceGithubRelogin('')).toBe(false)
|
||||||
|
expect(shouldForceGithubRelogin(undefined)).toBe(false)
|
||||||
|
expect(shouldForceGithubRelogin('something-else')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('treats force flags as present in multi-word args', () => {
|
||||||
|
expect(shouldForceGithubRelogin('--force extra')).toBe(true)
|
||||||
|
expect(shouldForceGithubRelogin('foo --relogin bar')).toBe(true)
|
||||||
|
expect(shouldForceGithubRelogin('abc reauth xyz')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasExistingGithubModelsLoginToken', () => {
|
||||||
|
test('returns true when GITHUB_TOKEN is present', () => {
|
||||||
|
expect(
|
||||||
|
hasExistingGithubModelsLoginToken({ GITHUB_TOKEN: 'token' }, ''),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true when GH_TOKEN is present', () => {
|
||||||
|
expect(
|
||||||
|
hasExistingGithubModelsLoginToken({ GH_TOKEN: 'token' }, ''),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true when stored token exists', () => {
|
||||||
|
expect(hasExistingGithubModelsLoginToken({}, 'stored-token')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when both env and stored token are missing', () => {
|
||||||
|
expect(hasExistingGithubModelsLoginToken({}, '')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onboarding auth precedence cleanup', () => {
|
||||||
|
test('clears preexisting OpenAI auth when switching to GitHub', () => {
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: '1',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
OPENAI_API_KEY: 'sk-stale-openai-key',
|
||||||
|
OPENAI_ORG: 'org-old',
|
||||||
|
OPENAI_PROJECT: 'project-old',
|
||||||
|
OPENAI_ORGANIZATION: 'org-legacy',
|
||||||
|
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
OPENAI_API_BASE: 'https://api.openai.com/v1',
|
||||||
|
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
||||||
|
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'profile_old',
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGithubOnboardingProcessEnv('github:copilot', env)
|
||||||
|
|
||||||
|
expect(env.CLAUDE_CODE_USE_GITHUB).toBe('1')
|
||||||
|
expect(env.OPENAI_MODEL).toBe('github:copilot')
|
||||||
|
|
||||||
|
expect(env.OPENAI_API_KEY).toBeUndefined()
|
||||||
|
expect(env.OPENAI_ORG).toBeUndefined()
|
||||||
|
expect(env.OPENAI_PROJECT).toBeUndefined()
|
||||||
|
expect(env.OPENAI_ORGANIZATION).toBeUndefined()
|
||||||
|
expect(env.OPENAI_BASE_URL).toBeUndefined()
|
||||||
|
expect(env.OPENAI_API_BASE).toBeUndefined()
|
||||||
|
|
||||||
|
expect(env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
|
expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
|
||||||
|
expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
|
||||||
|
|
||||||
|
const settingsEnv = buildGithubOnboardingSettingsEnv('github:copilot')
|
||||||
|
expect(settingsEnv.CLAUDE_CODE_USE_GITHUB).toBe('1')
|
||||||
|
expect(settingsEnv.OPENAI_MODEL).toBe('github:copilot')
|
||||||
|
expect(settingsEnv.OPENAI_API_KEY).toBeUndefined()
|
||||||
|
expect(settingsEnv.OPENAI_ORG).toBeUndefined()
|
||||||
|
expect(settingsEnv.OPENAI_PROJECT).toBeUndefined()
|
||||||
|
expect(settingsEnv.OPENAI_ORGANIZATION).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('activateGithubOnboardingMode', () => {
|
||||||
|
test('activates settings/env/hydration in order when merge succeeds', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
const result = activateGithubOnboardingMode(' github:copilot ', {
|
||||||
|
mergeSettingsEnv: model => {
|
||||||
|
calls.push(`merge:${model}`)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
applyProcessEnv: model => {
|
||||||
|
calls.push(`apply:${model}`)
|
||||||
|
},
|
||||||
|
hydrateToken: () => {
|
||||||
|
calls.push('hydrate')
|
||||||
|
},
|
||||||
|
onChangeAPIKey: () => {
|
||||||
|
calls.push('onChangeAPIKey')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true })
|
||||||
|
expect(calls).toEqual([
|
||||||
|
'merge:github:copilot',
|
||||||
|
'apply:github:copilot',
|
||||||
|
'hydrate',
|
||||||
|
'onChangeAPIKey',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stops activation when settings merge fails', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
const result = activateGithubOnboardingMode(DEFAULT_MODEL_FOR_TESTS, {
|
||||||
|
mergeSettingsEnv: () => {
|
||||||
|
calls.push('merge')
|
||||||
|
return { ok: false, detail: 'settings write failed' }
|
||||||
|
},
|
||||||
|
applyProcessEnv: () => {
|
||||||
|
calls.push('apply')
|
||||||
|
},
|
||||||
|
hydrateToken: () => {
|
||||||
|
calls.push('hydrate')
|
||||||
|
},
|
||||||
|
onChangeAPIKey: () => {
|
||||||
|
calls.push('onChangeAPIKey')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, detail: 'settings write failed' })
|
||||||
|
expect(calls).toEqual(['merge'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const DEFAULT_MODEL_FOR_TESTS = 'github:copilot'
|
||||||
@@ -12,11 +12,20 @@ import {
|
|||||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||||
import {
|
import {
|
||||||
hydrateGithubModelsTokenFromSecureStorage,
|
hydrateGithubModelsTokenFromSecureStorage,
|
||||||
|
readGithubModelsToken,
|
||||||
saveGithubModelsToken,
|
saveGithubModelsToken,
|
||||||
} from '../../utils/githubModelsCredentials.js'
|
} from '../../utils/githubModelsCredentials.js'
|
||||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'github:copilot'
|
const DEFAULT_MODEL = 'github:copilot'
|
||||||
|
const FORCE_RELOGIN_ARGS = new Set([
|
||||||
|
'force',
|
||||||
|
'--force',
|
||||||
|
'relogin',
|
||||||
|
'--relogin',
|
||||||
|
'reauth',
|
||||||
|
'--reauth',
|
||||||
|
])
|
||||||
|
|
||||||
type Step =
|
type Step =
|
||||||
| 'menu'
|
| 'menu'
|
||||||
@@ -24,17 +33,72 @@ type Step =
|
|||||||
| 'pat'
|
| 'pat'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
|
||||||
function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
|
export function shouldForceGithubRelogin(args?: string): boolean {
|
||||||
const { error } = updateSettingsForSource('userSettings', {
|
const normalized = (args ?? '').trim().toLowerCase()
|
||||||
env: {
|
if (!normalized) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).some(arg => FORCE_RELOGIN_ARGS.has(arg))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExistingGithubModelsLoginToken(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
storedToken?: string,
|
||||||
|
): boolean {
|
||||||
|
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()
|
||||||
|
if (envToken) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const persisted = (storedToken ?? readGithubModelsToken())?.trim()
|
||||||
|
return Boolean(persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGithubOnboardingSettingsEnv(
|
||||||
|
model: string,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
return {
|
||||||
CLAUDE_CODE_USE_GITHUB: '1',
|
CLAUDE_CODE_USE_GITHUB: '1',
|
||||||
OPENAI_MODEL: model,
|
OPENAI_MODEL: model,
|
||||||
CLAUDE_CODE_USE_OPENAI: undefined as any,
|
OPENAI_API_KEY: undefined,
|
||||||
CLAUDE_CODE_USE_GEMINI: undefined as any,
|
OPENAI_ORG: undefined,
|
||||||
CLAUDE_CODE_USE_BEDROCK: undefined as any,
|
OPENAI_PROJECT: undefined,
|
||||||
CLAUDE_CODE_USE_VERTEX: undefined as any,
|
OPENAI_ORGANIZATION: undefined,
|
||||||
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
|
OPENAI_BASE_URL: undefined,
|
||||||
},
|
OPENAI_API_BASE: undefined,
|
||||||
|
CLAUDE_CODE_USE_OPENAI: undefined,
|
||||||
|
CLAUDE_CODE_USE_GEMINI: undefined,
|
||||||
|
CLAUDE_CODE_USE_BEDROCK: undefined,
|
||||||
|
CLAUDE_CODE_USE_VERTEX: undefined,
|
||||||
|
CLAUDE_CODE_USE_FOUNDRY: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGithubOnboardingProcessEnv(
|
||||||
|
model: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
env.OPENAI_MODEL = model
|
||||||
|
|
||||||
|
delete env.OPENAI_API_KEY
|
||||||
|
delete env.OPENAI_ORG
|
||||||
|
delete env.OPENAI_PROJECT
|
||||||
|
delete env.OPENAI_ORGANIZATION
|
||||||
|
delete env.OPENAI_BASE_URL
|
||||||
|
delete env.OPENAI_API_BASE
|
||||||
|
|
||||||
|
delete env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
delete env.CLAUDE_CODE_USE_GEMINI
|
||||||
|
delete env.CLAUDE_CODE_USE_BEDROCK
|
||||||
|
delete env.CLAUDE_CODE_USE_VERTEX
|
||||||
|
delete env.CLAUDE_CODE_USE_FOUNDRY
|
||||||
|
delete env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||||
|
delete env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
env: buildGithubOnboardingSettingsEnv(model) as any,
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
return { ok: false, detail: error.message }
|
return { ok: false, detail: error.message }
|
||||||
@@ -42,6 +106,32 @@ function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function activateGithubOnboardingMode(
|
||||||
|
model: string = DEFAULT_MODEL,
|
||||||
|
options?: {
|
||||||
|
mergeSettingsEnv?: (model: string) => { ok: boolean; detail?: string }
|
||||||
|
applyProcessEnv?: (model: string) => void
|
||||||
|
hydrateToken?: () => void
|
||||||
|
onChangeAPIKey?: () => void
|
||||||
|
},
|
||||||
|
): { ok: boolean; detail?: string } {
|
||||||
|
const normalizedModel = model.trim() || DEFAULT_MODEL
|
||||||
|
const mergeSettingsEnv = options?.mergeSettingsEnv ?? mergeUserSettingsEnv
|
||||||
|
const applyProcessEnv = options?.applyProcessEnv ?? applyGithubOnboardingProcessEnv
|
||||||
|
const hydrateToken =
|
||||||
|
options?.hydrateToken ?? hydrateGithubModelsTokenFromSecureStorage
|
||||||
|
|
||||||
|
const merged = mergeSettingsEnv(normalizedModel)
|
||||||
|
if (!merged.ok) {
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
applyProcessEnv(normalizedModel)
|
||||||
|
hydrateToken()
|
||||||
|
options?.onChangeAPIKey?.()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
function OnboardGithub(props: {
|
function OnboardGithub(props: {
|
||||||
onDone: Parameters<LocalJSXCommandCall>[0]
|
onDone: Parameters<LocalJSXCommandCall>[0]
|
||||||
onChangeAPIKey: () => void
|
onChangeAPIKey: () => void
|
||||||
@@ -64,19 +154,17 @@ function OnboardGithub(props: {
|
|||||||
setStep('error')
|
setStep('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const merged = mergeUserSettingsEnv(model.trim() || DEFAULT_MODEL)
|
const activated = activateGithubOnboardingMode(model, {
|
||||||
if (!merged.ok) {
|
onChangeAPIKey,
|
||||||
|
})
|
||||||
|
if (!activated.ok) {
|
||||||
setErrorMsg(
|
setErrorMsg(
|
||||||
`Token saved, but settings were not updated: ${merged.detail ?? 'unknown error'}. ` +
|
`Token saved, but settings were not updated: ${activated.detail ?? 'unknown error'}. ` +
|
||||||
`Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`,
|
`Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`,
|
||||||
)
|
)
|
||||||
setStep('error')
|
setStep('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
|
||||||
process.env.OPENAI_MODEL = model.trim() || DEFAULT_MODEL
|
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
|
||||||
onChangeAPIKey()
|
|
||||||
onDone(
|
onDone(
|
||||||
'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
|
'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
|
||||||
{ display: 'user' },
|
{ display: 'user' },
|
||||||
@@ -147,11 +235,11 @@ function OnboardGithub(props: {
|
|||||||
{deviceHint.verification_uri}
|
{deviceHint.verification_uri}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
A browser window may have opened. Waiting for authorization…
|
A browser window may have opened. Waiting for authorization...
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text dimColor>Requesting device code from GitHub…</Text>
|
<Text dimColor>Requesting device code from GitHub...</Text>
|
||||||
)}
|
)}
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -206,7 +294,7 @@ function OnboardGithub(props: {
|
|||||||
<Text bold>GitHub Models setup</Text>
|
<Text bold>GitHub Models setup</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Stores your token in the OS credential store (macOS Keychain when available)
|
Stores your token in the OS credential store (macOS Keychain when available)
|
||||||
and enables CLAUDE_CODE_USE_GITHUB in your user settings — no export
|
and enables CLAUDE_CODE_USE_GITHUB in your user settings - no export
|
||||||
GITHUB_TOKEN needed for future runs.
|
GITHUB_TOKEN needed for future runs.
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
@@ -227,7 +315,28 @@ function OnboardGithub(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||||
|
const forceRelogin = shouldForceGithubRelogin(args)
|
||||||
|
if (hasExistingGithubModelsLoginToken() && !forceRelogin) {
|
||||||
|
const activated = activateGithubOnboardingMode(DEFAULT_MODEL, {
|
||||||
|
onChangeAPIKey: context.onChangeAPIKey,
|
||||||
|
})
|
||||||
|
if (!activated.ok) {
|
||||||
|
onDone(
|
||||||
|
`GitHub token detected, but settings activation failed: ${activated.detail ?? 'unknown error'}. ` +
|
||||||
|
'Set CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL=github:copilot in user settings manually.',
|
||||||
|
{ display: 'system' },
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
onDone(
|
||||||
|
'GitHub Models already authorized. Activated GitHub Models mode using your existing token. Use /onboard-github --force to re-authenticate.',
|
||||||
|
{ display: 'user' },
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardGithub
|
<OnboardGithub
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
|
|||||||
@@ -275,6 +275,21 @@ test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Co
|
|||||||
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
|
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('buildCurrentProviderSummary recognizes GitHub Models mode', () => {
|
||||||
|
const summary = buildCurrentProviderSummary({
|
||||||
|
processEnv: {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: '1',
|
||||||
|
OPENAI_MODEL: 'github:copilot',
|
||||||
|
OPENAI_BASE_URL: 'https://models.github.ai/inference',
|
||||||
|
},
|
||||||
|
persisted: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.providerLabel).toBe('GitHub Models')
|
||||||
|
expect(summary.modelLabel).toBe('github:copilot')
|
||||||
|
expect(summary.endpointLabel).toBe('https://models.github.ai/inference')
|
||||||
|
})
|
||||||
|
|
||||||
test('getProviderWizardDefaults ignores poisoned current provider values', () => {
|
test('getProviderWizardDefaults ignores poisoned current provider values', () => {
|
||||||
const defaults = getProviderWizardDefaults({
|
const defaults = getProviderWizardDefaults({
|
||||||
OPENAI_API_KEY: 'sk-secret-12345678',
|
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||||
|
|||||||
@@ -178,6 +178,23 @@ export function buildCurrentProviderSummary(options?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
return {
|
||||||
|
providerLabel: 'GitHub Models',
|
||||||
|
modelLabel: getSafeDisplayValue(
|
||||||
|
processEnv.OPENAI_MODEL ?? 'github:copilot',
|
||||||
|
processEnv,
|
||||||
|
),
|
||||||
|
endpointLabel: getSafeDisplayValue(
|
||||||
|
processEnv.OPENAI_BASE_URL ??
|
||||||
|
processEnv.OPENAI_API_BASE ??
|
||||||
|
'https://models.github.ai/inference',
|
||||||
|
processEnv,
|
||||||
|
),
|
||||||
|
savedProfileLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) {
|
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) {
|
||||||
const request = resolveProviderRequest({
|
const request = resolveProviderRequest({
|
||||||
model: processEnv.OPENAI_MODEL,
|
model: processEnv.OPENAI_MODEL,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useKeybinding } from '../keybindings/useKeybinding.js'
|
|||||||
import type { ProviderProfile } from '../utils/config.js'
|
import type { ProviderProfile } from '../utils/config.js'
|
||||||
import {
|
import {
|
||||||
addProviderProfile,
|
addProviderProfile,
|
||||||
|
applyActiveProviderProfileFromConfig,
|
||||||
deleteProviderProfile,
|
deleteProviderProfile,
|
||||||
getActiveProviderProfile,
|
getActiveProviderProfile,
|
||||||
getProviderPresetDefaults,
|
getProviderPresetDefaults,
|
||||||
@@ -14,6 +15,14 @@ import {
|
|||||||
type ProviderProfileInput,
|
type ProviderProfileInput,
|
||||||
updateProviderProfile,
|
updateProviderProfile,
|
||||||
} from '../utils/providerProfiles.js'
|
} from '../utils/providerProfiles.js'
|
||||||
|
import {
|
||||||
|
clearGithubModelsToken,
|
||||||
|
GITHUB_MODELS_HYDRATED_ENV_MARKER,
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage,
|
||||||
|
readGithubModelsToken,
|
||||||
|
} from '../utils/githubModelsCredentials.js'
|
||||||
|
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||||
|
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||||
import { Select } from './CustomSelect/index.js'
|
import { Select } from './CustomSelect/index.js'
|
||||||
import { Pane } from './design-system/Pane.js'
|
import { Pane } from './design-system/Pane.js'
|
||||||
import TextInput from './TextInput.js'
|
import TextInput from './TextInput.js'
|
||||||
@@ -75,6 +84,13 @@ const FORM_STEPS: Array<{
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const GITHUB_PROVIDER_ID = '__github_models__'
|
||||||
|
const GITHUB_PROVIDER_LABEL = 'GitHub Models'
|
||||||
|
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
|
||||||
|
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
|
||||||
|
|
||||||
|
type GithubCredentialSource = 'stored' | 'env' | 'none'
|
||||||
|
|
||||||
function toDraft(profile: ProviderProfile): ProviderDraft {
|
function toDraft(profile: ProviderProfile): ProviderDraft {
|
||||||
return {
|
return {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
@@ -102,11 +118,65 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
|||||||
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGithubCredentialSource(
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): GithubCredentialSource {
|
||||||
|
if (readGithubModelsToken()?.trim()) {
|
||||||
|
return 'stored'
|
||||||
|
}
|
||||||
|
if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) {
|
||||||
|
return 'env'
|
||||||
|
}
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGithubProviderAvailable(
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): boolean {
|
||||||
|
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return getGithubCredentialSource(processEnv) !== 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGithubProviderModel(
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
|
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
return processEnv.OPENAI_MODEL?.trim() || GITHUB_PROVIDER_DEFAULT_MODEL
|
||||||
|
}
|
||||||
|
return GITHUB_PROVIDER_DEFAULT_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGithubProviderSummary(
|
||||||
|
isActive: boolean,
|
||||||
|
credentialSource: GithubCredentialSource,
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
|
const credentialSummary =
|
||||||
|
credentialSource === 'stored'
|
||||||
|
? 'token stored'
|
||||||
|
: credentialSource === 'env'
|
||||||
|
? 'token via env'
|
||||||
|
: 'no token found'
|
||||||
|
const activeSuffix = isActive ? ' (active)' : ''
|
||||||
|
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||||
const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
|
const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
|
||||||
const [activeProfileId, setActiveProfileId] = React.useState(
|
const [activeProfileId, setActiveProfileId] = React.useState(
|
||||||
() => getActiveProviderProfile()?.id,
|
() => getActiveProviderProfile()?.id,
|
||||||
)
|
)
|
||||||
|
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(() =>
|
||||||
|
isGithubProviderAvailable(),
|
||||||
|
)
|
||||||
|
const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>(
|
||||||
|
() => getGithubCredentialSource(),
|
||||||
|
)
|
||||||
|
const [isGithubActive, setIsGithubActive] = React.useState(() =>
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB),
|
||||||
|
)
|
||||||
const [screen, setScreen] = React.useState<Screen>(
|
const [screen, setScreen] = React.useState<Screen>(
|
||||||
mode === 'first-run' ? 'select-preset' : 'menu',
|
mode === 'first-run' ? 'select-preset' : 'menu',
|
||||||
)
|
)
|
||||||
@@ -130,12 +200,116 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
const nextProfiles = getProviderProfiles()
|
const nextProfiles = getProviderProfiles()
|
||||||
setProfiles(nextProfiles)
|
setProfiles(nextProfiles)
|
||||||
setActiveProfileId(getActiveProviderProfile()?.id)
|
setActiveProfileId(getActiveProviderProfile()?.id)
|
||||||
|
setGithubProviderAvailable(isGithubProviderAvailable())
|
||||||
|
setGithubCredentialSource(getGithubCredentialSource())
|
||||||
|
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStartupProviderOverrideFromUserSettings(): string | null {
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
env: {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_GEMINI: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_GITHUB: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_BEDROCK: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_VERTEX: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return error ? error.message : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWithCancelled(message: string): void {
|
function closeWithCancelled(message: string): void {
|
||||||
onDone({ action: 'cancelled', message })
|
onDone({ action: 'cancelled', message })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activateGithubProvider(): string | null {
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
env: {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: '1',
|
||||||
|
OPENAI_MODEL: GITHUB_PROVIDER_DEFAULT_MODEL,
|
||||||
|
OPENAI_API_KEY: undefined as any,
|
||||||
|
OPENAI_ORG: undefined as any,
|
||||||
|
OPENAI_PROJECT: undefined as any,
|
||||||
|
OPENAI_ORGANIZATION: undefined as any,
|
||||||
|
OPENAI_BASE_URL: undefined as any,
|
||||||
|
OPENAI_API_BASE: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_OPENAI: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_GEMINI: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_BEDROCK: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_VERTEX: undefined as any,
|
||||||
|
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
process.env.OPENAI_MODEL = GITHUB_PROVIDER_DEFAULT_MODEL
|
||||||
|
delete process.env.OPENAI_API_KEY
|
||||||
|
delete process.env.OPENAI_ORG
|
||||||
|
delete process.env.OPENAI_PROJECT
|
||||||
|
delete process.env.OPENAI_ORGANIZATION
|
||||||
|
delete process.env.OPENAI_BASE_URL
|
||||||
|
delete process.env.OPENAI_API_BASE
|
||||||
|
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
|
||||||
|
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||||
|
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||||
|
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||||
|
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGithubProvider(): string | null {
|
||||||
|
const storedTokenBeforeClear = readGithubModelsToken()?.trim()
|
||||||
|
const cleared = clearGithubModelsToken()
|
||||||
|
if (!cleared.success) {
|
||||||
|
return cleared.warning ?? 'Could not clear GitHub credentials.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
env: {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: undefined as any,
|
||||||
|
OPENAI_MODEL: undefined as any,
|
||||||
|
OPENAI_BASE_URL: undefined as any,
|
||||||
|
OPENAI_API_BASE: undefined as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydratedTokenInSession = process.env.GITHUB_TOKEN?.trim()
|
||||||
|
if (
|
||||||
|
process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER] === '1' &&
|
||||||
|
hydratedTokenInSession &&
|
||||||
|
(!storedTokenBeforeClear || hydratedTokenInSession === storedTokenBeforeClear)
|
||||||
|
) {
|
||||||
|
delete process.env.GITHUB_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.OPENAI_API_KEY
|
||||||
|
delete process.env.OPENAI_ORG
|
||||||
|
delete process.env.OPENAI_PROJECT
|
||||||
|
delete process.env.OPENAI_ORGANIZATION
|
||||||
|
delete process.env.OPENAI_BASE_URL
|
||||||
|
delete process.env.OPENAI_API_BASE
|
||||||
|
|
||||||
|
// Restore active provider profile immediately when one exists.
|
||||||
|
applyActiveProviderProfileFromConfig()
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function startCreateFromPreset(preset: ProviderPreset): void {
|
function startCreateFromPreset(preset: ProviderPreset): void {
|
||||||
const defaults = getProviderPresetDefaults(preset)
|
const defaults = getProviderPresetDefaults(preset)
|
||||||
const nextDraft = {
|
const nextDraft = {
|
||||||
@@ -187,11 +361,20 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id
|
||||||
|
const settingsOverrideError = isActiveSavedProfile
|
||||||
|
? clearStartupProviderOverrideFromUserSettings()
|
||||||
|
: null
|
||||||
|
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage(
|
const successMessage =
|
||||||
editingProfileId
|
editingProfileId
|
||||||
? `Updated provider: ${saved.name}`
|
? `Updated provider: ${saved.name}`
|
||||||
: `Added provider: ${saved.name} (now active)`,
|
: `Added provider: ${saved.name} (now active)`
|
||||||
|
setStatusMessage(
|
||||||
|
settingsOverrideError
|
||||||
|
? `${successMessage}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||||
|
: successMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (mode === 'first-run') {
|
if (mode === 'first-run') {
|
||||||
@@ -413,6 +596,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
|
|
||||||
function renderMenu(): React.ReactNode {
|
function renderMenu(): React.ReactNode {
|
||||||
const hasProfiles = profiles.length > 0
|
const hasProfiles = profiles.length > 0
|
||||||
|
const hasSelectableProviders = hasProfiles || githubProviderAvailable
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
@@ -424,7 +608,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
value: 'activate',
|
value: 'activate',
|
||||||
label: 'Set active provider',
|
label: 'Set active provider',
|
||||||
description: 'Switch the active provider profile',
|
description: 'Switch the active provider profile',
|
||||||
disabled: !hasProfiles,
|
disabled: !hasSelectableProviders,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'edit',
|
value: 'edit',
|
||||||
@@ -436,7 +620,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
value: 'delete',
|
value: 'delete',
|
||||||
label: 'Delete provider',
|
label: 'Delete provider',
|
||||||
description: 'Remove a provider profile',
|
description: 'Remove a provider profile',
|
||||||
disabled: !hasProfiles,
|
disabled: !hasSelectableProviders,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'done',
|
value: 'done',
|
||||||
@@ -455,14 +639,25 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
</Text>
|
</Text>
|
||||||
{statusMessage && <Text>{statusMessage}</Text>}
|
{statusMessage && <Text>{statusMessage}</Text>}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{profiles.length === 0 ? (
|
{profiles.length === 0 && !githubProviderAvailable ? (
|
||||||
<Text dimColor>No provider profiles configured yet.</Text>
|
<Text dimColor>No provider profiles configured yet.</Text>
|
||||||
) : (
|
) : (
|
||||||
profiles.map(profile => (
|
<>
|
||||||
|
{profiles.map(profile => (
|
||||||
<Text key={profile.id} dimColor>
|
<Text key={profile.id} dimColor>
|
||||||
- {profile.name}: {profileSummary(profile, profile.id === activeProfileId)}
|
- {profile.name}: {profileSummary(profile, profile.id === activeProfileId)}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
))}
|
||||||
|
{githubProviderAvailable ? (
|
||||||
|
<Text dimColor>
|
||||||
|
- {GITHUB_PROVIDER_LABEL}:{' '}
|
||||||
|
{getGithubProviderSummary(
|
||||||
|
isGithubActive,
|
||||||
|
githubCredentialSource,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Select
|
<Select
|
||||||
@@ -474,7 +669,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
setScreen('select-preset')
|
setScreen('select-preset')
|
||||||
break
|
break
|
||||||
case 'activate':
|
case 'activate':
|
||||||
if (profiles.length > 0) {
|
if (hasSelectableProviders) {
|
||||||
setScreen('select-active')
|
setScreen('select-active')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -484,7 +679,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'delete':
|
case 'delete':
|
||||||
if (profiles.length > 0) {
|
if (hasSelectableProviders) {
|
||||||
setScreen('select-delete')
|
setScreen('select-delete')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -504,8 +699,29 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
title: string,
|
title: string,
|
||||||
emptyMessage: string,
|
emptyMessage: string,
|
||||||
onSelect: (profileId: string) => void,
|
onSelect: (profileId: string) => void,
|
||||||
|
options?: { includeGithub?: boolean },
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
if (profiles.length === 0) {
|
const includeGithub = options?.includeGithub ?? false
|
||||||
|
const selectOptions = profiles.map(profile => ({
|
||||||
|
value: profile.id,
|
||||||
|
label:
|
||||||
|
profile.id === activeProfileId
|
||||||
|
? `${profile.name} (active)`
|
||||||
|
: profile.name,
|
||||||
|
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (includeGithub && githubProviderAvailable) {
|
||||||
|
selectOptions.push({
|
||||||
|
value: GITHUB_PROVIDER_ID,
|
||||||
|
label: isGithubActive
|
||||||
|
? `${GITHUB_PROVIDER_LABEL} (active)`
|
||||||
|
: GITHUB_PROVIDER_LABEL,
|
||||||
|
description: `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel()}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectOptions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
<Text color="remember" bold>
|
<Text color="remember" bold>
|
||||||
@@ -528,25 +744,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = profiles.map(profile => ({
|
|
||||||
value: profile.id,
|
|
||||||
label:
|
|
||||||
profile.id === activeProfileId
|
|
||||||
? `${profile.name} (active)`
|
|
||||||
: profile.name,
|
|
||||||
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
<Text color="remember" bold>
|
<Text color="remember" bold>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
options={options}
|
options={selectOptions}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
onCancel={() => setScreen('menu')}
|
onCancel={() => setScreen('menu')}
|
||||||
visibleOptionCount={Math.min(10, Math.max(2, options.length))}
|
visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -566,16 +773,36 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
'Set active provider',
|
'Set active provider',
|
||||||
'No providers available. Add one first.',
|
'No providers available. Add one first.',
|
||||||
profileId => {
|
profileId => {
|
||||||
|
if (profileId === GITHUB_PROVIDER_ID) {
|
||||||
|
const githubError = activateGithubProvider()
|
||||||
|
if (githubError) {
|
||||||
|
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
||||||
|
setScreen('menu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshProfiles()
|
||||||
|
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||||
|
setScreen('menu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const active = setActiveProviderProfile(profileId)
|
const active = setActiveProviderProfile(profileId)
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setErrorMessage('Could not change active provider.')
|
setErrorMessage('Could not change active provider.')
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const settingsOverrideError =
|
||||||
|
clearStartupProviderOverrideFromUserSettings()
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage(`Active provider: ${active.name}`)
|
setStatusMessage(
|
||||||
|
settingsOverrideError
|
||||||
|
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||||
|
: `Active provider: ${active.name}`,
|
||||||
|
)
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
},
|
},
|
||||||
|
{ includeGithub: true },
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'select-edit':
|
case 'select-edit':
|
||||||
@@ -592,15 +819,35 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
'Delete provider',
|
'Delete provider',
|
||||||
'No providers available. Add one first.',
|
'No providers available. Add one first.',
|
||||||
profileId => {
|
profileId => {
|
||||||
|
if (profileId === GITHUB_PROVIDER_ID) {
|
||||||
|
const githubDeleteError = deleteGithubProvider()
|
||||||
|
if (githubDeleteError) {
|
||||||
|
setErrorMessage(`Could not delete GitHub provider: ${githubDeleteError}`)
|
||||||
|
} else {
|
||||||
|
refreshProfiles()
|
||||||
|
setStatusMessage('GitHub provider deleted')
|
||||||
|
}
|
||||||
|
setScreen('menu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = deleteProviderProfile(profileId)
|
const result = deleteProviderProfile(profileId)
|
||||||
if (!result.removed) {
|
if (!result.removed) {
|
||||||
setErrorMessage('Could not delete provider.')
|
setErrorMessage('Could not delete provider.')
|
||||||
} else {
|
} else {
|
||||||
|
const settingsOverrideError = result.activeProfileId
|
||||||
|
? clearStartupProviderOverrideFromUserSettings()
|
||||||
|
: null
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage('Provider deleted')
|
setStatusMessage(
|
||||||
|
settingsOverrideError
|
||||||
|
? `Provider deleted. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||||
|
: 'Provider deleted',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
},
|
},
|
||||||
|
{ includeGithub: true },
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'menu':
|
case 'menu':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_GITHUB_DEVICE_SCOPE,
|
||||||
GitHubDeviceFlowError,
|
GitHubDeviceFlowError,
|
||||||
pollAccessToken,
|
pollAccessToken,
|
||||||
requestDeviceCode,
|
requestDeviceCode,
|
||||||
@@ -48,6 +49,81 @@ describe('requestDeviceCode', () => {
|
|||||||
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
|
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
|
||||||
).rejects.toThrow(GitHubDeviceFlowError)
|
).rejects.toThrow(GitHubDeviceFlowError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses OAuth-safe default scope', async () => {
|
||||||
|
let capturedScope = ''
|
||||||
|
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const body = init?.body
|
||||||
|
if (body instanceof URLSearchParams) {
|
||||||
|
capturedScope = body.get('scope') ?? ''
|
||||||
|
} else {
|
||||||
|
capturedScope = new URLSearchParams(String(body ?? '')).get('scope') ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
device_code: 'abc',
|
||||||
|
user_code: 'ABCD-1234',
|
||||||
|
verification_uri: 'https://github.com/login/device',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await requestDeviceCode({ clientId: 'test-client', fetchImpl: globalThis.fetch })
|
||||||
|
expect(capturedScope).toBe(DEFAULT_GITHUB_DEVICE_SCOPE)
|
||||||
|
expect(capturedScope).toBe('read:user')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries with OAuth-safe scope on invalid_scope', async () => {
|
||||||
|
const scopesSeen: string[] = []
|
||||||
|
let callCount = 0
|
||||||
|
|
||||||
|
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const body = init?.body
|
||||||
|
const scope =
|
||||||
|
body instanceof URLSearchParams
|
||||||
|
? body.get('scope') ?? ''
|
||||||
|
: new URLSearchParams(String(body ?? '')).get('scope') ?? ''
|
||||||
|
scopesSeen.push(scope)
|
||||||
|
callCount++
|
||||||
|
|
||||||
|
if (callCount === 1) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_scope',
|
||||||
|
error_description: 'invalid models scope',
|
||||||
|
}),
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
device_code: 'abc',
|
||||||
|
user_code: 'ABCD-1234',
|
||||||
|
verification_uri: 'https://github.com/login/device',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await requestDeviceCode({
|
||||||
|
clientId: 'test-client',
|
||||||
|
scope: 'read:user,models:read',
|
||||||
|
fetchImpl: globalThis.fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.device_code).toBe('abc')
|
||||||
|
expect(callCount).toBe(2)
|
||||||
|
expect(scopesSeen).toEqual(['read:user,models:read', 'read:user'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('pollAccessToken', () => {
|
describe('pollAccessToken', () => {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'
|
|||||||
export const GITHUB_DEVICE_ACCESS_TOKEN_URL =
|
export const GITHUB_DEVICE_ACCESS_TOKEN_URL =
|
||||||
'https://github.com/login/oauth/access_token'
|
'https://github.com/login/oauth/access_token'
|
||||||
|
|
||||||
/** Match runtime devsper github_oauth DEFAULT_SCOPE */
|
// OAuth app device flow does not accept the GitHub Models permission token
|
||||||
export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user,models:read'
|
// scope (models:read). Use an OAuth-safe default.
|
||||||
|
const OAUTH_SAFE_GITHUB_DEVICE_SCOPE = 'read:user'
|
||||||
|
export const DEFAULT_GITHUB_DEVICE_SCOPE = OAUTH_SAFE_GITHUB_DEVICE_SCOPE
|
||||||
|
|
||||||
export class GitHubDeviceFlowError extends Error {
|
export class GitHubDeviceFlowError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -51,20 +53,37 @@ export async function requestDeviceCode(options?: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const fetchFn = options?.fetchImpl ?? fetch
|
const fetchFn = options?.fetchImpl ?? fetch
|
||||||
|
const requestedScope =
|
||||||
|
options?.scope?.trim() || DEFAULT_GITHUB_DEVICE_SCOPE
|
||||||
|
const scopesToTry =
|
||||||
|
requestedScope === OAUTH_SAFE_GITHUB_DEVICE_SCOPE
|
||||||
|
? [requestedScope]
|
||||||
|
: [requestedScope, OAUTH_SAFE_GITHUB_DEVICE_SCOPE]
|
||||||
|
|
||||||
|
let lastError = 'Device code request failed.'
|
||||||
|
|
||||||
|
for (const scope of scopesToTry) {
|
||||||
const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
|
const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
scope: options?.scope ?? DEFAULT_GITHUB_DEVICE_SCOPE,
|
scope,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new GitHubDeviceFlowError(
|
lastError = `Device code request failed: ${res.status} ${text}`
|
||||||
`Device code request failed: ${res.status} ${text}`,
|
const isInvalidScope = /invalid_scope/i.test(text)
|
||||||
)
|
const canRetryWithFallback =
|
||||||
|
scope !== OAUTH_SAFE_GITHUB_DEVICE_SCOPE && isInvalidScope
|
||||||
|
if (canRetryWithFallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
throw new GitHubDeviceFlowError(lastError)
|
||||||
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const device_code = data.device_code
|
const device_code = data.device_code
|
||||||
const user_code = data.user_code
|
const user_code = data.user_code
|
||||||
@@ -74,8 +93,11 @@ export async function requestDeviceCode(options?: {
|
|||||||
typeof user_code !== 'string' ||
|
typeof user_code !== 'string' ||
|
||||||
typeof verification_uri !== 'string'
|
typeof verification_uri !== 'string'
|
||||||
) {
|
) {
|
||||||
throw new GitHubDeviceFlowError('Malformed device code response from GitHub')
|
throw new GitHubDeviceFlowError(
|
||||||
|
'Malformed device code response from GitHub',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
device_code,
|
device_code,
|
||||||
user_code,
|
user_code,
|
||||||
@@ -83,6 +105,9 @@ export async function requestDeviceCode(options?: {
|
|||||||
expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900,
|
expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900,
|
||||||
interval: typeof data.interval === 'number' ? data.interval : 5,
|
interval: typeof data.interval === 'number' ? data.interval : 5,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GitHubDeviceFlowError(lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PollOptions = {
|
export type PollOptions = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
|||||||
import { toError } from '../utils/errors.js'
|
import { toError } from '../utils/errors.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
||||||
|
import { persistActiveProviderProfileModel } from '../utils/providerProfiles.js'
|
||||||
import {
|
import {
|
||||||
permissionModeFromString,
|
permissionModeFromString,
|
||||||
toExternalPermissionMode,
|
toExternalPermissionMode,
|
||||||
@@ -110,6 +111,12 @@ export function onChangeAppState({
|
|||||||
// Save to settings
|
// Save to settings
|
||||||
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
|
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
|
||||||
setMainLoopModelOverride(newState.mainLoopModel)
|
setMainLoopModelOverride(newState.mainLoopModel)
|
||||||
|
|
||||||
|
// Keep active provider profiles in sync with /model choices so restarts
|
||||||
|
// keep using the last selected model instead of the profile's old default.
|
||||||
|
if (process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1') {
|
||||||
|
persistActiveProviderProfileModel(newState.mainLoopModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat
|
// expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
|||||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
GH_TOKEN: process.env.GH_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,
|
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +45,13 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
|||||||
)
|
)
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
expect(process.env.GITHUB_TOKEN).toBe('stored-secret')
|
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 () => {
|
test('does not override existing GITHUB_TOKEN', async () => {
|
||||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
process.env.GITHUB_TOKEN = 'already'
|
process.env.GITHUB_TOKEN = 'already'
|
||||||
|
delete process.env.CLAUDE_CODE_GITHUB_TOKEN_HYDRATED
|
||||||
|
|
||||||
mock.module('./secureStorage/index.js', () => ({
|
mock.module('./secureStorage/index.js', () => ({
|
||||||
getSecureStorage: () => ({
|
getSecureStorage: () => ({
|
||||||
@@ -62,5 +66,6 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
|||||||
)
|
)
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
expect(process.env.GITHUB_TOKEN).toBe('already')
|
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. */
|
/** JSON key in the shared OpenClaude secure storage blob. */
|
||||||
export const GITHUB_MODELS_STORAGE_KEY = 'githubModels' as const
|
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 = {
|
export type GithubModelsCredentialBlob = {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
@@ -27,18 +29,28 @@ export function readGithubModelsToken(): string | undefined {
|
|||||||
*/
|
*/
|
||||||
export function hydrateGithubModelsTokenFromSecureStorage(): void {
|
export function hydrateGithubModelsTokenFromSecureStorage(): void {
|
||||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
if (isBareMode()) {
|
if (isBareMode()) {
|
||||||
|
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const t = readGithubModelsToken()
|
const t = readGithubModelsToken()
|
||||||
if (t) {
|
if (t) {
|
||||||
process.env.GITHUB_TOKEN = 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): {
|
export function saveGithubModelsToken(token: string): {
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
|
|||||||
const provider = getAPIProvider()
|
const provider = getAPIProvider()
|
||||||
specifiedModel =
|
specifiedModel =
|
||||||
(provider === 'gemini' ? process.env.GEMINI_MODEL : undefined) ||
|
(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) ||
|
(provider === 'firstParty' ? process.env.ANTHROPIC_MODEL : undefined) ||
|
||||||
settings.model ||
|
settings.model ||
|
||||||
undefined
|
undefined
|
||||||
@@ -237,6 +239,10 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
|||||||
if (getAPIProvider() === 'openai') {
|
if (getAPIProvider() === 'openai') {
|
||||||
return process.env.OPENAI_MODEL || 'gpt-4o'
|
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)
|
// Codex provider: always use the configured Codex model (default gpt-5.4)
|
||||||
if (getAPIProvider() === 'codex') {
|
if (getAPIProvider() === 'codex') {
|
||||||
return process.env.OPENAI_MODEL || 'gpt-5.4'
|
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.
|
// @[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.
|
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
||||||
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
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
|
// When using Ollama, show models from the Ollama server instead of Claude models
|
||||||
if (getAPIProvider() === 'openai' && isOllamaProvider()) {
|
if (getAPIProvider() === 'openai' && isOllamaProvider()) {
|
||||||
const defaultOption = getDefaultOptionForUser(fastMode)
|
const defaultOption = getDefaultOptionForUser(fastMode)
|
||||||
@@ -579,6 +591,10 @@ function getKnownModelOption(model: string): ModelOption | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getModelOptions(fastMode = false): ModelOption[] {
|
export function getModelOptions(fastMode = false): ModelOption[] {
|
||||||
|
if (getAPIProvider() === 'github') {
|
||||||
|
return filterModelOptionsByAllowlist(getModelOptionsBase(fastMode))
|
||||||
|
}
|
||||||
|
|
||||||
const options = getModelOptionsBase(fastMode)
|
const options = getModelOptionsBase(fastMode)
|
||||||
|
|
||||||
// Add the custom model from the ANTHROPIC_CUSTOM_MODEL_OPTION env var
|
// 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 {
|
function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
|
||||||
// Codex piggybacks on the OpenAI provider transport for Anthropic tier aliases.
|
// Codex piggybacks on the OpenAI provider transport for Anthropic tier aliases.
|
||||||
// Reuse OpenAI mappings so model string lookups never return undefined.
|
// 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
|
const out = {} as ModelStrings
|
||||||
for (const key of MODEL_KEYS) {
|
for (const key of MODEL_KEYS) {
|
||||||
out[key] = ALL_MODEL_CONFIGS[key][providerKey]
|
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)
|
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 () => {
|
test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => {
|
||||||
const processEnv = {
|
const processEnv = {
|
||||||
CLAUDE_CODE_USE_OPENAI: '0',
|
CLAUDE_CODE_USE_OPENAI: '0',
|
||||||
|
|||||||
@@ -407,6 +407,11 @@ export function deleteProfileFile(options?: ProfileFileLocation): string {
|
|||||||
export function hasExplicitProviderSelection(
|
export function hasExplicitProviderSelection(
|
||||||
processEnv: NodeJS.ProcessEnv = process.env,
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// If env was already applied from a provider profile, preserve it.
|
||||||
|
if (processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
|
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
|
||||||
processEnv.CLAUDE_CODE_USE_GITHUB !== 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 originalEnv = { ...process.env }
|
||||||
|
|
||||||
const RESTORED_KEYS = [
|
const RESTORED_KEYS = [
|
||||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
|
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
|
||||||
|
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
|
||||||
'CLAUDE_CODE_USE_OPENAI',
|
'CLAUDE_CODE_USE_OPENAI',
|
||||||
'CLAUDE_CODE_USE_GEMINI',
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
'CLAUDE_CODE_USE_GITHUB',
|
'CLAUDE_CODE_USE_GITHUB',
|
||||||
@@ -22,7 +32,6 @@ const RESTORED_KEYS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
|
||||||
for (const key of RESTORED_KEYS) {
|
for (const key of RESTORED_KEYS) {
|
||||||
if (originalEnv[key] === undefined) {
|
if (originalEnv[key] === undefined) {
|
||||||
delete process.env[key]
|
delete process.env[key]
|
||||||
@@ -30,6 +39,14 @@ afterEach(() => {
|
|||||||
process.env[key] = originalEnv[key]
|
process.env[key] = originalEnv[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [],
|
||||||
|
activeProviderProfileId: undefined,
|
||||||
|
openaiAdditionalModelOptionsCache: [],
|
||||||
|
openaiAdditionalModelOptionsCacheByProfile: {},
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
|
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', () => {
|
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_GEMINI = '1'
|
||||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
const { applyProviderProfileToProcessEnv, getAPIProvider } =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
|
|
||||||
applyProviderProfileToProcessEnv(buildProfile())
|
applyProviderProfileToProcessEnv(buildProfile())
|
||||||
|
|
||||||
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
||||||
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined()
|
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined()
|
||||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
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')
|
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_GEMINI = '1'
|
||||||
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
const { applyProviderProfileToProcessEnv, getAPIProvider } =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
|
|
||||||
applyProviderProfileToProcessEnv(
|
applyProviderProfileToProcessEnv(
|
||||||
buildProfile({
|
buildProfile({
|
||||||
@@ -111,12 +96,10 @@ describe('applyProviderProfileToProcessEnv', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('applyActiveProviderProfileFromConfig', () => {
|
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.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||||
const { applyActiveProviderProfileFromConfig } =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
|
|
||||||
const applied = applyActiveProviderProfileFromConfig({
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
providerProfiles: [
|
providerProfiles: [
|
||||||
@@ -134,13 +117,11 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
|||||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
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_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||||
const { applyActiveProviderProfileFromConfig } =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
|
|
||||||
const applied = applyActiveProviderProfileFromConfig({
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
providerProfiles: [
|
providerProfiles: [
|
||||||
@@ -159,7 +140,63 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
|||||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
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_OPENAI
|
||||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
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_BASE_URL = 'http://localhost:11434/v1'
|
||||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
||||||
const { applyActiveProviderProfileFromConfig } =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
|
|
||||||
const applied = applyActiveProviderProfileFromConfig({
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
providerProfiles: [
|
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', () => {
|
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
|
delete process.env.OPENAI_MODEL
|
||||||
const { getProviderPresetDefaults } = await importFreshProviderModules()
|
|
||||||
|
|
||||||
const defaults = getProviderPresetDefaults('ollama')
|
const defaults = getProviderPresetDefaults('ollama')
|
||||||
|
|
||||||
@@ -203,23 +294,23 @@ describe('getProviderPresetDefaults', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('deleteProviderProfile', () => {
|
describe('deleteProviderProfile', () => {
|
||||||
test('deleting final profile clears provider env when active profile applied it', async () => {
|
test('deleting final profile clears provider env when active profile applied it', () => {
|
||||||
const {
|
applyProviderProfileToProcessEnv(
|
||||||
addProviderProfile,
|
buildProfile({
|
||||||
deleteProviderProfile,
|
id: 'only_profile',
|
||||||
} =
|
|
||||||
await importFreshProviderModules()
|
|
||||||
const profile = addProviderProfile({
|
|
||||||
name: 'Only Profile',
|
|
||||||
provider: 'openai',
|
|
||||||
baseUrl: 'https://api.openai.com/v1',
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
model: 'gpt-4o',
|
model: 'gpt-4o',
|
||||||
apiKey: 'sk-test',
|
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.removed).toBe(true)
|
||||||
expect(result.activeProfileId).toBeUndefined()
|
expect(result.activeProfileId).toBeUndefined()
|
||||||
@@ -243,25 +334,18 @@ describe('deleteProviderProfile', () => {
|
|||||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deleting final profile preserves explicit startup provider env', async () => {
|
test('deleting final profile preserves explicit startup provider env', () => {
|
||||||
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
|
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
process.env.OPENAI_MODEL = 'qwen2.5:3b'
|
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.removed).toBe(true)
|
||||||
expect(result.activeProfileId).toBeUndefined()
|
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_BASE_URL = 'http://localhost:11434/v1'
|
||||||
const DEFAULT_OLLAMA_MODEL = 'llama3.1:8b'
|
const DEFAULT_OLLAMA_MODEL = 'llama3.1:8b'
|
||||||
const PROFILE_ENV_APPLIED_FLAG = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED'
|
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 {
|
function trimValue(value: string | undefined): string {
|
||||||
return value?.trim() ?? ''
|
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(
|
function sameOptionalEnvValue(
|
||||||
left: string | undefined,
|
left: string | undefined,
|
||||||
right: string | undefined,
|
right: string | undefined,
|
||||||
@@ -284,6 +302,10 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) !== profile.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.provider === 'anthropic') {
|
if (profile.provider === 'anthropic') {
|
||||||
return (
|
return (
|
||||||
!hasProviderSelectionFlags(processEnv) &&
|
!hasProviderSelectionFlags(processEnv) &&
|
||||||
@@ -339,11 +361,13 @@ export function clearProviderProfileEnvFromProcessEnv(
|
|||||||
delete processEnv.ANTHROPIC_MODEL
|
delete processEnv.ANTHROPIC_MODEL
|
||||||
delete processEnv.ANTHROPIC_API_KEY
|
delete processEnv.ANTHROPIC_API_KEY
|
||||||
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
|
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
|
||||||
|
delete processEnv[PROFILE_ENV_APPLIED_ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void {
|
export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void {
|
||||||
clearProviderProfileEnvFromProcessEnv()
|
clearProviderProfileEnvFromProcessEnv()
|
||||||
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
||||||
|
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
||||||
|
|
||||||
process.env.ANTHROPIC_MODEL = profile.model
|
process.env.ANTHROPIC_MODEL = profile.model
|
||||||
if (profile.provider === 'anthropic') {
|
if (profile.provider === 'anthropic') {
|
||||||
@@ -386,12 +410,24 @@ export function applyActiveProviderProfileFromConfig(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCurrentEnvProfileManaged =
|
||||||
|
processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' &&
|
||||||
|
trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id
|
||||||
|
|
||||||
if (!options?.force && hasProviderSelectionFlags(processEnv)) {
|
if (!options?.force && hasProviderSelectionFlags(processEnv)) {
|
||||||
// Respect explicit startup provider intent. Re-apply only when the
|
// Respect explicit startup provider intent. Auto-heal only when this
|
||||||
// current process env is already profile-managed and aligned.
|
// exact active profile previously applied the current env.
|
||||||
if (!isProcessEnvAlignedWithProfile(processEnv, activeProfile)) {
|
if (!isCurrentEnvProfileManaged) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasConflictingProviderFlagsForProfile(processEnv, activeProfile)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProcessEnvAlignedWithProfile(processEnv, activeProfile)) {
|
||||||
|
return activeProfile
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyProviderProfileToProcessEnv(activeProfile)
|
applyProviderProfileToProcessEnv(activeProfile)
|
||||||
@@ -496,6 +532,61 @@ export function updateProviderProfile(
|
|||||||
return updatedProfile
|
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(
|
export function setActiveProviderProfile(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
): ProviderProfile | null {
|
): ProviderProfile | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user