mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +00:00
Add Databricks auth support and custom header option to gemini cli (#11893)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
@@ -464,6 +464,18 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
|
|||||||
- Specifies the default Gemini model to use.
|
- Specifies the default Gemini model to use.
|
||||||
- Overrides the hardcoded default
|
- Overrides the hardcoded default
|
||||||
- Example: `export GEMINI_MODEL="gemini-2.5-flash"`
|
- Example: `export GEMINI_MODEL="gemini-2.5-flash"`
|
||||||
|
- **`GEMINI_CLI_CUSTOM_HEADERS`**:
|
||||||
|
- Adds extra HTTP headers to Gemini API and Code Assist requests.
|
||||||
|
- Accepts a comma-separated list of `Name: value` pairs.
|
||||||
|
- Example:
|
||||||
|
`export GEMINI_CLI_CUSTOM_HEADERS="X-My-Header: foo, X-Trace-ID: abc123"`.
|
||||||
|
- **`GEMINI_API_KEY_AUTH_MECHANISM`**:
|
||||||
|
- Specifies how the API key should be sent for authentication when using
|
||||||
|
`AuthType.USE_GEMINI` or `AuthType.USE_VERTEX_AI`.
|
||||||
|
- Valid values are `x-goog-api-key` (default) or `bearer`.
|
||||||
|
- If set to `bearer`, the API key will be sent in the
|
||||||
|
`Authorization: Bearer <key>` header.
|
||||||
|
- Example: `export GEMINI_API_KEY_AUTH_MECHANISM="bearer"`
|
||||||
- **`GOOGLE_API_KEY`**:
|
- **`GOOGLE_API_KEY`**:
|
||||||
- Your Google Cloud API key.
|
- Your Google Cloud API key.
|
||||||
- Required for using Vertex AI in express mode.
|
- Required for using Vertex AI in express mode.
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ vi.mock('./fakeContentGenerator.js');
|
|||||||
const mockConfig = {} as unknown as Config;
|
const mockConfig = {} as unknown as Config;
|
||||||
|
|
||||||
describe('createContentGenerator', () => {
|
describe('createContentGenerator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('should create a FakeContentGenerator', async () => {
|
it('should create a FakeContentGenerator', async () => {
|
||||||
const mockGenerator = {} as unknown as ContentGenerator;
|
const mockGenerator = {} as unknown as ContentGenerator;
|
||||||
vi.mocked(FakeContentGenerator.fromFile).mockResolvedValue(
|
vi.mocked(FakeContentGenerator.fromFile).mockResolvedValue(
|
||||||
@@ -135,6 +143,152 @@ describe('createContentGenerator', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => {
|
||||||
|
const mockGenerator = {} as unknown as ContentGenerator;
|
||||||
|
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
|
||||||
|
mockGenerator as never,
|
||||||
|
);
|
||||||
|
vi.stubEnv(
|
||||||
|
'GEMINI_CLI_CUSTOM_HEADERS',
|
||||||
|
'X-Test-Header: test-value, Another-Header: another value',
|
||||||
|
);
|
||||||
|
|
||||||
|
await createContentGenerator(
|
||||||
|
{
|
||||||
|
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
},
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createCodeAssistContentGenerator).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'User-Agent': expect.any(String),
|
||||||
|
'X-Test-Header': 'test-value',
|
||||||
|
'Another-Header': 'another value',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
mockConfig,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for GoogleGenAI requests without inferring auth mechanism', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const mockGenerator = {
|
||||||
|
models: {},
|
||||||
|
} as unknown as GoogleGenAI;
|
||||||
|
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||||
|
vi.stubEnv(
|
||||||
|
'GEMINI_CLI_CUSTOM_HEADERS',
|
||||||
|
'X-Test-Header: test, Another: value',
|
||||||
|
);
|
||||||
|
|
||||||
|
await createContentGenerator(
|
||||||
|
{
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
authType: AuthType.USE_GEMINI,
|
||||||
|
},
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
vertexai: undefined,
|
||||||
|
httpOptions: {
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'User-Agent': expect.any(String),
|
||||||
|
'X-Test-Header': 'test',
|
||||||
|
Another: 'value',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({
|
||||||
|
httpOptions: {
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.any(String),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass api key as Authorization Header when GEMINI_API_KEY_AUTH_MECHANISM is set to bearer', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const mockGenerator = {
|
||||||
|
models: {},
|
||||||
|
} as unknown as GoogleGenAI;
|
||||||
|
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||||
|
vi.stubEnv('GEMINI_API_KEY_AUTH_MECHANISM', 'bearer');
|
||||||
|
|
||||||
|
await createContentGenerator(
|
||||||
|
{
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
authType: AuthType.USE_GEMINI,
|
||||||
|
},
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
vertexai: undefined,
|
||||||
|
httpOptions: {
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'User-Agent': expect.any(String),
|
||||||
|
Authorization: 'Bearer test-api-key',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not pass api key as Authorization Header when GEMINI_API_KEY_AUTH_MECHANISM is not set (default behavior)', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const mockGenerator = {
|
||||||
|
models: {},
|
||||||
|
} as unknown as GoogleGenAI;
|
||||||
|
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||||
|
// GEMINI_API_KEY_AUTH_MECHANISM is not stubbed, so it will be undefined, triggering default 'x-goog-api-key'
|
||||||
|
|
||||||
|
await createContentGenerator(
|
||||||
|
{
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
authType: AuthType.USE_GEMINI,
|
||||||
|
},
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(GoogleGenAI).toHaveBeenCalledWith({
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
vertexai: undefined,
|
||||||
|
httpOptions: {
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'User-Agent': expect.any(String),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Explicitly assert that Authorization header is NOT present
|
||||||
|
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({
|
||||||
|
httpOptions: {
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.any(String),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should create a GoogleGenAI content generator with client install id logging disabled', async () => {
|
it('should create a GoogleGenAI content generator with client install id logging disabled', async () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { UserTierId } from '../code_assist/types.js';
|
|||||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||||
import { InstallationManager } from '../utils/installationManager.js';
|
import { InstallationManager } from '../utils/installationManager.js';
|
||||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||||
|
import { parseCustomHeaders } from '../utils/customHeaderUtils.js';
|
||||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,10 +116,26 @@ export async function createContentGenerator(
|
|||||||
return FakeContentGenerator.fromFile(gcConfig.fakeResponses);
|
return FakeContentGenerator.fromFile(gcConfig.fakeResponses);
|
||||||
}
|
}
|
||||||
const version = process.env['CLI_VERSION'] || process.version;
|
const version = process.env['CLI_VERSION'] || process.version;
|
||||||
|
const customHeadersEnv =
|
||||||
|
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
|
||||||
const userAgent = `GeminiCLI/${version} (${process.platform}; ${process.arch})`;
|
const userAgent = `GeminiCLI/${version} (${process.platform}; ${process.arch})`;
|
||||||
|
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
|
||||||
|
const apiKeyAuthMechanism =
|
||||||
|
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
|
||||||
|
|
||||||
const baseHeaders: Record<string, string> = {
|
const baseHeaders: Record<string, string> = {
|
||||||
|
...customHeadersMap,
|
||||||
'User-Agent': userAgent,
|
'User-Agent': userAgent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
apiKeyAuthMechanism === 'bearer' &&
|
||||||
|
(config.authType === AuthType.USE_GEMINI ||
|
||||||
|
config.authType === AuthType.USE_VERTEX_AI) &&
|
||||||
|
config.apiKey
|
||||||
|
) {
|
||||||
|
baseHeaders['Authorization'] = `Bearer ${config.apiKey}`;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
config.authType === AuthType.LOGIN_WITH_GOOGLE ||
|
config.authType === AuthType.LOGIN_WITH_GOOGLE ||
|
||||||
config.authType === AuthType.COMPUTE_ADC
|
config.authType === AuthType.COMPUTE_ADC
|
||||||
|
|||||||
91
packages/core/src/utils/customHeaderUtils.test.ts
Normal file
91
packages/core/src/utils/customHeaderUtils.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { parseCustomHeaders } from './customHeaderUtils.js';
|
||||||
|
|
||||||
|
describe('parseCustomHeaders', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty object if input is undefined', () => {
|
||||||
|
expect(parseCustomHeaders(undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty object if input is empty string', () => {
|
||||||
|
expect(parseCustomHeaders('')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a single header correctly', () => {
|
||||||
|
const input = 'Authorization: Bearer abc123';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
Authorization: 'Bearer abc123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple headers separated by commas', () => {
|
||||||
|
const input =
|
||||||
|
'Authorization: Bearer abc123, Content-Type: application/json';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
Authorization: 'Bearer abc123',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore entries without colon', () => {
|
||||||
|
const input = 'Authorization Bearer abc123, Content-Type: application/json';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace around names and values', () => {
|
||||||
|
const input =
|
||||||
|
' Authorization : Bearer abc123 , Content-Type : application/json ';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
Authorization: 'Bearer abc123',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle headers with colons in the value', () => {
|
||||||
|
const input = 'X-Custom: value:with:colons, Authorization: Bearer xyz';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
'X-Custom': 'value:with:colons',
|
||||||
|
Authorization: 'Bearer xyz',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip headers with empty name', () => {
|
||||||
|
const input = ': no-name, Authorization: Bearer abc';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
Authorization: 'Bearer abc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip completely empty entries', () => {
|
||||||
|
const input = ', , Authorization: Bearer abc';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
Authorization: 'Bearer abc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Authorization Bearer with different casing', () => {
|
||||||
|
const input = 'authorization: Bearer token123';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
authorization: 'Bearer token123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle values with commas correctly', () => {
|
||||||
|
const input = 'X-Header: value,with,commas, Authorization: Bearer abc';
|
||||||
|
expect(parseCustomHeaders(input)).toEqual({
|
||||||
|
'X-Header': 'value,with,commas',
|
||||||
|
Authorization: 'Bearer abc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
packages/core/src/utils/customHeaderUtils.ts
Normal file
41
packages/core/src/utils/customHeaderUtils.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses custom headers and returns a map of key and vallues
|
||||||
|
*/
|
||||||
|
export function parseCustomHeaders(
|
||||||
|
envValue: string | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (!envValue) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the string on commas that are followed by a header key (key:),
|
||||||
|
// but ignore commas that are part of a header value (including values with colons or commas)
|
||||||
|
for (const entry of envValue.split(/,(?=\s*[^,:]+:)/)) {
|
||||||
|
const trimmedEntry = entry.trim();
|
||||||
|
if (!trimmedEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmedEntry.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = trimmedEntry.slice(0, separatorIndex).trim();
|
||||||
|
const value = trimmedEntry.slice(separatorIndex + 1).trim();
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user