feat(core): differentiate User-Agent for a2a-server and ACP clients (#22059)

This commit is contained in:
Bryan Morgan
2026-03-11 22:31:59 -04:00
committed by GitHub
parent f090736ebc
commit 949e85ca55
12 changed files with 277 additions and 4 deletions
+45
View File
@@ -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
+7
View File
@@ -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.
@@ -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
+1
View File
@@ -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
+51
View File
@@ -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();
});
});
+15 -1
View File
@@ -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,
+7
View File
@@ -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;
}
@@ -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(() => {
+7 -1
View File
@@ -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';
+15
View File
@@ -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);
+12 -1
View File
@@ -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;
+54
View File
@@ -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;
}