mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(core): Add support for listing experiments (#12495)
This commit is contained in:
55
packages/core/src/code_assist/experiments/client_metadata.ts
Normal file
55
packages/core/src/code_assist/experiments/client_metadata.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getReleaseChannel } from '../../utils/channel.js';
|
||||
import type { ClientMetadata, Platform } from './types.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Cache all client metadata.
|
||||
let clientMetadataPromise: Promise<ClientMetadata> | undefined;
|
||||
|
||||
function getPlatform(): Platform {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
if (platform === 'darwin' && arch === 'x64') {
|
||||
return 'DARWIN_AMD64';
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return 'DARWIN_ARM64';
|
||||
}
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return 'LINUX_AMD64';
|
||||
}
|
||||
if (platform === 'linux' && arch === 'arm64') {
|
||||
return 'LINUX_ARM64';
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') {
|
||||
return 'WINDOWS_AMD64';
|
||||
}
|
||||
return 'PLATFORM_UNSPECIFIED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the client metadata.
|
||||
*
|
||||
* The client metadata is cached so that it is only computed once per session.
|
||||
*/
|
||||
export async function getClientMetadata(): Promise<ClientMetadata> {
|
||||
if (!clientMetadataPromise) {
|
||||
clientMetadataPromise = (async () => ({
|
||||
ide_type: 'GEMINI_CLI',
|
||||
ide_version: process.env['CLI_VERSION'] || process.version,
|
||||
platform: getPlatform(),
|
||||
update_channel: await getReleaseChannel(__dirname),
|
||||
}))();
|
||||
}
|
||||
return await clientMetadataPromise;
|
||||
}
|
||||
49
packages/core/src/code_assist/experiments/experiments.ts
Normal file
49
packages/core/src/code_assist/experiments/experiments.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CodeAssistServer } from '../server.js';
|
||||
import { getClientMetadata } from './client_metadata.js';
|
||||
import type { ListExperimentsResponse, Flag } from './types.js';
|
||||
|
||||
export interface Experiments {
|
||||
flags: Record<string, Flag>;
|
||||
experimentIds: number[];
|
||||
}
|
||||
|
||||
let experimentsPromise: Promise<Experiments> | undefined;
|
||||
|
||||
/**
|
||||
* Gets the experiments from the server.
|
||||
*
|
||||
* The experiments are cached so that they are only fetched once.
|
||||
*/
|
||||
export async function getExperiments(
|
||||
server: CodeAssistServer,
|
||||
): Promise<Experiments> {
|
||||
if (experimentsPromise) {
|
||||
return await experimentsPromise;
|
||||
}
|
||||
|
||||
experimentsPromise = (async () => {
|
||||
const metadata = await getClientMetadata();
|
||||
const response = await server.listExperiments(metadata);
|
||||
return parseExperiments(response);
|
||||
})();
|
||||
return await experimentsPromise;
|
||||
}
|
||||
|
||||
function parseExperiments(response: ListExperimentsResponse): Experiments {
|
||||
const flags: Record<string, Flag> = {};
|
||||
for (const flag of response.flags ?? []) {
|
||||
if (flag.name) {
|
||||
flags[flag.name] = flag;
|
||||
}
|
||||
}
|
||||
return {
|
||||
flags,
|
||||
experimentIds: response.experiment_ids ?? [],
|
||||
};
|
||||
}
|
||||
58
packages/core/src/code_assist/experiments/types.ts
Normal file
58
packages/core/src/code_assist/experiments/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface ListExperimentsRequest {
|
||||
project: string;
|
||||
metadata?: ClientMetadata;
|
||||
}
|
||||
|
||||
export interface ListExperimentsResponse {
|
||||
experiment_ids?: number[];
|
||||
flags?: Flag[];
|
||||
filtered_flags?: FilteredFlag[];
|
||||
debug_string?: string;
|
||||
}
|
||||
|
||||
export interface Flag {
|
||||
name?: string;
|
||||
bool_value?: boolean;
|
||||
float_value?: number;
|
||||
int_value?: string; // int64
|
||||
string_value?: string;
|
||||
int32_list_value?: Int32List;
|
||||
string_list_value?: StringList;
|
||||
}
|
||||
|
||||
export interface Int32List {
|
||||
values?: number[];
|
||||
}
|
||||
|
||||
export interface StringList {
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
export interface FilteredFlag {
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ClientMetadata {
|
||||
ide_type?: IdeType;
|
||||
ide_version?: string;
|
||||
platform?: Platform;
|
||||
update_channel?: 'nightly' | 'preview' | 'stable';
|
||||
duet_project?: string;
|
||||
}
|
||||
|
||||
export type IdeType = 'GEMINI_CLI';
|
||||
|
||||
export type Platform =
|
||||
| 'PLATFORM_UNSPECIFIED'
|
||||
| 'DARWIN_AMD64'
|
||||
| 'DARWIN_ARM64'
|
||||
| 'LINUX_AMD64'
|
||||
| 'LINUX_ARM64'
|
||||
| 'WINDOWS_AMD64';
|
||||
@@ -252,4 +252,30 @@ describe('CodeAssistServer', () => {
|
||||
currentTier: { id: UserTierId.STANDARD },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the listExperiments endpoint with metadata', async () => {
|
||||
const client = new OAuth2Client();
|
||||
const server = new CodeAssistServer(
|
||||
client,
|
||||
'test-project',
|
||||
{},
|
||||
'test-session',
|
||||
UserTierId.FREE,
|
||||
);
|
||||
const mockResponse = {
|
||||
experiments: [],
|
||||
};
|
||||
vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse);
|
||||
|
||||
const metadata = {
|
||||
ide_version: 'v0.1.0',
|
||||
};
|
||||
const response = await server.listExperiments(metadata);
|
||||
|
||||
expect(server.requestPost).toHaveBeenCalledWith('listExperiments', {
|
||||
project: 'test-project',
|
||||
metadata: { ide_version: 'v0.1.0', duet_project: 'test-project' },
|
||||
});
|
||||
expect(response).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
OnboardUserRequest,
|
||||
SetCodeAssistGlobalUserSettingRequest,
|
||||
} from './types.js';
|
||||
import type {
|
||||
ListExperimentsRequest,
|
||||
ListExperimentsResponse,
|
||||
ClientMetadata,
|
||||
} from './experiments/types.js';
|
||||
import type {
|
||||
CountTokensParameters,
|
||||
CountTokensResponse,
|
||||
@@ -149,6 +154,23 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
throw Error();
|
||||
}
|
||||
|
||||
async listExperiments(
|
||||
metadata: ClientMetadata,
|
||||
): Promise<ListExperimentsResponse> {
|
||||
if (!this.projectId) {
|
||||
throw new Error('projectId is not defined for CodeAssistServer.');
|
||||
}
|
||||
const projectId = this.projectId;
|
||||
const req: ListExperimentsRequest = {
|
||||
project: projectId,
|
||||
metadata: { ...metadata, duet_project: projectId },
|
||||
};
|
||||
return await this.requestPost<ListExperimentsResponse>(
|
||||
'listExperiments',
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
async requestPost<T>(
|
||||
method: string,
|
||||
req: object,
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { isNightly, isPreview, isStable, _clearCache } from './channel.js';
|
||||
import {
|
||||
ReleaseChannel,
|
||||
getReleaseChannel,
|
||||
isNightly,
|
||||
isPreview,
|
||||
isStable,
|
||||
_clearCache,
|
||||
} from './channel.js';
|
||||
import * as packageJson from './package.js';
|
||||
|
||||
vi.mock('./package.js', () => ({
|
||||
@@ -132,6 +139,54 @@ describe('channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReleaseChannel', () => {
|
||||
it('should return STABLE for a stable version', async () => {
|
||||
vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.STABLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return NIGHTLY for a nightly version', async () => {
|
||||
vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({
|
||||
name: 'test',
|
||||
version: '1.0.0-nightly.1',
|
||||
});
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.NIGHTLY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return PREVIEW for a preview version', async () => {
|
||||
vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({
|
||||
name: 'test',
|
||||
version: '1.0.0-preview.1',
|
||||
});
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.PREVIEW,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return NIGHTLY if package.json is not found', async () => {
|
||||
vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue(undefined);
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.NIGHTLY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return NIGHTLY if version is not defined', async () => {
|
||||
vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({
|
||||
name: 'test',
|
||||
});
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.NIGHTLY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should only call getPackageJson once for the same cwd', async () => {
|
||||
const spy = vi
|
||||
@@ -141,6 +196,9 @@ describe('channel', () => {
|
||||
await expect(isStable('/test/dir')).resolves.toBe(true);
|
||||
await expect(isNightly('/test/dir')).resolves.toBe(false);
|
||||
await expect(isPreview('/test/dir')).resolves.toBe(false);
|
||||
await expect(getReleaseChannel('/test/dir')).resolves.toBe(
|
||||
ReleaseChannel.STABLE,
|
||||
);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function _clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
async function _getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
|
||||
export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
|
||||
if (cache.has(cwd)) {
|
||||
return cache.get(cwd)!;
|
||||
}
|
||||
@@ -43,13 +43,13 @@ async function _getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
|
||||
}
|
||||
|
||||
export async function isNightly(cwd: string): Promise<boolean> {
|
||||
return (await _getReleaseChannel(cwd)) === ReleaseChannel.NIGHTLY;
|
||||
return (await getReleaseChannel(cwd)) === ReleaseChannel.NIGHTLY;
|
||||
}
|
||||
|
||||
export async function isPreview(cwd: string): Promise<boolean> {
|
||||
return (await _getReleaseChannel(cwd)) === ReleaseChannel.PREVIEW;
|
||||
return (await getReleaseChannel(cwd)) === ReleaseChannel.PREVIEW;
|
||||
}
|
||||
|
||||
export async function isStable(cwd: string): Promise<boolean> {
|
||||
return (await _getReleaseChannel(cwd)) === ReleaseChannel.STABLE;
|
||||
return (await getReleaseChannel(cwd)) === ReleaseChannel.STABLE;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user