feat: add Gemini ADC and access token auth (#312)

* feat: add Gemini ADC and access token auth

* feat: add Gemini token and ADC provider setup

* feat: add Gemini token and ADC provider setup

* fix: honor Gemini auth mode on restart
This commit is contained in:
Vasanth T
2026-04-04 15:07:17 +05:30
committed by GitHub
parent 280c9732f5
commit ea335aeddc
15 changed files with 1128 additions and 130 deletions

View File

@@ -116,13 +116,15 @@ Advanced and source-build guides:
| Provider | Setup Path | Notes |
| --- | --- | --- |
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and compatible local `/v1` servers |
| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer |
| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer (API key, access token, or local ADC) |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available |
| Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
For Gemini, `/provider` can now save either the API-key path, a securely stored access-token path, or a local ADC profile.
---
## What Works

View File

@@ -197,6 +197,23 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
expect(message).not.toContain('sk-secret-12345678')
})
test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => {
const message = buildProfileSaveMessage(
'gemini',
{
GEMINI_AUTH_MODE: 'access-token',
GEMINI_MODEL: 'gemini-2.5-flash',
GEMINI_BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/openai',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
)
expect(message).toContain('Saved Google Gemini profile.')
expect(message).toContain('Model: gemini-2.5-flash')
expect(message).toContain('Credentials: access token (stored securely)')
expect(message).not.toContain('AIza')
})
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({
processEnv: {

View File

@@ -36,6 +36,14 @@ import {
type ProfileFile,
type ProviderProfile,
} from '../../utils/providerProfile.js'
import {
getGeminiProjectIdHint,
mayHaveGeminiAdcCredentials,
} from '../../utils/geminiAuth.js'
import {
readGeminiAccessToken,
saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -60,8 +68,14 @@ type Step =
baseUrl: string | null
defaultModel: string
}
| { name: 'gemini-auth-method' }
| { name: 'gemini-key' }
| { name: 'gemini-model'; apiKey: string }
| { name: 'gemini-access-token' }
| {
name: 'gemini-model'
apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc'
}
| { name: 'codex-check' }
type CurrentProviderSummary = {
@@ -216,9 +230,13 @@ function buildSavedProfileSummary(
env,
),
credentialLabel:
maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
? 'configured'
: undefined,
env.GEMINI_AUTH_MODE === 'access-token'
? 'access token (stored securely)'
: env.GEMINI_AUTH_MODE === 'adc'
? 'local ADC'
: maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
? 'configured'
: undefined,
}
case 'codex':
return {
@@ -427,7 +445,7 @@ function ProviderChooser({
{
label: 'Gemini',
value: 'gemini',
description: 'Use a Google Gemini API key',
description: 'Use Google Gemini with API key, access token, or local ADC',
},
{
label: 'Codex',
@@ -926,7 +944,7 @@ export function ProviderWizard({
defaultModel: defaults.openAIModel,
})
} else if (value === 'gemini') {
setStep({ name: 'gemini-key' })
setStep({ name: 'gemini-auth-method' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1066,12 +1084,76 @@ export function ProviderWizard({
/>
)
case 'gemini-auth-method': {
const hasShellGeminiKey = Boolean(
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
)
const hasShellGeminiAccessToken = Boolean(process.env.GEMINI_ACCESS_TOKEN)
const hasStoredGeminiAccessToken = Boolean(readGeminiAccessToken())
const hasAdc = mayHaveGeminiAdcCredentials(process.env)
const projectHint = getGeminiProjectIdHint(process.env)
const options: OptionWithDescription[] = [
{
label: 'API key',
value: 'api-key',
description: hasShellGeminiKey
? 'Use the current Gemini API key from this shell, or enter a new one'
: 'Use a Google Gemini API key',
},
{
label: 'Access token',
value: 'access-token',
description: hasShellGeminiAccessToken || hasStoredGeminiAccessToken
? `Use ${
hasShellGeminiAccessToken
? 'the current GEMINI_ACCESS_TOKEN'
: 'the securely stored Gemini access token'
}`
: 'Enter a Gemini access token and store it securely',
},
{
label: 'Local ADC',
value: 'adc',
description: hasAdc
? `Use local Google ADC credentials${projectHint ? ` (project: ${projectHint})` : ''}`
: 'Use local Google ADC credentials after running gcloud auth application-default login',
},
]
return (
<Dialog title="Gemini setup" onCancel={() => onDone()}>
<Box flexDirection="column" gap={1}>
<Text>Choose how this Gemini profile should authenticate.</Text>
<Select
options={options}
inlineDescriptions
visibleOptionCount={options.length}
onChange={value => {
if (value === 'api-key') {
setStep({ name: 'gemini-key' })
} else if (value === 'access-token') {
setStep({ name: 'gemini-access-token' })
} else {
setStep({
name: 'gemini-model',
authMode: 'adc',
})
}
}}
onCancel={() => setStep({ name: 'choose' })}
/>
</Box>
</Dialog>
)
}
case 'gemini-key':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Gemini setup"
subtitle="Step 1 of 2"
subtitle="Step 1 of 3"
description={
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
? 'Enter a Gemini API key, or leave this blank to reuse the current GEMINI_API_KEY/GOOGLE_API_KEY from this session.'
@@ -1089,25 +1171,95 @@ export function ProviderWizard({
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
''
setStep({ name: 'gemini-model', apiKey })
setStep({ name: 'gemini-model', apiKey, authMode: 'api-key' })
}}
onCancel={() => setStep({ name: 'choose' })}
onCancel={() => setStep({ name: 'gemini-auth-method' })}
/>
)
case 'gemini-access-token': {
const currentToken =
process.env.GEMINI_ACCESS_TOKEN || readGeminiAccessToken() || ''
return (
<TextEntryDialog
resetStateKey={step.name}
title="Gemini setup"
subtitle="Step 2 of 3"
description={
currentToken
? 'Enter a Gemini access token, or leave this blank to reuse the current token from this session or secure storage.'
: 'Enter a Gemini access token. It will be stored securely for this profile.'
}
initialValue=""
placeholder="ya29...."
mask="*"
allowEmpty={Boolean(currentToken)}
validate={value => {
const token = value.trim() || currentToken
return token ? null : 'Enter a Gemini access token or go back and choose Local ADC.'
}}
onSubmit={value => {
const token = value.trim() || currentToken
const saved = saveGeminiAccessToken(token)
if (!saved.success) {
onDone(
`Failed to save Gemini access token: ${saved.warning ?? 'unknown error'}`,
{
display: 'system',
},
)
return
}
setStep({
name: 'gemini-model',
authMode: 'access-token',
})
}}
onCancel={() => setStep({ name: 'gemini-auth-method' })}
/>
)
}
case 'gemini-model':
return (
<TextEntryDialog
resetStateKey={step.name}
title="Gemini setup"
subtitle="Step 2 of 2"
description={`Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`}
subtitle={
step.authMode === 'api-key'
? 'Step 3 of 3'
: step.authMode === 'access-token'
? 'Step 3 of 3'
: 'Step 2 of 2'
}
description={
step.authMode === 'api-key'
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`
: step.authMode === 'access-token'
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use the stored Gemini access token at runtime.`
: `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use local Google ADC credentials at runtime.`
}
initialValue={defaults.geminiModel}
placeholder={DEFAULT_GEMINI_MODEL}
allowEmpty
onSubmit={value => {
if (
step.authMode === 'adc' &&
!mayHaveGeminiAdcCredentials(process.env)
) {
onDone(
'Local ADC credentials were not detected. Run `gcloud auth application-default login` first, then save the Gemini ADC profile again.',
{
display: 'system',
},
)
return
}
const env = buildGeminiProfileEnv({
apiKey: step.apiKey,
authMode: step.authMode,
model: value.trim() || DEFAULT_GEMINI_MODEL,
processEnv: {},
})
@@ -1115,7 +1267,13 @@ export function ProviderWizard({
finishProfileSave(onDone, 'gemini', env)
}
}}
onCancel={() => setStep({ name: 'gemini-key' })}
onCancel={() =>
step.authMode === 'api-key'
? setStep({ name: 'gemini-key' })
: step.authMode === 'access-token'
? setStep({ name: 'gemini-access-token' })
: setStep({ name: 'gemini-auth-method' })
}
/>
)

View File

@@ -1,14 +1,12 @@
import { feature } from 'bun:bundle';
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import {
applyProfileEnvToProcessEnv,
buildStartupEnvFromProfile,
redactSecretValueForDisplay,
} from '../utils/providerProfile.js'
import {
getProviderValidationError,
validateProviderEnvOrExit,
} from '../utils/providerValidation.js'
// OpenClaude: disable experimental API betas by default.
// Tool search (defer_loading), global cache scope, and context management
@@ -42,82 +40,6 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
}
}
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
}
function getProviderValidationError(
env: NodeJS.ProcessEnv = process.env,
): string | null {
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
if (!(env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY)) {
return 'GEMINI_API_KEY is required when CLAUDE_CODE_USE_GEMINI=1.'
}
return null
}
if (useGithub && !useOpenAI) {
const token = (env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()) ?? ''
if (!token) {
return 'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.'
}
return null
}
if (!useOpenAI) {
return null
}
const request = resolveProviderRequest({
model: env.OPENAI_MODEL,
baseUrl: env.OPENAI_BASE_URL,
})
if (env.OPENAI_API_KEY === 'SUA_CHAVE') {
return 'Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.'
}
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) {
const authHint = credentials.authPath
? ` or put auth.json at ${credentials.authPath}`
: ''
const safeModel =
redactSecretValueForDisplay(request.requestedModel, env) ??
'the requested model'
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
}
if (!credentials.accountId) {
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
}
return null
}
if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
const hasGithubToken = !!(env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim())
if (useGithub && hasGithubToken) {
return null
}
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
}
return null
}
function validateProviderEnvOrExit(): void {
const error = getProviderValidationError()
if (error) {
console.error(error)
process.exit(1)
}
}
/**
* Bootstrap entrypoint - checks for special flags before loading the full CLI.
* All imports are dynamic to minimize module evaluation for fast paths.
@@ -151,6 +73,8 @@ async function main(): Promise<void> {
enableConfigs()
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
applySafeConfigEnvironmentVariables()
const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../utils/geminiCredentials.js')
hydrateGeminiAccessTokenFromSecureStorage()
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
hydrateGithubModelsTokenFromSecureStorage()
}
@@ -159,7 +83,7 @@ async function main(): Promise<void> {
processEnv: process.env,
})
if (startupEnv !== process.env) {
const startupProfileError = getProviderValidationError(startupEnv)
const startupProfileError = await getProviderValidationError(startupEnv)
if (startupProfileError) {
console.error(
`Warning: ignoring saved provider profile. ${startupProfileError}`,
@@ -169,7 +93,7 @@ async function main(): Promise<void> {
}
}
validateProviderEnvOrExit()
await validateProviderEnvOrExit()
// Print the gradient startup screen before the Ink UI loads
const { printStartupScreen } = await import('../components/StartupScreen.js')

View File

@@ -6,10 +6,27 @@ type FetchType = typeof globalThis.fetch
const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL,
GEMINI_MODEL: process.env.GEMINI_MODEL,
GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
}
const originalFetch = globalThis.fetch
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
type OpenAIShimClient = {
beta: {
messages: {
@@ -52,11 +69,29 @@ function makeStreamChunks(chunks: unknown[]): string[] {
beforeEach(() => {
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
process.env.OPENAI_API_KEY = 'test-key'
delete process.env.OPENAI_MODEL
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
delete process.env.GEMINI_AUTH_MODE
delete process.env.GEMINI_BASE_URL
delete process.env.GEMINI_MODEL
delete process.env.GOOGLE_CLOUD_PROJECT
})
afterEach(() => {
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
globalThis.fetch = originalFetch
})
@@ -308,6 +343,76 @@ test('preserves image tool results as placeholders in follow-up requests', async
expect(toolMessage?.content).toContain('[image:image/png]')
})
test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => {
let capturedAuthorization: string | null = null
let capturedProject: string | null = null
let requestUrl: string | undefined
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'access-token'
process.env.GEMINI_ACCESS_TOKEN = 'gemini-access-token'
process.env.GOOGLE_CLOUD_PROJECT = 'gemini-project'
process.env.GEMINI_BASE_URL =
'https://generativelanguage.googleapis.com/v1beta/openai'
process.env.GEMINI_MODEL = 'gemini-2.0-flash'
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_KEY
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
globalThis.fetch = (async (input, init) => {
requestUrl = typeof input === 'string' ? input : input.url
const headers = init?.headers as Record<string, string> | undefined
capturedAuthorization =
headers?.Authorization ?? headers?.authorization ?? null
capturedProject =
headers?.['x-goog-user-project'] ??
headers?.['X-Goog-User-Project'] ??
null
return new Response(
JSON.stringify({
id: 'chatcmpl-gemini',
model: 'gemini-2.0-flash',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 3,
completion_tokens: 1,
total_tokens: 4,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gemini-2.0-flash',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 32,
stream: false,
})
expect(requestUrl).toBe(
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
)
expect(capturedAuthorization).toBe('Bearer gemini-access-token')
expect(capturedProject).toBe('gemini-project')
})
test('preserves Gemini tool call extra_content from streaming chunks', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([

View File

@@ -23,6 +23,8 @@
import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import {
codexStreamToAnthropic,
@@ -46,6 +48,7 @@ type SecretValueSource = Partial<{
CODEX_API_KEY: string
GEMINI_API_KEY: string
GOOGLE_API_KEY: string
GEMINI_ACCESS_TOKEN: string
}>
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
@@ -893,7 +896,9 @@ class OpenAIShimMessages {
...(options?.headers ?? {}),
}
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const apiKey =
this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
// path segments like https://evil.com/cognitiveservices.azure.com/
let isAzure = false
@@ -910,6 +915,14 @@ class OpenAIShimMessages {
} else {
headers.Authorization = `Bearer ${apiKey}`
}
} else if (isGemini) {
const geminiCredential = await resolveGeminiCredential(process.env)
if (geminiCredential.kind !== 'none') {
headers.Authorization = `Bearer ${geminiCredential.credential}`
if (geminiCredential.projectId) {
headers['x-goog-user-project'] = geminiCredential.projectId
}
}
}
if (isGithub) {
@@ -1105,6 +1118,7 @@ export function createOpenAIShimClient(options: {
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
providerOverride?: { model: string; baseURL: string; apiKey: string }
}): unknown {
hydrateGeminiAccessTokenFromSecureStorage()
hydrateGithubModelsTokenFromSecureStorage()
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
@@ -1113,8 +1127,11 @@ export function createOpenAIShimClient(options: {
process.env.OPENAI_BASE_URL ??=
process.env.GEMINI_BASE_URL ??
'https://generativelanguage.googleapis.com/v1beta/openai'
process.env.OPENAI_API_KEY ??=
process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? ''
const geminiApiKey =
process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
if (geminiApiKey && !process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = geminiApiKey
}
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
}

View File

@@ -0,0 +1,186 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
getGeminiProjectIdHint,
mayHaveGeminiAdcCredentials,
resolveGeminiCredential,
} from './geminiAuth.ts'
const existingFilePath = import.meta.path
const originalEnv = {
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
GCLOUD_PROJECT: process.env.GCLOUD_PROJECT,
GOOGLE_PROJECT_ID: process.env.GOOGLE_PROJECT_ID,
APPDATA: process.env.APPDATA,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv(
'GOOGLE_APPLICATION_CREDENTIALS',
originalEnv.GOOGLE_APPLICATION_CREDENTIALS,
)
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
restoreEnv('GCLOUD_PROJECT', originalEnv.GCLOUD_PROJECT)
restoreEnv('GOOGLE_PROJECT_ID', originalEnv.GOOGLE_PROJECT_ID)
restoreEnv('APPDATA', originalEnv.APPDATA)
})
describe('resolveGeminiCredential', () => {
test('prefers GEMINI_API_KEY over other Gemini auth inputs', async () => {
process.env.GEMINI_API_KEY = 'gem-key'
process.env.GOOGLE_API_KEY = 'google-key'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'api-key',
credential: 'gem-key',
})
})
test('uses GEMINI_ACCESS_TOKEN when no API key is configured', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_AUTH_MODE = 'access-token'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
process.env.GOOGLE_CLOUD_PROJECT = 'test-project'
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'access-token',
credential: 'token-123',
projectId: 'test-project',
})
})
test('falls back to ADC when available', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
process.env.GEMINI_AUTH_MODE = 'adc'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
async getProjectId() {
return 'adc-project'
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
})
})
test('returns none when no Gemini auth source is configured', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
delete process.env.GOOGLE_APPLICATION_CREDENTIALS
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'none',
})
})
test('access-token mode does not silently fall back to ADC', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
process.env.GEMINI_AUTH_MODE = 'access-token'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'none',
})
})
test('adc mode ignores GEMINI_ACCESS_TOKEN and uses ADC credentials', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_AUTH_MODE = 'adc'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
async getProjectId() {
return 'adc-project'
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
})
})
})
describe('Gemini auth helpers', () => {
test('detects explicit project id hints', () => {
process.env.GOOGLE_PROJECT_ID = 'project-a'
expect(getGeminiProjectIdHint(process.env)).toBe('project-a')
})
test('only treats existing ADC paths as valid hints', () => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
expect(mayHaveGeminiAdcCredentials(process.env)).toBe(true)
process.env.GOOGLE_APPLICATION_CREDENTIALS = `${existingFilePath}.missing`
process.env.APPDATA = undefined
expect(mayHaveGeminiAdcCredentials(process.env)).toBe(false)
})
})

216
src/utils/geminiAuth.ts Normal file
View File

@@ -0,0 +1,216 @@
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { memoizeWithTTLAsync } from './memoize.js'
const GEMINI_ADC_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
const GEMINI_ADC_CACHE_TTL_MS = 5 * 60 * 1000
export type GeminiAuthMode = 'api-key' | 'access-token' | 'adc'
type GoogleAccessTokenResult =
| string
| null
| undefined
| {
token?: string | null
}
type GoogleAuthClientLike = {
getAccessToken(): Promise<GoogleAccessTokenResult> | GoogleAccessTokenResult
}
type GoogleAuthLike = {
getClient(): Promise<GoogleAuthClientLike>
getProjectId?(): Promise<string>
}
export type GeminiResolvedCredential =
| {
kind: 'api-key'
credential: string
}
| {
kind: 'access-token' | 'adc'
credential: string
projectId?: string
}
| {
kind: 'none'
}
type ResolveGeminiCredentialDeps = {
createGoogleAuth?: () => Promise<GoogleAuthLike>
}
function sanitizeCredential(value: string | undefined | null): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
export function getGeminiProjectIdHint(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return (
sanitizeCredential(env.GOOGLE_CLOUD_PROJECT) ??
sanitizeCredential(env.GCLOUD_PROJECT) ??
sanitizeCredential(env.GOOGLE_PROJECT_ID)
)
}
export function getGeminiAuthMode(
env: NodeJS.ProcessEnv = process.env,
): GeminiAuthMode | undefined {
const normalized = sanitizeCredential(env.GEMINI_AUTH_MODE)?.toLowerCase()
if (
normalized === 'api-key' ||
normalized === 'access-token' ||
normalized === 'adc'
) {
return normalized
}
return undefined
}
export function getGeminiAdcCredentialPaths(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const explicit = sanitizeCredential(env.GOOGLE_APPLICATION_CREDENTIALS)
const paths = new Set<string>()
if (explicit) {
paths.add(explicit)
}
paths.add(join(homedir(), '.config', 'gcloud', 'application_default_credentials.json'))
const appData = sanitizeCredential(env.APPDATA)
if (appData) {
paths.add(join(appData, 'gcloud', 'application_default_credentials.json'))
}
return [...paths]
}
export function mayHaveGeminiAdcCredentials(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return getGeminiAdcCredentialPaths(env).some(path => existsSync(path))
}
function normalizeAccessToken(
value: GoogleAccessTokenResult,
): string | undefined {
if (typeof value === 'string') {
return sanitizeCredential(value)
}
return sanitizeCredential(value?.token)
}
async function createDefaultGoogleAuth(): Promise<GoogleAuthLike> {
const { GoogleAuth } = await import('google-auth-library')
return new GoogleAuth({
scopes: [GEMINI_ADC_SCOPE],
}) as GoogleAuthLike
}
async function resolveGeminiAdcCredentialUncached(
env: NodeJS.ProcessEnv,
deps: ResolveGeminiCredentialDeps,
): Promise<Exclude<GeminiResolvedCredential, { kind: 'none' | 'api-key' | 'access-token' }> | { kind: 'none' }> {
if (!mayHaveGeminiAdcCredentials(env)) {
return { kind: 'none' }
}
try {
const auth = await (deps.createGoogleAuth ?? createDefaultGoogleAuth)()
const client = await auth.getClient()
const accessToken = normalizeAccessToken(await client.getAccessToken())
if (!accessToken) {
return { kind: 'none' }
}
const hintedProjectId = getGeminiProjectIdHint(env)
const resolvedProjectId =
hintedProjectId ??
(typeof auth.getProjectId === 'function'
? sanitizeCredential(await auth.getProjectId().catch(() => undefined))
: undefined)
return {
kind: 'adc',
credential: accessToken,
...(resolvedProjectId ? { projectId: resolvedProjectId } : {}),
}
} catch {
return { kind: 'none' }
}
}
const resolveDefaultGeminiAdcCredential = memoizeWithTTLAsync(
async (
googleApplicationCredentials: string | undefined,
appData: string | undefined,
home: string,
projectIdHint: string | undefined,
) =>
resolveGeminiAdcCredentialUncached(
{
GOOGLE_APPLICATION_CREDENTIALS: googleApplicationCredentials,
APPDATA: appData,
GOOGLE_CLOUD_PROJECT: projectIdHint,
GCLOUD_PROJECT: projectIdHint,
GOOGLE_PROJECT_ID: projectIdHint,
HOME: home,
} as NodeJS.ProcessEnv,
{},
),
GEMINI_ADC_CACHE_TTL_MS,
)
export async function resolveGeminiCredential(
env: NodeJS.ProcessEnv = process.env,
deps: ResolveGeminiCredentialDeps = {},
): Promise<GeminiResolvedCredential> {
const authMode = getGeminiAuthMode(env)
const apiKey =
authMode === 'access-token' || authMode === 'adc'
? undefined
: sanitizeCredential(env.GEMINI_API_KEY) ??
sanitizeCredential(env.GOOGLE_API_KEY)
if (apiKey && (authMode === undefined || authMode === 'api-key')) {
return {
kind: 'api-key',
credential: apiKey,
}
}
const accessToken =
authMode === 'api-key' || authMode === 'adc'
? undefined
: sanitizeCredential(env.GEMINI_ACCESS_TOKEN)
if (accessToken && (authMode === undefined || authMode === 'access-token')) {
const projectId = getGeminiProjectIdHint(env)
return {
kind: 'access-token',
credential: accessToken,
...(projectId ? { projectId } : {}),
}
}
if (authMode === 'api-key' || authMode === 'access-token') {
return { kind: 'none' }
}
if (deps.createGoogleAuth) {
return resolveGeminiAdcCredentialUncached(env, deps)
}
return resolveDefaultGeminiAdcCredential(
sanitizeCredential(env.GOOGLE_APPLICATION_CREDENTIALS),
sanitizeCredential(env.APPDATA),
homedir(),
getGeminiProjectIdHint(env),
)
}

View File

@@ -0,0 +1,31 @@
import { afterEach, expect, test } from 'bun:test'
import {
clearGeminiAccessToken,
readGeminiAccessToken,
saveGeminiAccessToken,
} from './geminiCredentials.ts'
const originalToken = process.env.GEMINI_ACCESS_TOKEN
afterEach(() => {
if (originalToken === undefined) {
delete process.env.GEMINI_ACCESS_TOKEN
} else {
process.env.GEMINI_ACCESS_TOKEN = originalToken
}
clearGeminiAccessToken()
})
test('saveGeminiAccessToken stores and reads back the token', () => {
const result = saveGeminiAccessToken('token-123')
expect(result.success).toBe(true)
expect(readGeminiAccessToken()).toBe('token-123')
})
test('clearGeminiAccessToken removes the stored token', () => {
expect(saveGeminiAccessToken('token-123').success).toBe(true)
expect(clearGeminiAccessToken().success).toBe(true)
expect(readGeminiAccessToken()).toBeUndefined()
})

View File

@@ -0,0 +1,76 @@
import { isBareMode, isEnvTruthy } from './envUtils.js'
import { getGeminiAuthMode } from './geminiAuth.js'
import { getSecureStorage } from './secureStorage/index.js'
export const GEMINI_TOKEN_STORAGE_KEY = 'gemini' as const
export type GeminiCredentialBlob = {
accessToken: string
}
export function readGeminiAccessToken(): string | undefined {
if (isBareMode()) return undefined
try {
const data = getSecureStorage().read() as
| ({ gemini?: GeminiCredentialBlob } & Record<string, unknown>)
| null
const token = data?.gemini?.accessToken?.trim()
return token || undefined
} catch {
return undefined
}
}
export function hydrateGeminiAccessTokenFromSecureStorage(): void {
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return
}
const authMode = getGeminiAuthMode(process.env)
if (authMode && authMode !== 'access-token') {
return
}
if (process.env.GEMINI_ACCESS_TOKEN?.trim()) {
return
}
if (isBareMode()) {
return
}
const token = readGeminiAccessToken()
if (token) {
process.env.GEMINI_ACCESS_TOKEN = token
}
}
export function saveGeminiAccessToken(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 previous = secureStorage.read() || {}
const next = {
...(previous as Record<string, unknown>),
[GEMINI_TOKEN_STORAGE_KEY]: { accessToken: trimmed },
}
return secureStorage.update(next as typeof previous)
}
export function clearGeminiAccessToken(): {
success: boolean
warning?: string
} {
if (isBareMode()) {
return { success: true }
}
const secureStorage = getSecureStorage()
const previous = secureStorage.read() || {}
const next = { ...(previous as Record<string, unknown>) }
delete next[GEMINI_TOKEN_STORAGE_KEY]
return secureStorage.update(next as typeof previous)
}

View File

@@ -355,11 +355,38 @@ test('gemini profiles accept google api key fallback', () => {
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'api-key',
GEMINI_MODEL: 'gemini-2.0-flash',
GEMINI_API_KEY: 'gem-live',
})
})
test('gemini profiles support access-token auth mode without persisting a key', () => {
const env = buildGeminiProfileEnv({
authMode: 'access-token',
model: 'gemini-2.5-flash',
processEnv: {},
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'access-token',
GEMINI_MODEL: 'gemini-2.5-flash',
})
})
test('gemini profiles support adc auth mode without persisting a key', () => {
const env = buildGeminiProfileEnv({
authMode: 'adc',
model: 'gemini-2.5-flash',
processEnv: {},
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'adc',
GEMINI_MODEL: 'gemini-2.5-flash',
})
})
test('gemini profiles require a key', () => {
const env = buildGeminiProfileEnv({
processEnv: {},
@@ -405,6 +432,39 @@ test('buildStartupEnvFromProfile applies persisted gemini settings when no provi
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
})
test('buildStartupEnvFromProfile rehydrates stored Gemini access token for access-token profile mode', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', {
GEMINI_AUTH_MODE: 'access-token',
GEMINI_MODEL: 'gemini-2.5-flash',
}),
processEnv: {},
readGeminiAccessToken: () => 'token-live',
})
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_AUTH_MODE, 'access-token')
assert.equal(env.GEMINI_ACCESS_TOKEN, 'token-live')
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
})
test('buildStartupEnvFromProfile does not inject stored access token for adc profile mode', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', {
GEMINI_AUTH_MODE: 'adc',
GEMINI_MODEL: 'gemini-2.5-flash',
}),
processEnv: {},
readGeminiAccessToken: () => 'token-live',
})
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_AUTH_MODE, 'adc')
assert.equal(env.GEMINI_ACCESS_TOKEN, undefined)
assert.equal(env.GEMINI_API_KEY, undefined)
})
test('buildStartupEnvFromProfile leaves explicit provider selections untouched', async () => {
const processEnv = {
CLAUDE_CODE_USE_GEMINI: '1',

View File

@@ -12,6 +12,7 @@ import {
normalizeRecommendationGoal,
type RecommendationGoal,
} from './providerRecommendation.ts'
import { readGeminiAccessToken } from './geminiCredentials.ts'
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
@@ -32,6 +33,8 @@ const PROFILE_ENV_KEYS = [
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY',
'GEMINI_AUTH_MODE',
'GEMINI_ACCESS_TOKEN',
'GEMINI_MODEL',
'GEMINI_BASE_URL',
'GOOGLE_API_KEY',
@@ -54,6 +57,7 @@ export type ProfileEnv = {
CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
GEMINI_MODEL?: string
GEMINI_BASE_URL?: string
}
@@ -220,19 +224,22 @@ export function buildGeminiProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
authMode?: 'api-key' | 'access-token' | 'adc'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const authMode = options.authMode ?? 'api-key'
const key = sanitizeApiKey(
options.apiKey ??
processEnv.GEMINI_API_KEY ??
processEnv.GOOGLE_API_KEY,
)
if (!key) {
if (authMode === 'api-key' && !key) {
return null
}
const env: ProfileEnv = {
GEMINI_AUTH_MODE: authMode,
GEMINI_MODEL:
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
sanitizeProviderConfigValue(
@@ -241,7 +248,10 @@ export function buildGeminiProfileEnv(options: {
processEnv,
) ||
DEFAULT_GEMINI_MODEL,
GEMINI_API_KEY: key,
}
if (authMode === 'api-key' && key) {
env.GEMINI_API_KEY = key
}
const baseUrl =
@@ -422,6 +432,7 @@ export async function buildLaunchEnv(options: {
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
resolveAtomicChatDefaultModel?: () => Promise<string | null>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options.processEnv ?? process.env
const persistedEnv =
@@ -460,11 +471,16 @@ export async function buildLaunchEnv(options: {
processEnv.GEMINI_BASE_URL,
processEnv,
)
const shellGeminiAccessToken =
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
const storedGeminiAccessToken =
options.readGeminiAccessToken?.() ?? readGeminiAccessToken()
const shellGeminiKey = sanitizeApiKey(
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
)
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
const persistedGeminiAuthMode = persistedEnv.GEMINI_AUTH_MODE
if (options.profile === 'gemini') {
const env: NodeJS.ProcessEnv = {
@@ -484,12 +500,29 @@ export async function buildLaunchEnv(options: {
persistedGeminiBaseUrl ||
DEFAULT_GEMINI_BASE_URL
const geminiAuthMode =
persistedGeminiAuthMode === 'access-token' ||
persistedGeminiAuthMode === 'adc'
? persistedGeminiAuthMode
: 'api-key'
const geminiKey = shellGeminiKey || persistedGeminiKey
if (geminiKey) {
if (geminiAuthMode === 'api-key' && geminiKey) {
env.GEMINI_API_KEY = geminiKey
} else {
delete env.GEMINI_API_KEY
}
env.GEMINI_AUTH_MODE = geminiAuthMode
if (geminiAuthMode === 'access-token') {
const geminiAccessToken =
shellGeminiAccessToken || storedGeminiAccessToken
if (geminiAccessToken) {
env.GEMINI_ACCESS_TOKEN = geminiAccessToken
} else {
delete env.GEMINI_ACCESS_TOKEN
}
} else {
delete env.GEMINI_ACCESS_TOKEN
}
delete env.GOOGLE_API_KEY
delete env.OPENAI_BASE_URL
@@ -510,6 +543,8 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
delete env.GEMINI_MODEL
delete env.GEMINI_BASE_URL
delete env.GOOGLE_API_KEY
@@ -624,6 +659,7 @@ export async function buildStartupEnvFromProfile(options?: {
processEnv?: NodeJS.ProcessEnv
getOllamaChatBaseUrl?: (baseUrl?: string) => string
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options?.processEnv ?? process.env
if (hasExplicitProviderSelection(processEnv)) {
@@ -645,6 +681,7 @@ export async function buildStartupEnvFromProfile(options?: {
getOllamaChatBaseUrl:
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
readGeminiAccessToken: options?.readGeminiAccessToken,
})
}

View File

@@ -0,0 +1,73 @@
import { afterEach, expect, test } from 'bun:test'
import { getProviderValidationError } from './providerValidation.ts'
const originalEnv = {
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv(
'GOOGLE_APPLICATION_CREDENTIALS',
originalEnv.GOOGLE_APPLICATION_CREDENTIALS,
)
})
test('accepts GEMINI_ACCESS_TOKEN as valid Gemini auth', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'access-token'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
})
test('accepts ADC credentials for Gemini auth', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'adc'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
await expect(
getProviderValidationError(process.env, {
resolveGeminiCredential: async () => ({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
}),
}),
).resolves.toBeNull()
})
test('still errors when no Gemini credential source is available', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'access-token'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
delete process.env.GOOGLE_APPLICATION_CREDENTIALS
await expect(getProviderValidationError(process.env)).resolves.toBe(
'GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_ACCESS_TOKEN, or Google ADC credentials are required when CLAUDE_CODE_USE_GEMINI=1.',
)
})

View File

@@ -0,0 +1,96 @@
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import {
type GeminiResolvedCredential,
resolveGeminiCredential,
} from './geminiAuth.js'
import { redactSecretValueForDisplay } from './providerProfile.js'
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
}
export async function getProviderValidationError(
env: NodeJS.ProcessEnv = process.env,
options?: {
resolveGeminiCredential?: (
env: NodeJS.ProcessEnv,
) => Promise<GeminiResolvedCredential>
},
): Promise<string | null> {
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
const geminiCredential = await (
options?.resolveGeminiCredential ?? resolveGeminiCredential
)(env)
if (geminiCredential.kind === 'none') {
return 'GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_ACCESS_TOKEN, or Google ADC credentials are required when CLAUDE_CODE_USE_GEMINI=1.'
}
return null
}
if (useGithub && !useOpenAI) {
const token = (env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()) ?? ''
if (!token) {
return 'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.'
}
return null
}
if (!useOpenAI) {
return null
}
const request = resolveProviderRequest({
model: env.OPENAI_MODEL,
baseUrl: env.OPENAI_BASE_URL,
})
if (env.OPENAI_API_KEY === 'SUA_CHAVE') {
return 'Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.'
}
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) {
const authHint = credentials.authPath
? ` or put auth.json at ${credentials.authPath}`
: ''
const safeModel =
redactSecretValueForDisplay(request.requestedModel, env) ??
'the requested model'
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
}
if (!credentials.accountId) {
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
}
return null
}
if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
const hasGithubToken = !!(env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim())
if (useGithub && hasGithubToken) {
return null
}
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
}
return null
}
export async function validateProviderEnvOrExit(
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
const error = await getProviderValidationError(env)
if (error) {
console.error(error)
process.exit(1)
}
}