diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index f57badb689..211d877071 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -45,6 +45,7 @@ Environment variables can override these settings. | `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | | `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | | `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` | +| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - | **Note on boolean environment variables:** For boolean settings like `enabled`, setting the environment variable to `true` or `1` enables the feature. @@ -216,6 +217,50 @@ recommend using file-based output for local development. For advanced local telemetry setups (such as Jaeger or Genkit), see the [Local development guide](../local-development.md#viewing-traces). +## Client identification + +Gemini CLI includes identifiers in its `User-Agent` header to help you +differentiate and report on API traffic from different environments (for +example, identifying calls from Gemini Code Assist versus a standard terminal). + +### Automatic identification + +Most integrated environments are identified automatically without additional +configuration. The identifier is included as a prefix to the `User-Agent` and as +a "surface" tag in the parenthetical metadata. + +| Environment | User-Agent Prefix | Surface Tag | +| :---------------------------------- | :--------------------------- | :---------- | +| **Gemini Code Assist (Agent Mode)** | `GeminiCLI-a2a-server` | `vscode` | +| **Zed (via ACP)** | `GeminiCLI-acp-zed` | `zed` | +| **XCode (via ACP)** | `GeminiCLI-acp-xcode` | `xcode` | +| **IntelliJ IDEA (via ACP)** | `GeminiCLI-acp-intellijidea` | `jetbrains` | +| **Standard Terminal** | `GeminiCLI` | `terminal` | + +**Example User-Agent:** +`GeminiCLI-a2a-server/0.34.0/gemini-pro (linux; x64; vscode)` + +### Custom identification + +You can provide a custom identifier for your own scripts or automation by +setting the `GEMINI_CLI_SURFACE` environment variable. This is useful for +tracking specific internal tools or distribution channels in your GCP logs. + +**macOS/Linux** + +```bash +export GEMINI_CLI_SURFACE="my-custom-tool" +``` + +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_SURFACE="my-custom-tool" +``` + +When set, the value appears at the end of the `User-Agent` parenthetical: +`GeminiCLI/0.34.0/gemini-pro (linux; x64; my-custom-tool)` + ## Logs, metrics, and traces This section describes the structure of logs, metrics, and traces generated by diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 767630e773..6e70c9ee05 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1384,6 +1384,13 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - Useful for shared compute environments or keeping CLI state isolated. - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`) +- **`GEMINI_CLI_SURFACE`**: + - Specifies a custom label to include in the `User-Agent` header for API + traffic reporting. + - This is useful for tracking specific internal tools or distribution + channels. + - Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell: + `$env:GEMINI_CLI_SURFACE="my-custom-tool"`) - **`GOOGLE_API_KEY`**: - Your Google Cloud API key. - Required for using Vertex AI in express mode. diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index ee63df36f7..bd8771d1b5 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -91,6 +91,15 @@ describe('loadConfig', () => { expect(fetchAdminControlsOnce).not.toHaveBeenCalled(); }); + it('should pass clientName as a2a-server to Config', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + clientName: 'a2a-server', + }), + ); + }); + describe('when admin controls experiment is enabled', () => { beforeEach(() => { // We need to cast to any here to modify the mock implementation diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 5b6757701d..607695f173 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -62,6 +62,7 @@ export async function loadConfig( const configParams: ConfigParameters = { sessionId: taskId, + clientName: 'a2a-server', model: PREVIEW_GEMINI_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 22ff209cb6..422f6cd2ac 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3616,3 +3616,54 @@ describe('loadCliConfig mcpEnabled', () => { }); }); }); + +describe('loadCliConfig acpMode and clientName', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should set acpMode to true and detect clientName when --acp flag is used', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'vscode'); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBe('acp-vscode'); + }); + + it('should set acpMode to true but leave clientName undefined for generic terminals', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBeUndefined(); + }); + + it('should set acpMode to false and clientName to undefined by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(false); + expect(config.getClientName()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2ebc4d4b22..010fb8e17f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -40,6 +40,7 @@ import { type HookDefinition, type HookEventName, type OutputFormat, + detectIdeFromEnv, } from '@google/gemini-cli-core'; import { type Settings, @@ -710,8 +711,21 @@ export async function loadCliConfig( } } + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; + let clientName: string | undefined = undefined; + if (isAcpMode) { + const ide = detectIdeFromEnv(); + if ( + ide && + (ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode') + ) { + clientName = `acp-${ide.name}`; + } + } + return new Config({ - acpMode: !!argv.acp || !!argv.experimentalAcp, + acpMode: isAcpMode, + clientName, sessionId, clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 33839ff75f..066d273b82 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -502,6 +502,7 @@ export interface PolicyUpdateConfirmationRequest { export interface ConfigParameters { sessionId: string; + clientName?: string; clientVersion?: string; embeddingModel?: string; sandbox?: SandboxConfig; @@ -646,6 +647,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly acknowledgedAgentsService: AcknowledgedAgentsService; private skillManager!: SkillManager; private _sessionId: string; + private readonly clientName: string | undefined; private clientVersion: string; private fileSystemService: FileSystemService; private trackerService?: TrackerService; @@ -843,6 +845,7 @@ export class Config implements McpContext, AgentLoopContext { constructor(params: ConfigParameters) { this._sessionId = params.sessionId; + this.clientName = params.clientName; this.clientVersion = params.clientVersion ?? 'unknown'; this.approvedPlanPath = undefined; this.embeddingModel = @@ -1408,6 +1411,10 @@ export class Config implements McpContext, AgentLoopContext { return this.promptId; } + getClientName(): string | undefined { + return this.clientName; + } + setSessionId(sessionId: string): void { this._sessionId = sessionId; } diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 0d470ec934..57ce1fed23 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -33,6 +33,7 @@ const mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; describe('createContentGenerator', () => { @@ -53,6 +54,7 @@ describe('createContentGenerator', () => { const fakeResponsesFile = 'fake/responses.yaml'; const mockConfigWithFake = { fakeResponses: fakeResponsesFile, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const generator = await createContentGenerator( { @@ -74,6 +76,7 @@ describe('createContentGenerator', () => { const mockConfigWithRecordResponses = { fakeResponses: fakeResponsesFile, recordResponses: recordResponsesFile, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const generator = await createContentGenerator( { @@ -123,6 +126,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => true, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; // Set a fixed version for testing @@ -144,7 +148,9 @@ describe('createContentGenerator', () => { vertexai: undefined, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ - 'User-Agent': expect.stringContaining('GeminiCLI/1.2.3/gemini-pro'), + 'User-Agent': expect.stringMatching( + /GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; .*\)/, + ), }), }), }); @@ -153,6 +159,40 @@ describe('createContentGenerator', () => { ); }); + it('should include clientName prefix in User-Agent when specified', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => true, + getClientName: vi.fn().mockReturnValue('a2a-server'), + } as unknown as Config; + + // Set a fixed version for testing + vi.stubEnv('CLI_VERSION', '1.2.3'); + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + await createContentGenerator( + { apiKey: 'test-api-key', authType: AuthType.USE_GEMINI }, + mockConfig, + undefined, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringMatching( + /GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/, + ), + }), + }), + }), + ); + }); + 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( @@ -189,6 +229,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -235,6 +276,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -268,6 +310,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -309,6 +352,7 @@ describe('createContentGenerator', () => { const mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { models: {}, @@ -340,6 +384,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -373,6 +418,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -410,6 +456,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -448,6 +495,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -478,6 +526,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -511,6 +560,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -540,6 +590,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'http://evil-proxy.example.com'); @@ -560,6 +611,7 @@ describe('createContentGenerator', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; const mockGenerator = { @@ -596,6 +648,7 @@ describe('createContentGeneratorConfig', () => { setModel: vi.fn(), flashFallbackHandler: vi.fn(), getProxy: vi.fn(), + getClientName: vi.fn().mockReturnValue(undefined), } as unknown as Config; beforeEach(() => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index d7da9fb064..f61fa950eb 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -22,6 +22,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js'; import { InstallationManager } from '../utils/installationManager.js'; import { FakeContentGenerator } from './fakeContentGenerator.js'; import { parseCustomHeaders } from '../utils/customHeaderUtils.js'; +import { determineSurface } from '../utils/surface.js'; import { RecordingContentGenerator } from './recordingContentGenerator.js'; import { getVersion, resolveModel } from '../../index.js'; import type { LlmRole } from '../telemetry/llmRole.js'; @@ -173,7 +174,12 @@ export async function createContentGenerator( ); const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; - const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; + const clientName = gcConfig.getClientName(); + const userAgentPrefix = clientName + ? `GeminiCLI-${clientName}` + : 'GeminiCLI'; + const surface = determineSurface(); + const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`; const customHeadersMap = parseCustomHeaders(customHeadersEnv); const apiKeyAuthMechanism = process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key'; diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 0b27b27560..764a85bf7a 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -140,6 +140,21 @@ describe('detectIde', () => { expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity); }); + it('should detect Zed via ZED_SESSION_ID', () => { + vi.stubEnv('ZED_SESSION_ID', 'test-session-id'); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.zed); + }); + + it('should detect Zed via TERM_PROGRAM', () => { + vi.stubEnv('TERM_PROGRAM', 'Zed'); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.zed); + }); + + it('should detect XCode via XCODE_VERSION_ACTUAL', () => { + vi.stubEnv('XCODE_VERSION_ACTUAL', '1500'); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.xcode); + }); + it('should detect JetBrains IDE via TERMINAL_EMULATOR', () => { vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index c07ef8254c..924e90aa6b 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -27,6 +27,8 @@ export const IDE_DEFINITIONS = { rustrover: { name: 'rustrover', displayName: 'RustRover' }, datagrip: { name: 'datagrip', displayName: 'DataGrip' }, phpstorm: { name: 'phpstorm', displayName: 'PhpStorm' }, + zed: { name: 'zed', displayName: 'Zed' }, + xcode: { name: 'xcode', displayName: 'XCode' }, } as const; export interface IdeInfo { @@ -75,6 +77,12 @@ export function detectIdeFromEnv(): IdeInfo { if (process.env['TERM_PROGRAM'] === 'sublime') { return IDE_DEFINITIONS.sublimetext; } + if (process.env['ZED_SESSION_ID'] || process.env['TERM_PROGRAM'] === 'Zed') { + return IDE_DEFINITIONS.zed; + } + if (process.env['XCODE_VERSION_ACTUAL']) { + return IDE_DEFINITIONS.xcode; + } if (isJetBrains()) { return IDE_DEFINITIONS.jetbrains; } @@ -147,10 +155,13 @@ export function detectIde( }; } - // Only VS Code, Sublime Text and JetBrains integrations are currently supported. + // Only VS Code, Sublime Text, JetBrains, Zed, and XCode integrations are currently supported. if ( process.env['TERM_PROGRAM'] !== 'vscode' && process.env['TERM_PROGRAM'] !== 'sublime' && + process.env['TERM_PROGRAM'] !== 'Zed' && + !process.env['ZED_SESSION_ID'] && + !process.env['XCODE_VERSION_ACTUAL'] && !isJetBrains() ) { return undefined; diff --git a/packages/core/src/utils/surface.ts b/packages/core/src/utils/surface.ts new file mode 100644 index 0000000000..e4b1241d84 --- /dev/null +++ b/packages/core/src/utils/surface.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { detectIdeFromEnv } from '../ide/detect-ide.js'; + +/** Default surface value when no IDE/environment is detected. */ +export const SURFACE_NOT_SET = 'terminal'; + +/** + * Determines the surface/distribution channel the CLI is running in. + * + * Priority: + * 1. `GEMINI_CLI_SURFACE` env var (first-class override for enterprise customers) + * 2. `SURFACE` env var (legacy override, kept for backward compatibility) + * 3. Auto-detection via environment variables (Cloud Shell, GitHub Actions, IDE, etc.) + * + * @returns A human-readable surface identifier (e.g., "vscode", "cursor", "terminal"). + */ +export function determineSurface(): string { + // Priority 1 & 2: Explicit overrides from environment variables. + const customSurface = + process.env['GEMINI_CLI_SURFACE'] || process.env['SURFACE']; + if (customSurface) { + return customSurface; + } + + // Priority 3: Auto-detect IDE/environment. + const ide = detectIdeFromEnv(); + + // `detectIdeFromEnv` falls back to 'vscode' for generic terminals. + // If a specific IDE (e.g., Cloud Shell, Cursor, JetBrains) was detected, + // its name will be something other than 'vscode', and we can use it directly. + if (ide.name !== 'vscode') { + return ide.name; + } + + // If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it. + // This prevents generic terminals from being misidentified as VSCode. + if (process.env['TERM_PROGRAM'] === 'vscode') { + return ide.name; + } + + // Priority 4: GitHub Actions (checked after IDE detection so that + // specific environments like Cloud Shell take precedence). + if (process.env['GITHUB_SHA']) { + return 'GitHub'; + } + + // Priority 5: Fallback for all other cases (e.g., a generic terminal). + return SURFACE_NOT_SET; +}