Merge pull request #107 from rithulkamesh/main
feat: GitHub Models provider + interactive onboard (keychain-backed)
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
||||||
"dev:code": "bun run profile:code && bun run dev:profile",
|
"dev:code": "bun run profile:code && bun run dev:profile",
|
||||||
"start": "node dist/cli.mjs",
|
"start": "node dist/cli.mjs",
|
||||||
"test:provider-recommendation": "node --test --experimental-strip-types src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"smoke": "bun run build && node dist/cli.mjs --version",
|
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||||
"test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts",
|
"test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts",
|
||||||
|
|||||||
@@ -93,11 +93,15 @@ function isLocalBaseUrl(baseUrl: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
|
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
||||||
|
|
||||||
function currentBaseUrl(): string {
|
function currentBaseUrl(): string {
|
||||||
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
|
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
|
||||||
return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL
|
return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL
|
||||||
}
|
}
|
||||||
|
if (isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
return process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE
|
||||||
|
}
|
||||||
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
|
return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +130,47 @@ function checkGeminiEnv(): CheckResult[] {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkGithubEnv(): CheckResult[] {
|
||||||
|
const results: CheckResult[] = []
|
||||||
|
const baseUrl = process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE
|
||||||
|
results.push(pass('Provider mode', 'GitHub Models provider enabled.'))
|
||||||
|
|
||||||
|
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||||
|
if (!token?.trim()) {
|
||||||
|
results.push(fail('GITHUB_TOKEN', 'Missing. Set GITHUB_TOKEN or GH_TOKEN.'))
|
||||||
|
} else {
|
||||||
|
results.push(pass('GITHUB_TOKEN', 'Configured.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.OPENAI_MODEL) {
|
||||||
|
results.push(
|
||||||
|
pass(
|
||||||
|
'OPENAI_MODEL',
|
||||||
|
'Not set. Default github:copilot → openai/gpt-4.1 at runtime.',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(pass('OPENAI_BASE_URL', baseUrl))
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
function checkOpenAIEnv(): CheckResult[] {
|
function checkOpenAIEnv(): CheckResult[] {
|
||||||
const results: CheckResult[] = []
|
const results: CheckResult[] = []
|
||||||
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
|
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
|
|
||||||
if (useGemini) {
|
if (useGemini) {
|
||||||
return checkGeminiEnv()
|
return checkGeminiEnv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useGithub && !useOpenAI) {
|
||||||
|
return checkGithubEnv()
|
||||||
|
}
|
||||||
|
|
||||||
if (!useOpenAI) {
|
if (!useOpenAI) {
|
||||||
results.push(pass('Provider mode', 'Anthropic login flow enabled (CLAUDE_CODE_USE_OPENAI is off).'))
|
results.push(pass('Provider mode', 'Anthropic login flow enabled (CLAUDE_CODE_USE_OPENAI is off).'))
|
||||||
return results
|
return results
|
||||||
@@ -181,10 +217,19 @@ function checkOpenAIEnv(): CheckResult[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = process.env.OPENAI_API_KEY
|
const key = process.env.OPENAI_API_KEY
|
||||||
|
const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||||
if (key === 'SUA_CHAVE') {
|
if (key === 'SUA_CHAVE') {
|
||||||
results.push(fail('OPENAI_API_KEY', 'Placeholder value detected: SUA_CHAVE.'))
|
results.push(fail('OPENAI_API_KEY', 'Placeholder value detected: SUA_CHAVE.'))
|
||||||
} else if (!key && !isLocalBaseUrl(request.baseUrl)) {
|
} else if (
|
||||||
|
!key &&
|
||||||
|
!isLocalBaseUrl(request.baseUrl) &&
|
||||||
|
!(useGithub && githubToken?.trim())
|
||||||
|
) {
|
||||||
results.push(fail('OPENAI_API_KEY', 'Missing key for non-local provider URL.'))
|
results.push(fail('OPENAI_API_KEY', 'Missing key for non-local provider URL.'))
|
||||||
|
} else if (!key && useGithub && githubToken?.trim()) {
|
||||||
|
results.push(
|
||||||
|
pass('OPENAI_API_KEY', 'Not set; GITHUB_TOKEN/GH_TOKEN will be used for GitHub Models.'),
|
||||||
|
)
|
||||||
} else if (!key) {
|
} else if (!key) {
|
||||||
results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Atomic Chat/Ollama/LM Studio).'))
|
results.push(pass('OPENAI_API_KEY', 'Not set (allowed for local providers like Atomic Chat/Ollama/LM Studio).'))
|
||||||
} else {
|
} else {
|
||||||
@@ -197,11 +242,19 @@ function checkOpenAIEnv(): CheckResult[] {
|
|||||||
async function checkBaseUrlReachability(): Promise<CheckResult> {
|
async function checkBaseUrlReachability(): Promise<CheckResult> {
|
||||||
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
|
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
|
||||||
if (!useGemini && !useOpenAI) {
|
if (!useGemini && !useOpenAI && !useGithub) {
|
||||||
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
|
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useGithub) {
|
||||||
|
return pass(
|
||||||
|
'Provider reachability',
|
||||||
|
'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
const geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
const resolvedBaseUrl = useGemini
|
const resolvedBaseUrl = useGemini
|
||||||
? (process.env.GEMINI_BASE_URL ?? geminiBaseUrl)
|
? (process.env.GEMINI_BASE_URL ?? geminiBaseUrl)
|
||||||
@@ -281,7 +334,11 @@ function isAtomicChatUrl(baseUrl: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkOllamaProcessorMode(): CheckResult {
|
function checkOllamaProcessorMode(): CheckResult {
|
||||||
if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
|
if (
|
||||||
|
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
|
isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
||||||
|
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
) {
|
||||||
return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).')
|
return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +389,22 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
|
|||||||
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
|
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isTruthy(process.env.CLAUDE_CODE_USE_GITHUB) &&
|
||||||
|
!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: true,
|
||||||
|
OPENAI_MODEL:
|
||||||
|
process.env.OPENAI_MODEL ??
|
||||||
|
'(unset, default: github:copilot → openai/gpt-4.1)',
|
||||||
|
OPENAI_BASE_URL:
|
||||||
|
process.env.OPENAI_BASE_URL ?? GITHUB_MODELS_DEFAULT_BASE,
|
||||||
|
GITHUB_TOKEN_SET: Boolean(
|
||||||
|
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
const request = resolveProviderRequest({
|
const request = resolveProviderRequest({
|
||||||
model: process.env.OPENAI_MODEL,
|
model: process.env.OPENAI_MODEL,
|
||||||
baseUrl: process.env.OPENAI_BASE_URL,
|
baseUrl: process.env.OPENAI_BASE_URL,
|
||||||
@@ -387,6 +460,13 @@ async function main(): Promise<void> {
|
|||||||
const options = parseOptions(process.argv.slice(2))
|
const options = parseOptions(process.argv.slice(2))
|
||||||
const results: CheckResult[] = []
|
const results: CheckResult[] = []
|
||||||
|
|
||||||
|
const { enableConfigs } = await import('../src/utils/config.js')
|
||||||
|
enableConfigs()
|
||||||
|
const { applySafeConfigEnvironmentVariables } = await import('../src/utils/managedEnv.js')
|
||||||
|
applySafeConfigEnvironmentVariables()
|
||||||
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../src/utils/githubModelsCredentials.js')
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
|
||||||
results.push(checkNodeVersion())
|
results.push(checkNodeVersion())
|
||||||
results.push(checkBunRuntime())
|
results.push(checkBunRuntime())
|
||||||
results.push(checkBuildArtifacts())
|
results.push(checkBuildArtifacts())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import cost from './commands/cost/index.js'
|
|||||||
import diff from './commands/diff/index.js'
|
import diff from './commands/diff/index.js'
|
||||||
import ctx_viz from './commands/ctx_viz/index.js'
|
import ctx_viz from './commands/ctx_viz/index.js'
|
||||||
import doctor from './commands/doctor/index.js'
|
import doctor from './commands/doctor/index.js'
|
||||||
|
import onboardGithub from './commands/onboard-github/index.js'
|
||||||
import memory from './commands/memory/index.js'
|
import memory from './commands/memory/index.js'
|
||||||
import help from './commands/help/index.js'
|
import help from './commands/help/index.js'
|
||||||
import ide from './commands/ide/index.js'
|
import ide from './commands/ide/index.js'
|
||||||
@@ -288,6 +289,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
memory,
|
memory,
|
||||||
mobile,
|
mobile,
|
||||||
model,
|
model,
|
||||||
|
onboardGithub,
|
||||||
outputStyle,
|
outputStyle,
|
||||||
remoteEnv,
|
remoteEnv,
|
||||||
plugin,
|
plugin,
|
||||||
|
|||||||
11
src/commands/onboard-github/index.ts
Normal file
11
src/commands/onboard-github/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const onboardGithub: Command = {
|
||||||
|
name: 'onboard-github',
|
||||||
|
description:
|
||||||
|
'Interactive setup for GitHub Models: device login or PAT, saved to secure storage',
|
||||||
|
type: 'local-jsx',
|
||||||
|
load: () => import('./onboard-github.js'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default onboardGithub
|
||||||
237
src/commands/onboard-github/onboard-github.tsx
Normal file
237
src/commands/onboard-github/onboard-github.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Select } from '../../components/CustomSelect/select.js'
|
||||||
|
import { Spinner } from '../../components/Spinner.js'
|
||||||
|
import TextInput from '../../components/TextInput.js'
|
||||||
|
import { Box, Text } from '../../ink.js'
|
||||||
|
import {
|
||||||
|
openVerificationUri,
|
||||||
|
pollAccessToken,
|
||||||
|
requestDeviceCode,
|
||||||
|
} from '../../services/github/deviceFlow.js'
|
||||||
|
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||||
|
import {
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage,
|
||||||
|
saveGithubModelsToken,
|
||||||
|
} from '../../utils/githubModelsCredentials.js'
|
||||||
|
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'github:copilot'
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| 'menu'
|
||||||
|
| 'device-busy'
|
||||||
|
| 'pat'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
function mergeUserSettingsEnv(model: string): { ok: boolean; detail?: string } {
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
env: {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: '1',
|
||||||
|
OPENAI_MODEL: model,
|
||||||
|
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 { ok: false, detail: error.message }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardGithub(props: {
|
||||||
|
onDone: Parameters<LocalJSXCommandCall>[0]
|
||||||
|
onChangeAPIKey: () => void
|
||||||
|
}): React.ReactNode {
|
||||||
|
const { onDone, onChangeAPIKey } = props
|
||||||
|
const [step, setStep] = useState<Step>('menu')
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
const [deviceHint, setDeviceHint] = useState<{
|
||||||
|
user_code: string
|
||||||
|
verification_uri: string
|
||||||
|
} | null>(null)
|
||||||
|
const [patDraft, setPatDraft] = useState('')
|
||||||
|
const [cursorOffset, setCursorOffset] = useState(0)
|
||||||
|
|
||||||
|
const finalize = useCallback(
|
||||||
|
async (token: string, model: string = DEFAULT_MODEL) => {
|
||||||
|
const saved = saveGithubModelsToken(token)
|
||||||
|
if (!saved.success) {
|
||||||
|
setErrorMsg(saved.warning ?? 'Could not save token to secure storage.')
|
||||||
|
setStep('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const merged = mergeUserSettingsEnv(model.trim() || DEFAULT_MODEL)
|
||||||
|
if (!merged.ok) {
|
||||||
|
setErrorMsg(
|
||||||
|
`Token saved, but settings were not updated: ${merged.detail ?? 'unknown error'}. ` +
|
||||||
|
`Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`,
|
||||||
|
)
|
||||||
|
setStep('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
process.env.OPENAI_MODEL = model.trim() || DEFAULT_MODEL
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
onChangeAPIKey()
|
||||||
|
onDone(
|
||||||
|
'GitHub Models onboard complete. Token stored in secure storage; user settings updated. Restart if the model does not switch.',
|
||||||
|
{ display: 'user' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[onChangeAPIKey, onDone],
|
||||||
|
)
|
||||||
|
|
||||||
|
const runDeviceFlow = useCallback(async () => {
|
||||||
|
setStep('device-busy')
|
||||||
|
setErrorMsg(null)
|
||||||
|
setDeviceHint(null)
|
||||||
|
try {
|
||||||
|
const device = await requestDeviceCode()
|
||||||
|
setDeviceHint({
|
||||||
|
user_code: device.user_code,
|
||||||
|
verification_uri: device.verification_uri,
|
||||||
|
})
|
||||||
|
await openVerificationUri(device.verification_uri)
|
||||||
|
const token = await pollAccessToken(device.device_code, {
|
||||||
|
initialInterval: device.interval,
|
||||||
|
timeoutSeconds: device.expires_in,
|
||||||
|
})
|
||||||
|
await finalize(token, DEFAULT_MODEL)
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e))
|
||||||
|
setStep('error')
|
||||||
|
}
|
||||||
|
}, [finalize])
|
||||||
|
|
||||||
|
if (step === 'error' && errorMsg) {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: 'Back to menu',
|
||||||
|
value: 'back' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Exit',
|
||||||
|
value: 'exit' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="red">{errorMsg}</Text>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
if (v === 'back') {
|
||||||
|
setStep('menu')
|
||||||
|
setErrorMsg(null)
|
||||||
|
} else {
|
||||||
|
onDone('GitHub onboard cancelled', { display: 'system' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'device-busy') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text>GitHub device login</Text>
|
||||||
|
{deviceHint ? (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
Enter code <Text bold>{deviceHint.user_code}</Text> at{' '}
|
||||||
|
{deviceHint.verification_uri}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
A browser window may have opened. Waiting for authorization…
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>Requesting device code from GitHub…</Text>
|
||||||
|
)}
|
||||||
|
<Spinner />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'pat') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text>Paste a GitHub personal access token with access to GitHub Models.</Text>
|
||||||
|
<Text dimColor>Input is masked. Enter to submit; Esc to go back.</Text>
|
||||||
|
<TextInput
|
||||||
|
value={patDraft}
|
||||||
|
mask="*"
|
||||||
|
onChange={setPatDraft}
|
||||||
|
onSubmit={async (value: string) => {
|
||||||
|
const t = value.trim()
|
||||||
|
if (!t) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await finalize(t, DEFAULT_MODEL)
|
||||||
|
}}
|
||||||
|
onExit={() => {
|
||||||
|
setStep('menu')
|
||||||
|
setPatDraft('')
|
||||||
|
}}
|
||||||
|
columns={80}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
|
onChangeCursorOffset={setCursorOffset}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuOptions = [
|
||||||
|
{
|
||||||
|
label: 'Sign in with browser (device code)',
|
||||||
|
value: 'device' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Paste personal access token',
|
||||||
|
value: 'pat' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
value: 'cancel' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text bold>GitHub Models setup</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Stores your token in the OS credential store (macOS Keychain when available)
|
||||||
|
and enables CLAUDE_CODE_USE_GITHUB in your user settings — no export
|
||||||
|
GITHUB_TOKEN needed for future runs.
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={menuOptions}
|
||||||
|
onChange={(v: string) => {
|
||||||
|
if (v === 'cancel') {
|
||||||
|
onDone('GitHub onboard cancelled', { display: 'system' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v === 'pat') {
|
||||||
|
setStep('pat')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void runDeviceFlow()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||||
|
return (
|
||||||
|
<OnboardGithub
|
||||||
|
onDone={onDone}
|
||||||
|
onChangeAPIKey={context.onChangeAPIKey}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ const LOGO_CLAUDE = [
|
|||||||
|
|
||||||
function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } {
|
function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } {
|
||||||
const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
||||||
|
const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||||
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
||||||
|
|
||||||
if (useGemini) {
|
if (useGemini) {
|
||||||
@@ -88,6 +89,13 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
|
|||||||
return { name: 'Google Gemini', model, baseUrl, isLocal: false }
|
return { name: 'Google Gemini', model, baseUrl, isLocal: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useGithub) {
|
||||||
|
const model = process.env.OPENAI_MODEL || 'github:copilot'
|
||||||
|
const baseUrl =
|
||||||
|
process.env.OPENAI_BASE_URL || 'https://models.github.ai/inference'
|
||||||
|
return { name: 'GitHub Models', model, baseUrl, isLocal: false }
|
||||||
|
}
|
||||||
|
|
||||||
if (useOpenAI) {
|
if (useOpenAI) {
|
||||||
const model = process.env.OPENAI_MODEL || 'gpt-4o'
|
const model = process.env.OPENAI_MODEL || 'gpt-4o'
|
||||||
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
||||||
|
|||||||
@@ -46,7 +46,22 @@ function isLocalProviderUrl(baseUrl: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateProviderEnvOrExit(): void {
|
function validateProviderEnvOrExit(): void {
|
||||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
|
const useOpenAI = isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
|
const useGithub = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
|
||||||
|
if (useGithub && !useOpenAI) {
|
||||||
|
const token =
|
||||||
|
(process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()) ?? ''
|
||||||
|
if (!token) {
|
||||||
|
console.error(
|
||||||
|
'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useOpenAI) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +92,15 @@ function validateProviderEnvOrExit(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
|
if (!process.env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
|
||||||
console.error('OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.')
|
const hasGithubToken = !!(
|
||||||
process.exit(1)
|
process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()
|
||||||
|
)
|
||||||
|
if (!(useGithub && hasGithubToken)) {
|
||||||
|
console.error(
|
||||||
|
'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local. When CLAUDE_CODE_USE_GITHUB=1, GITHUB_TOKEN or GH_TOKEN may be used instead.',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +120,15 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { enableConfigs } = await import('../utils/config.js')
|
||||||
|
enableConfigs()
|
||||||
|
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
|
||||||
|
applySafeConfigEnvironmentVariables()
|
||||||
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
}
|
||||||
|
|
||||||
validateProviderEnvOrExit()
|
validateProviderEnvOrExit()
|
||||||
|
|
||||||
// Print the gradient startup screen before the Ink UI loads
|
// Print the gradient startup screen before the Ink UI loads
|
||||||
|
|||||||
@@ -2313,7 +2313,11 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
errors
|
errors
|
||||||
} = getSettingsWithErrors();
|
} = getSettingsWithErrors();
|
||||||
const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata);
|
const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata);
|
||||||
if (nonMcpErrors.length > 0 && !isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
|
if (
|
||||||
|
nonMcpErrors.length > 0 &&
|
||||||
|
!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) &&
|
||||||
|
!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
) {
|
||||||
await launchInvalidSettingsDialog(root, {
|
await launchInvalidSettingsDialog(root, {
|
||||||
settingsErrors: nonMcpErrors,
|
settingsErrors: nonMcpErrors,
|
||||||
onExit: () => gracefulShutdownSync(1)
|
onExit: () => gracefulShutdownSync(1)
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ export async function getAnthropicClient({
|
|||||||
fetch: resolvedFetch,
|
fetch: resolvedFetch,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
|
if (
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
) {
|
||||||
const { createOpenAIShimClient } = await import('./openaiShim.js')
|
const { createOpenAIShimClient } = await import('./openaiShim.js')
|
||||||
return createOpenAIShimClient({
|
return createOpenAIShimClient({
|
||||||
defaultHeaders,
|
defaultHeaders,
|
||||||
|
|||||||
@@ -14,8 +14,15 @@
|
|||||||
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
|
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
|
||||||
* OPENAI_MODEL=gpt-4o — default model override
|
* OPENAI_MODEL=gpt-4o — default model override
|
||||||
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
||||||
|
*
|
||||||
|
* GitHub Models (models.github.ai), OpenAI-compatible:
|
||||||
|
* CLAUDE_CODE_USE_GITHUB=1 — enable GitHub inference (no need for USE_OPENAI)
|
||||||
|
* GITHUB_TOKEN or GH_TOKEN — PAT with models access (mapped to Bearer auth)
|
||||||
|
* OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
|
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||||
import {
|
import {
|
||||||
codexStreamToAnthropic,
|
codexStreamToAnthropic,
|
||||||
collectCodexCompletedResponse,
|
collectCodexCompletedResponse,
|
||||||
@@ -30,6 +37,25 @@ import {
|
|||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from './providerConfig.js'
|
} from './providerConfig.js'
|
||||||
|
|
||||||
|
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
||||||
|
const GITHUB_API_VERSION = '2022-11-28'
|
||||||
|
const GITHUB_429_MAX_RETRIES = 3
|
||||||
|
const GITHUB_429_BASE_DELAY_SEC = 1
|
||||||
|
const GITHUB_429_MAX_DELAY_SEC = 32
|
||||||
|
|
||||||
|
function isGithubModelsMode(): boolean {
|
||||||
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRetryAfterHint(response: Response): string {
|
||||||
|
const ra = response.headers.get('retry-after')
|
||||||
|
return ra ? ` (Retry-After: ${ra})` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepMs(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types — minimal subset of Anthropic SDK types we need to produce
|
// Types — minimal subset of Anthropic SDK types we need to produce
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -294,9 +320,7 @@ function normalizeSchemaForOpenAI(
|
|||||||
function convertTools(
|
function convertTools(
|
||||||
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
|
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
|
||||||
): OpenAITool[] {
|
): OpenAITool[] {
|
||||||
const isGemini =
|
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
|
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
|
||||||
@@ -754,6 +778,12 @@ class OpenAIShimMessages {
|
|||||||
body.stream_options = { include_usage: true }
|
body.stream_options = { include_usage: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isGithub = isGithubModelsMode()
|
||||||
|
if (isGithub && body.max_completion_tokens !== undefined) {
|
||||||
|
body.max_tokens = body.max_completion_tokens
|
||||||
|
delete body.max_completion_tokens
|
||||||
|
}
|
||||||
|
|
||||||
if (params.temperature !== undefined) body.temperature = params.temperature
|
if (params.temperature !== undefined) body.temperature = params.temperature
|
||||||
if (params.top_p !== undefined) body.top_p = params.top_p
|
if (params.top_p !== undefined) body.top_p = params.top_p
|
||||||
|
|
||||||
@@ -803,6 +833,11 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isGithub) {
|
||||||
|
headers.Accept = 'application/vnd.github.v3+json'
|
||||||
|
headers['X-GitHub-Api-Version'] = GITHUB_API_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
// Build the chat completions URL
|
// Build the chat completions URL
|
||||||
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path
|
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path
|
||||||
// and an api-version query parameter.
|
// and an api-version query parameter.
|
||||||
@@ -825,19 +860,42 @@ class OpenAIShimMessages {
|
|||||||
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
|
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(chatCompletionsUrl, {
|
const fetchInit = {
|
||||||
method: 'POST',
|
method: 'POST' as const,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text().catch(() => 'unknown error')
|
|
||||||
throw new Error(`OpenAI API error ${response.status}: ${errorBody}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
|
||||||
|
let response: Response | undefined
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
response = await fetch(chatCompletionsUrl, fetchInit)
|
||||||
|
if (response.ok) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isGithub &&
|
||||||
|
response.status === 429 &&
|
||||||
|
attempt < maxAttempts - 1
|
||||||
|
) {
|
||||||
|
await response.text().catch(() => {})
|
||||||
|
const delaySec = Math.min(
|
||||||
|
GITHUB_429_BASE_DELAY_SEC * 2 ** attempt,
|
||||||
|
GITHUB_429_MAX_DELAY_SEC,
|
||||||
|
)
|
||||||
|
await sleepMs(delaySec * 1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const errorBody = await response.text().catch(() => 'unknown error')
|
||||||
|
const rateHint =
|
||||||
|
isGithub && response.status === 429 ? formatRetryAfterHint(response) : ''
|
||||||
|
throw new Error(
|
||||||
|
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('OpenAI shim: request loop exited unexpectedly')
|
||||||
}
|
}
|
||||||
|
|
||||||
private _convertNonStreamingResponse(
|
private _convertNonStreamingResponse(
|
||||||
@@ -847,7 +905,10 @@ class OpenAIShimMessages {
|
|||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
role?: string
|
role?: string
|
||||||
content?: string | null
|
content?:
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| Array<{ type?: string; text?: string }>
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id: string
|
id: string
|
||||||
function: { name: string; arguments: string }
|
function: { name: string; arguments: string }
|
||||||
@@ -866,8 +927,25 @@ class OpenAIShimMessages {
|
|||||||
const choice = data.choices?.[0]
|
const choice = data.choices?.[0]
|
||||||
const content: Array<Record<string, unknown>> = []
|
const content: Array<Record<string, unknown>> = []
|
||||||
|
|
||||||
if (choice?.message?.content) {
|
const rawContent = choice?.message?.content
|
||||||
content.push({ type: 'text', text: choice.message.content })
|
if (typeof rawContent === 'string' && rawContent) {
|
||||||
|
content.push({ type: 'text', text: rawContent })
|
||||||
|
} else if (Array.isArray(rawContent) && rawContent.length > 0) {
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const part of rawContent) {
|
||||||
|
if (
|
||||||
|
part &&
|
||||||
|
typeof part === 'object' &&
|
||||||
|
part.type === 'text' &&
|
||||||
|
typeof part.text === 'string'
|
||||||
|
) {
|
||||||
|
parts.push(part.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const joined = parts.join('\n')
|
||||||
|
if (joined) {
|
||||||
|
content.push({ type: 'text', text: joined })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (choice?.message?.tool_calls) {
|
if (choice?.message?.tool_calls) {
|
||||||
@@ -926,12 +1004,11 @@ export function createOpenAIShimClient(options: {
|
|||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}): unknown {
|
}): unknown {
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
|
||||||
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
|
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
|
||||||
// so the existing providerConfig.ts infrastructure picks them up correctly.
|
// so the existing providerConfig.ts infrastructure picks them up correctly.
|
||||||
if (
|
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
|
||||||
) {
|
|
||||||
process.env.OPENAI_BASE_URL ??=
|
process.env.OPENAI_BASE_URL ??=
|
||||||
process.env.GEMINI_BASE_URL ??
|
process.env.GEMINI_BASE_URL ??
|
||||||
'https://generativelanguage.googleapis.com/v1beta/openai'
|
'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
@@ -940,6 +1017,10 @@ export function createOpenAIShimClient(options: {
|
|||||||
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
|
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
|
||||||
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
|
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
|
||||||
}
|
}
|
||||||
|
} else if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
process.env.OPENAI_BASE_URL ??= GITHUB_MODELS_DEFAULT_BASE
|
||||||
|
process.env.OPENAI_API_KEY ??=
|
||||||
|
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const beta = new OpenAIShimBeta({
|
const beta = new OpenAIShimBeta({
|
||||||
|
|||||||
41
src/services/api/providerConfig.github.test.ts
Normal file
41
src/services/api/providerConfig.github.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_GITHUB_MODELS_API_MODEL,
|
||||||
|
normalizeGithubModelsApiModel,
|
||||||
|
resolveProviderRequest,
|
||||||
|
} from './providerConfig.js'
|
||||||
|
|
||||||
|
const originalUseGithub = process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalUseGithub === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = originalUseGithub
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||||
|
['github:copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||||
|
['', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||||
|
['github:gpt-4o', 'gpt-4o'],
|
||||||
|
['gpt-4o', 'gpt-4o'],
|
||||||
|
['github:copilot?reasoning=high', DEFAULT_GITHUB_MODELS_API_MODEL],
|
||||||
|
] as const)('normalizeGithubModelsApiModel(%s) -> %s', (input, expected) => {
|
||||||
|
expect(normalizeGithubModelsApiModel(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveProviderRequest applies GitHub normalization when CLAUDE_CODE_USE_GITHUB=1', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
|
||||||
|
expect(r.resolvedModel).toBe('gpt-4o')
|
||||||
|
expect(r.transport).toBe('chat_completions')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveProviderRequest leaves model unchanged without GitHub flag', () => {
|
||||||
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
|
||||||
|
expect(r.resolvedModel).toBe('github:gpt-4o')
|
||||||
|
})
|
||||||
@@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
|
|||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
|
|
||||||
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||||
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||||
|
/** Default GitHub Models API model when user selects copilot / github:copilot */
|
||||||
|
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'openai/gpt-4.1'
|
||||||
|
|
||||||
const CODEX_ALIAS_MODELS: Record<
|
const CODEX_ALIAS_MODELS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -171,16 +175,31 @@ export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize user model string for GitHub Models inference (models.github.ai).
|
||||||
|
* Mirrors runtime devsper `github._normalize_model_id`.
|
||||||
|
*/
|
||||||
|
export function normalizeGithubModelsApiModel(requestedModel: string): string {
|
||||||
|
const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel
|
||||||
|
const segment =
|
||||||
|
noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim()
|
||||||
|
if (!segment || segment.toLowerCase() === 'copilot') {
|
||||||
|
return DEFAULT_GITHUB_MODELS_API_MODEL
|
||||||
|
}
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveProviderRequest(options?: {
|
export function resolveProviderRequest(options?: {
|
||||||
model?: string
|
model?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
fallbackModel?: string
|
fallbackModel?: string
|
||||||
}): ResolvedProviderRequest {
|
}): ResolvedProviderRequest {
|
||||||
|
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const requestedModel =
|
const requestedModel =
|
||||||
options?.model?.trim() ||
|
options?.model?.trim() ||
|
||||||
process.env.OPENAI_MODEL?.trim() ||
|
process.env.OPENAI_MODEL?.trim() ||
|
||||||
options?.fallbackModel?.trim() ||
|
options?.fallbackModel?.trim() ||
|
||||||
'gpt-4o'
|
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
||||||
const descriptor = parseModelDescriptor(requestedModel)
|
const descriptor = parseModelDescriptor(requestedModel)
|
||||||
const rawBaseUrl =
|
const rawBaseUrl =
|
||||||
options?.baseUrl ??
|
options?.baseUrl ??
|
||||||
@@ -192,10 +211,16 @@ export function resolveProviderRequest(options?: {
|
|||||||
? 'codex_responses'
|
? 'codex_responses'
|
||||||
: 'chat_completions'
|
: 'chat_completions'
|
||||||
|
|
||||||
|
const resolvedModel =
|
||||||
|
transport === 'chat_completions' &&
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
? normalizeGithubModelsApiModel(requestedModel)
|
||||||
|
: descriptor.baseModel
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transport,
|
transport,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
resolvedModel: descriptor.baseModel,
|
resolvedModel,
|
||||||
baseUrl:
|
baseUrl:
|
||||||
(rawBaseUrl ??
|
(rawBaseUrl ??
|
||||||
(transport === 'codex_responses'
|
(transport === 'codex_responses'
|
||||||
|
|||||||
94
src/services/github/deviceFlow.test.ts
Normal file
94
src/services/github/deviceFlow.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GitHubDeviceFlowError,
|
||||||
|
pollAccessToken,
|
||||||
|
requestDeviceCode,
|
||||||
|
} from './deviceFlow.js'
|
||||||
|
|
||||||
|
describe('requestDeviceCode', () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses successful device code response', async () => {
|
||||||
|
globalThis.fetch = mock(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
device_code: 'abc',
|
||||||
|
user_code: 'ABCD-1234',
|
||||||
|
verification_uri: 'https://github.com/login/device',
|
||||||
|
expires_in: 600,
|
||||||
|
interval: 5,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const r = await requestDeviceCode({
|
||||||
|
clientId: 'test-client',
|
||||||
|
fetchImpl: globalThis.fetch,
|
||||||
|
})
|
||||||
|
expect(r.device_code).toBe('abc')
|
||||||
|
expect(r.user_code).toBe('ABCD-1234')
|
||||||
|
expect(r.verification_uri).toBe('https://github.com/login/device')
|
||||||
|
expect(r.expires_in).toBe(600)
|
||||||
|
expect(r.interval).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws on HTTP error', async () => {
|
||||||
|
globalThis.fetch = mock(() =>
|
||||||
|
Promise.resolve(new Response('bad', { status: 500 })),
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
|
||||||
|
).rejects.toThrow(GitHubDeviceFlowError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pollAccessToken', () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token when GitHub responds with access_token immediately', async () => {
|
||||||
|
let calls = 0
|
||||||
|
globalThis.fetch = mock(() => {
|
||||||
|
calls++
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ access_token: 'tok-xyz' }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = await pollAccessToken('dev-code', {
|
||||||
|
clientId: 'cid',
|
||||||
|
fetchImpl: globalThis.fetch,
|
||||||
|
})
|
||||||
|
expect(token).toBe('tok-xyz')
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws on access_denied', async () => {
|
||||||
|
globalThis.fetch = mock(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ error: 'access_denied' }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
pollAccessToken('dc', {
|
||||||
|
clientId: 'c',
|
||||||
|
fetchImpl: globalThis.fetch,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/denied/)
|
||||||
|
})
|
||||||
|
})
|
||||||
174
src/services/github/deviceFlow.ts
Normal file
174
src/services/github/deviceFlow.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* GitHub OAuth device flow for CLI login (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||||
|
|
||||||
|
export const DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID = 'Ov23liXjWSSui6QIahPl'
|
||||||
|
|
||||||
|
export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'
|
||||||
|
export const GITHUB_DEVICE_ACCESS_TOKEN_URL =
|
||||||
|
'https://github.com/login/oauth/access_token'
|
||||||
|
|
||||||
|
/** Match runtime devsper github_oauth DEFAULT_SCOPE */
|
||||||
|
export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user,models:read'
|
||||||
|
|
||||||
|
export class GitHubDeviceFlowError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'GitHubDeviceFlowError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceCodeResult = {
|
||||||
|
device_code: string
|
||||||
|
user_code: string
|
||||||
|
verification_uri: string
|
||||||
|
expires_in: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGithubDeviceFlowClientId(): string {
|
||||||
|
return (
|
||||||
|
process.env.GITHUB_DEVICE_FLOW_CLIENT_ID?.trim() ||
|
||||||
|
DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDeviceCode(options?: {
|
||||||
|
clientId?: string
|
||||||
|
scope?: string
|
||||||
|
fetchImpl?: typeof fetch
|
||||||
|
}): Promise<DeviceCodeResult> {
|
||||||
|
const clientId = options?.clientId ?? getGithubDeviceFlowClientId()
|
||||||
|
if (!clientId) {
|
||||||
|
throw new GitHubDeviceFlowError(
|
||||||
|
'No OAuth client ID: set GITHUB_DEVICE_FLOW_CLIENT_ID or paste a PAT instead.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const fetchFn = options?.fetchImpl ?? fetch
|
||||||
|
const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
scope: options?.scope ?? DEFAULT_GITHUB_DEVICE_SCOPE,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new GitHubDeviceFlowError(
|
||||||
|
`Device code request failed: ${res.status} ${text}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
|
const device_code = data.device_code
|
||||||
|
const user_code = data.user_code
|
||||||
|
const verification_uri = data.verification_uri
|
||||||
|
if (
|
||||||
|
typeof device_code !== 'string' ||
|
||||||
|
typeof user_code !== 'string' ||
|
||||||
|
typeof verification_uri !== 'string'
|
||||||
|
) {
|
||||||
|
throw new GitHubDeviceFlowError('Malformed device code response from GitHub')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
device_code,
|
||||||
|
user_code,
|
||||||
|
verification_uri,
|
||||||
|
expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900,
|
||||||
|
interval: typeof data.interval === 'number' ? data.interval : 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PollOptions = {
|
||||||
|
clientId?: string
|
||||||
|
initialInterval?: number
|
||||||
|
timeoutSeconds?: number
|
||||||
|
fetchImpl?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollAccessToken(
|
||||||
|
deviceCode: string,
|
||||||
|
options?: PollOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const clientId = options?.clientId ?? getGithubDeviceFlowClientId()
|
||||||
|
if (!clientId) {
|
||||||
|
throw new GitHubDeviceFlowError('client_id required for polling')
|
||||||
|
}
|
||||||
|
let interval = Math.max(1, options?.initialInterval ?? 5)
|
||||||
|
const timeoutSeconds = options?.timeoutSeconds ?? 900
|
||||||
|
const fetchFn = options?.fetchImpl ?? fetch
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
while ((Date.now() - start) / 1000 < timeoutSeconds) {
|
||||||
|
const res = await fetchFn(GITHUB_DEVICE_ACCESS_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
device_code: deviceCode,
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new GitHubDeviceFlowError(
|
||||||
|
`Token request failed: ${res.status} ${text}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
|
const err = data.error as string | undefined
|
||||||
|
if (err == null) {
|
||||||
|
const token = data.access_token
|
||||||
|
if (typeof token === 'string' && token) {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
throw new GitHubDeviceFlowError('No access_token in response')
|
||||||
|
}
|
||||||
|
if (err === 'authorization_pending') {
|
||||||
|
await sleep(interval * 1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (err === 'slow_down') {
|
||||||
|
interval =
|
||||||
|
typeof data.interval === 'number' ? data.interval : interval + 5
|
||||||
|
await sleep(interval * 1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (err === 'expired_token') {
|
||||||
|
throw new GitHubDeviceFlowError(
|
||||||
|
'Device code expired. Start the login flow again.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (err === 'access_denied') {
|
||||||
|
throw new GitHubDeviceFlowError('Authorization was denied or cancelled.')
|
||||||
|
}
|
||||||
|
throw new GitHubDeviceFlowError(`GitHub OAuth error: ${err}`)
|
||||||
|
}
|
||||||
|
throw new GitHubDeviceFlowError('Timed out waiting for authorization.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort open browser / OS handler for the verification URL.
|
||||||
|
*/
|
||||||
|
export async function openVerificationUri(uri: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
await execFileNoThrow('open', [uri], { useCwd: false, timeout: 5000 })
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
await execFileNoThrow('cmd', ['/c', 'start', '', uri], {
|
||||||
|
useCwd: false,
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await execFileNoThrow('xdg-open', [uri], { useCwd: false, timeout: 5000 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// User can open the URL manually
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,7 +117,8 @@ export function isAnthropicAuthEnabled(): boolean {
|
|||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
|
|
||||||
// Check if user has configured an external API key source
|
// Check if user has configured an external API key source
|
||||||
// This allows externally-provided API keys to work (without requiring proxy configuration)
|
// This allows externally-provided API keys to work (without requiring proxy configuration)
|
||||||
@@ -1731,14 +1732,15 @@ export function getSubscriptionName(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if using third-party services (Bedrock or Vertex or Foundry or OpenAI-compatible or Gemini) */
|
/** Check if using third-party services (Bedrock or Vertex or Foundry or OpenAI-compatible or Gemini or GitHub Models) */
|
||||||
export function isUsing3PServices(): boolean {
|
export function isUsing3PServices(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
||||||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function getContextWindowForModel(
|
|||||||
process.env.CLAUDE_CODE_USE_OPENAI === '1' ||
|
process.env.CLAUDE_CODE_USE_OPENAI === '1' ||
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI === 'true' ||
|
process.env.CLAUDE_CODE_USE_OPENAI === 'true' ||
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
process.env.CLAUDE_CODE_USE_GEMINI === 'true' ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB === '1' ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||||
) {
|
) {
|
||||||
const openaiWindow = getOpenAIContextWindow(model)
|
const openaiWindow = getOpenAIContextWindow(model)
|
||||||
if (openaiWindow !== undefined) {
|
if (openaiWindow !== undefined) {
|
||||||
@@ -181,7 +183,9 @@ export function getModelMaxOutputTokens(model: string): {
|
|||||||
process.env.CLAUDE_CODE_USE_OPENAI === '1' ||
|
process.env.CLAUDE_CODE_USE_OPENAI === '1' ||
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI === 'true' ||
|
process.env.CLAUDE_CODE_USE_OPENAI === 'true' ||
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
process.env.CLAUDE_CODE_USE_GEMINI === 'true' ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB === '1' ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||||
) {
|
) {
|
||||||
const openaiMax = getOpenAIMaxOutputTokens(model)
|
const openaiMax = getOpenAIMaxOutputTokens(model)
|
||||||
if (openaiMax !== undefined) {
|
if (openaiMax !== undefined) {
|
||||||
|
|||||||
66
src/utils/githubModelsCredentials.hydrate.test.ts
Normal file
66
src/utils/githubModelsCredentials.hydrate.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Hydrate tests live in a separate file with no static import of
|
||||||
|
* githubModelsCredentials so Bun's mock.module can replace secureStorage
|
||||||
|
* before that module is first loaded.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
describe('hydrateGithubModelsTokenFromSecureStorage', () => {
|
||||||
|
const orig = {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||||
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
|
GH_TOKEN: process.env.GH_TOKEN,
|
||||||
|
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
for (const [k, v] of Object.entries(orig)) {
|
||||||
|
if (v === undefined) {
|
||||||
|
delete process.env[k as keyof typeof orig]
|
||||||
|
} else {
|
||||||
|
process.env[k as keyof typeof orig] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets GITHUB_TOKEN from secure storage when USE_GITHUB and env token empty', async () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
delete process.env.GITHUB_TOKEN
|
||||||
|
delete process.env.GH_TOKEN
|
||||||
|
delete process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
|
||||||
|
mock.module('./secureStorage/index.js', () => ({
|
||||||
|
getSecureStorage: () => ({
|
||||||
|
read: () => ({
|
||||||
|
githubModels: { accessToken: 'stored-secret' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import(
|
||||||
|
'./githubModelsCredentials.js'
|
||||||
|
)
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
expect(process.env.GITHUB_TOKEN).toBe('stored-secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not override existing GITHUB_TOKEN', async () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
process.env.GITHUB_TOKEN = 'already'
|
||||||
|
|
||||||
|
mock.module('./secureStorage/index.js', () => ({
|
||||||
|
getSecureStorage: () => ({
|
||||||
|
read: () => ({
|
||||||
|
githubModels: { accessToken: 'stored-secret' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import(
|
||||||
|
'./githubModelsCredentials.js'
|
||||||
|
)
|
||||||
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
expect(process.env.GITHUB_TOKEN).toBe('already')
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/utils/githubModelsCredentials.test.ts
Normal file
47
src/utils/githubModelsCredentials.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearGithubModelsToken,
|
||||||
|
readGithubModelsToken,
|
||||||
|
saveGithubModelsToken,
|
||||||
|
} from './githubModelsCredentials.js'
|
||||||
|
|
||||||
|
describe('readGithubModelsToken', () => {
|
||||||
|
test('returns undefined in bare mode', () => {
|
||||||
|
const prev = process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||||
|
expect(readGithubModelsToken()).toBeUndefined()
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveGithubModelsToken / clearGithubModelsToken', () => {
|
||||||
|
test('save returns failure in bare mode', () => {
|
||||||
|
const prev = process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||||
|
const r = saveGithubModelsToken('abc')
|
||||||
|
expect(r.success).toBe(false)
|
||||||
|
expect(r.warning).toContain('Bare mode')
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clear succeeds in bare mode', () => {
|
||||||
|
const prev = process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||||
|
expect(clearGithubModelsToken().success).toBe(true)
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_SIMPLE
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_SIMPLE = prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
73
src/utils/githubModelsCredentials.ts
Normal file
73
src/utils/githubModelsCredentials.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { isBareMode, isEnvTruthy } from './envUtils.js'
|
||||||
|
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 type GithubModelsCredentialBlob = {
|
||||||
|
accessToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readGithubModelsToken(): string | undefined {
|
||||||
|
if (isBareMode()) return undefined
|
||||||
|
try {
|
||||||
|
const data = getSecureStorage().read() as
|
||||||
|
| ({ githubModels?: GithubModelsCredentialBlob } & Record<string, unknown>)
|
||||||
|
| null
|
||||||
|
const t = data?.githubModels?.accessToken?.trim()
|
||||||
|
return t || undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If GitHub Models mode is on and no token is in the environment, copy the
|
||||||
|
* stored token into process.env so the OpenAI shim and validation see it.
|
||||||
|
*/
|
||||||
|
export function hydrateGithubModelsTokenFromSecureStorage(): void {
|
||||||
|
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isBareMode()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = readGithubModelsToken()
|
||||||
|
if (t) {
|
||||||
|
process.env.GITHUB_TOKEN = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGithubModelsToken(token: string): {
|
||||||
|
success: boolean
|
||||||
|
warning?: string
|
||||||
|
} {
|
||||||
|
if (isBareMode()) {
|
||||||
|
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
|
||||||
|
}
|
||||||
|
const trimmed = token.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return { success: false, warning: 'Token is empty.' }
|
||||||
|
}
|
||||||
|
const secureStorage = getSecureStorage()
|
||||||
|
const prev = secureStorage.read() || {}
|
||||||
|
const merged = {
|
||||||
|
...(prev as Record<string, unknown>),
|
||||||
|
[GITHUB_MODELS_STORAGE_KEY]: { accessToken: trimmed },
|
||||||
|
}
|
||||||
|
return secureStorage.update(merged as typeof prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGithubModelsToken(): { success: boolean; warning?: string } {
|
||||||
|
if (isBareMode()) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const secureStorage = getSecureStorage()
|
||||||
|
const prev = secureStorage.read() || {}
|
||||||
|
const next = { ...(prev as Record<string, unknown>) }
|
||||||
|
delete next[GITHUB_MODELS_STORAGE_KEY]
|
||||||
|
return secureStorage.update(next as typeof prev)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
|||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'CLAUDE_CODE_USE_GITHUB',
|
||||||
// Endpoint config (base URLs, project/resource identifiers)
|
// Endpoint config (base URLs, project/resource identifiers)
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_BEDROCK_BASE_URL',
|
'ANTHROPIC_BEDROCK_BASE_URL',
|
||||||
@@ -147,6 +148,7 @@ export const SAFE_ENV_VARS = new Set([
|
|||||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'CLAUDE_CODE_USE_GITHUB',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
'DISABLE_AUTOUPDATER',
|
'DISABLE_AUTOUPDATER',
|
||||||
'DISABLE_BUG_COMMAND',
|
'DISABLE_BUG_COMMAND',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||||
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
||||||
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||||
@@ -15,6 +16,7 @@ const originalEnv = {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
|
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
|
||||||
|
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_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
|
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_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
|
||||||
@@ -23,6 +25,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
function clearProviderEnv(): void {
|
function clearProviderEnv(): void {
|
||||||
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_OPENAI
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||||
@@ -38,6 +41,7 @@ test('first-party provider keeps Anthropic account setup flow enabled', () => {
|
|||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
['CLAUDE_CODE_USE_OPENAI', 'openai'],
|
['CLAUDE_CODE_USE_OPENAI', 'openai'],
|
||||||
|
['CLAUDE_CODE_USE_GITHUB', 'github'],
|
||||||
['CLAUDE_CODE_USE_GEMINI', 'gemini'],
|
['CLAUDE_CODE_USE_GEMINI', 'gemini'],
|
||||||
['CLAUDE_CODE_USE_BEDROCK', 'bedrock'],
|
['CLAUDE_CODE_USE_BEDROCK', 'bedrock'],
|
||||||
['CLAUDE_CODE_USE_VERTEX', 'vertex'],
|
['CLAUDE_CODE_USE_VERTEX', 'vertex'],
|
||||||
@@ -52,3 +56,11 @@ test.each([
|
|||||||
expect(usesAnthropicAccountFlow()).toBe(false)
|
expect(usesAnthropicAccountFlow()).toBe(false)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test('GEMINI takes precedence over GitHub when both are set', () => {
|
||||||
|
clearProviderEnv()
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
|
||||||
|
expect(getAPIProvider()).toBe('gemini')
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
|
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
|
||||||
import { isEnvTruthy } from '../envUtils.js'
|
import { isEnvTruthy } from '../envUtils.js'
|
||||||
|
|
||||||
export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' | 'gemini'
|
export type APIProvider =
|
||||||
|
| 'firstParty'
|
||||||
|
| 'bedrock'
|
||||||
|
| 'vertex'
|
||||||
|
| 'foundry'
|
||||||
|
| 'openai'
|
||||||
|
| 'gemini'
|
||||||
|
| 'github'
|
||||||
|
|
||||||
export function getAPIProvider(): APIProvider {
|
export function getAPIProvider(): APIProvider {
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
? 'gemini'
|
? 'gemini'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
? 'openai'
|
? 'github'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
? 'bedrock'
|
? 'openai'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
||||||
? 'vertex'
|
? 'bedrock'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
||||||
? 'foundry'
|
? 'vertex'
|
||||||
: 'firstParty'
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
||||||
|
? 'foundry'
|
||||||
|
: 'firstParty'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usesAnthropicAccountFlow(): boolean {
|
export function usesAnthropicAccountFlow(): boolean {
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export async function buildLaunchEnv(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete env.CLAUDE_CODE_USE_OPENAI
|
delete env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
delete env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
|
||||||
env.GEMINI_MODEL =
|
env.GEMINI_MODEL =
|
||||||
processEnv.GEMINI_MODEL ||
|
processEnv.GEMINI_MODEL ||
|
||||||
@@ -239,6 +240,7 @@ export async function buildLaunchEnv(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete env.CLAUDE_CODE_USE_GEMINI
|
delete env.CLAUDE_CODE_USE_GEMINI
|
||||||
|
delete env.CLAUDE_CODE_USE_GITHUB
|
||||||
delete env.GEMINI_API_KEY
|
delete env.GEMINI_API_KEY
|
||||||
delete env.GEMINI_MODEL
|
delete env.GEMINI_MODEL
|
||||||
delete env.GEMINI_BASE_URL
|
delete env.GEMINI_BASE_URL
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ const TEAMMATE_ENV_VARS = [
|
|||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'CLAUDE_CODE_USE_GITHUB',
|
||||||
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
|
'CLAUDE_CODE_USE_OPENAI',
|
||||||
|
'GITHUB_TOKEN',
|
||||||
|
'GH_TOKEN',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'OPENAI_BASE_URL',
|
||||||
|
'OPENAI_MODEL',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GEMINI_BASE_URL',
|
||||||
|
'GEMINI_MODEL',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
// Custom API endpoint
|
// Custom API endpoint
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
// Config directory override
|
// Config directory override
|
||||||
|
|||||||
Reference in New Issue
Block a user