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:
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user