diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f2347d35..bccba9e6 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -71,6 +71,7 @@ import { checkForUpdates } from './utils/updateCheck.js'; import ansiEscapes from 'ansi-escapes'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; +import { PrivacyNotice } from './privacy/PrivacyNotice.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -130,6 +131,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const ctrlDTimerRef = useRef(null); const [constrainHeight, setConstrainHeight] = useState(true); + const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); + + const openPrivacyNotice = useCallback(() => { + setShowPrivacyNotice(true); + }, []); const errorCount = useMemo( () => consoleMessages.filter((msg) => msg.type === 'error').length, @@ -277,6 +283,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { toggleCorgiMode, showToolDescriptions, setQuittingMessages, + openPrivacyNotice, ); const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; @@ -712,6 +719,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { onExit={exitEditorDialog} /> + ) : showPrivacyNotice ? ( + setShowPrivacyNotice(false)} + config={config} + /> ) : ( <> void, showToolDescriptions: boolean = false, setQuittingMessages: (message: HistoryItem[]) => void, + openPrivacyNotice: () => void, ) => { const session = useSessionStats(); const gitService = useMemo(() => { @@ -254,6 +255,13 @@ export const useSlashCommandProcessor = ( openEditorDialog(); }, }, + { + name: 'privacy', + description: 'display the privacy notice', + action: (_mainCommand, _subCommand, _args) => { + openPrivacyNotice(); + }, + }, { name: 'stats', altName: 'usage', @@ -1022,6 +1030,7 @@ export const useSlashCommandProcessor = ( setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, + openPrivacyNotice, ]); const handleSlashCommand = useCallback( diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.ts b/packages/cli/src/ui/hooks/usePrivacySettings.ts new file mode 100644 index 00000000..44824def --- /dev/null +++ b/packages/cli/src/ui/hooks/usePrivacySettings.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GaxiosError } from 'gaxios'; +import { useState, useEffect, useCallback } from 'react'; +import { Config, CodeAssistServer, UserTierId } from '@google/gemini-cli-core'; + +export interface PrivacyState { + isLoading: boolean; + error?: string; + isFreeTier?: boolean; + dataCollectionOptIn?: boolean; +} + +export const usePrivacySettings = (config: Config) => { + const [privacyState, setPrivacyState] = useState({ + isLoading: true, + }); + + useEffect(() => { + const fetchInitialState = async () => { + setPrivacyState({ + isLoading: true, + }); + try { + const server = getCodeAssistServer(config); + const tier = await getTier(server); + if (tier !== UserTierId.FREE) { + // We don't need to fetch opt-out info since non-free tier + // data gathering is already worked out some other way. + setPrivacyState({ + isLoading: false, + isFreeTier: false, + }); + return; + } + + const optIn = await getRemoteDataCollectionOptIn(server); + setPrivacyState({ + isLoading: false, + isFreeTier: true, + dataCollectionOptIn: optIn, + }); + } catch (e) { + setPrivacyState({ + isLoading: false, + error: e instanceof Error ? e.message : String(e), + }); + } + }; + fetchInitialState(); + }, [config]); + + const updateDataCollectionOptIn = useCallback( + async (optIn: boolean) => { + try { + const server = getCodeAssistServer(config); + const updatedOptIn = await setRemoteDataCollectionOptIn(server, optIn); + setPrivacyState({ + isLoading: false, + isFreeTier: true, + dataCollectionOptIn: updatedOptIn, + }); + } catch (e) { + setPrivacyState({ + isLoading: false, + error: e instanceof Error ? e.message : String(e), + }); + } + }, + [config], + ); + + return { + privacyState, + updateDataCollectionOptIn, + }; +}; + +function getCodeAssistServer(config: Config): CodeAssistServer { + const server = config.getGeminiClient().getContentGenerator(); + // Neither of these cases should ever happen. + if (!(server instanceof CodeAssistServer)) { + throw new Error('Oauth not being used'); + } else if (!server.projectId) { + throw new Error('Oauth not being used'); + } + return server; +} + +async function getTier(server: CodeAssistServer): Promise { + const loadRes = await server.loadCodeAssist({ + cloudaicompanionProject: server.projectId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: server.projectId, + }, + }); + if (!loadRes.currentTier) { + throw new Error('User does not have a current tier'); + } + return loadRes.currentTier.id; +} + +async function getRemoteDataCollectionOptIn( + server: CodeAssistServer, +): Promise { + try { + const resp = await server.getCodeAssistGlobalUserSetting(); + return resp.freeTierDataCollectionOptin; + } catch (e) { + if (e instanceof GaxiosError) { + if (e.response?.status === 404) { + return true; + } + } + throw e; + } +} + +async function setRemoteDataCollectionOptIn( + server: CodeAssistServer, + optIn: boolean, +): Promise { + const resp = await server.setCodeAssistGlobalUserSetting({ + cloudaicompanionProject: server.projectId, + freeTierDataCollectionOptin: optIn, + }); + return resp.freeTierDataCollectionOptin; +} diff --git a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx new file mode 100644 index 00000000..f9341bf9 --- /dev/null +++ b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Newline, Text } from 'ink'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { usePrivacySettings } from '../hooks/usePrivacySettings.js'; +import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js'; +import { Config } from '@google/gemini-cli-core'; +import { Colors } from '../colors.js'; + +interface CloudFreePrivacyNoticeProps { + config: Config; + onExit: () => void; +} + +export const CloudFreePrivacyNotice = ({ + config, + onExit, +}: CloudFreePrivacyNoticeProps) => { + const { privacyState, updateDataCollectionOptIn } = + usePrivacySettings(config); + + if (privacyState.isLoading) { + return Loading...; + } + + if (privacyState.error) { + return ( + + Error loading Opt-in settings: {privacyState.error} + + ); + } + + if (privacyState.isFreeTier === false) { + return ; + } + + const items = [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ]; + + return ( + + + Gemini Code Assist for Individuals Privacy Notice + + + + This notice and our Privacy Policy + [1] describe how Gemini Code + Assist handles your data. Please read them carefully. + + + + When you use Gemini Code Assist for individuals with Gemini CLI, Google + collects your prompts, related code, generated output, code edits, + related feature usage information, and your feedback to provide, + improve, and develop Google products and services and machine learning + technologies. + + + + To help with quality and improve our products (such as generative + machine-learning models), human reviewers may read, annotate, and + process the data collected above. We take steps to protect your privacy + as part of this process. This includes disconnecting the data from your + Google Account before reviewers see or annotate it, and storing those + disconnected copies for up to 18 months. Please don't submit + confidential information or any data you wouldn't want a reviewer + to see or Google to use to improve our products, services and + machine-learning technologies. + + + + + Allow Google to use this data to develop and improve our products? + + { + updateDataCollectionOptIn(value); + // Only exit if there was no error. + if (!privacyState.error) { + onExit(); + } + }} + /> + + + + [1]{' '} + https://policies.google.com/privacy + + + Press Enter to choose an option and exit. + + ); +}; diff --git a/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx new file mode 100644 index 00000000..e50dcd4b --- /dev/null +++ b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Newline, Text, useInput } from 'ink'; +import { Colors } from '../colors.js'; + +interface CloudPaidPrivacyNoticeProps { + onExit: () => void; +} + +export const CloudPaidPrivacyNotice = ({ + onExit, +}: CloudPaidPrivacyNoticeProps) => { + useInput((input, key) => { + if (key.escape) { + onExit(); + } + }); + + return ( + + + Vertex AI Notice + + + + Service Specific Terms[1] are + incorporated into the agreement under which Google has agreed to provide + Google Cloud Platform[2] to + Customer (the “Agreement”). If the Agreement authorizes the resale or + supply of Google Cloud Platform under a Google Cloud partner or reseller + program, then except for in the section entitled “Partner-Specific + Terms”, all references to Customer in the Service Specific Terms mean + Partner or Reseller (as applicable), and all references to Customer Data + in the Service Specific Terms mean Partner Data. Capitalized terms used + but not defined in the Service Specific Terms have the meaning given to + them in the Agreement. + + + + [1]{' '} + https://cloud.google.com/terms/service-terms + + + [2]{' '} + https://cloud.google.com/terms/services + + + Press Esc to exit. + + ); +}; diff --git a/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx new file mode 100644 index 00000000..57030ac3 --- /dev/null +++ b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Newline, Text, useInput } from 'ink'; +import { Colors } from '../colors.js'; + +interface GeminiPrivacyNoticeProps { + onExit: () => void; +} + +export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => { + useInput((input, key) => { + if (key.escape) { + onExit(); + } + }); + + return ( + + + Gemini API Key Notice + + + + By using the Gemini API[1], + Google AI Studio + [2], and the other Google + developer services that reference these terms (collectively, the + "APIs" or "Services"), you are agreeing to Google + APIs Terms of Service (the "API Terms") + [3], and the Gemini API + Additional Terms of Service (the "Additional Terms") + [4]. + + + + [1]{' '} + https://ai.google.dev/docs/gemini_api_overview + + + [2] https://aistudio.google.com/ + + + [3]{' '} + https://developers.google.com/terms + + + [4]{' '} + https://ai.google.dev/gemini-api/terms + + + Press Esc to exit. + + ); +}; diff --git a/packages/cli/src/ui/privacy/PrivacyNotice.tsx b/packages/cli/src/ui/privacy/PrivacyNotice.tsx new file mode 100644 index 00000000..b12b3648 --- /dev/null +++ b/packages/cli/src/ui/privacy/PrivacyNotice.tsx @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box } from 'ink'; +import { type Config, AuthType } from '@google/gemini-cli-core'; +import { GeminiPrivacyNotice } from './GeminiPrivacyNotice.js'; +import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js'; +import { CloudFreePrivacyNotice } from './CloudFreePrivacyNotice.js'; + +interface PrivacyNoticeProps { + onExit: () => void; + config: Config; +} + +const PrivacyNoticeText = ({ + config, + onExit, +}: { + config: Config; + onExit: () => void; +}) => { + const authType = config.getContentGeneratorConfig()?.authType; + + switch (authType) { + case AuthType.USE_GEMINI: + return ; + case AuthType.USE_VERTEX_AI: + return ; + case AuthType.LOGIN_WITH_GOOGLE_PERSONAL: + default: + return ; + } +}; + +export const PrivacyNotice = ({ onExit, config }: PrivacyNoticeProps) => ( + + + +); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 5798289a..1eaf9217 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -6,28 +6,30 @@ import { AuthClient } from 'google-auth-library'; import { - LoadCodeAssistResponse, + CodeAssistGlobalUserSettingResponse, LoadCodeAssistRequest, - OnboardUserRequest, + LoadCodeAssistResponse, LongrunningOperationResponse, + OnboardUserRequest, + SetCodeAssistGlobalUserSettingRequest, } from './types.js'; import { - GenerateContentResponse, - GenerateContentParameters, CountTokensParameters, - EmbedContentResponse, CountTokensResponse, EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponse, } from '@google/genai'; import * as readline from 'readline'; import { ContentGenerator } from '../core/contentGenerator.js'; import { + CaCountTokenResponse, CaGenerateContentResponse, - toGenerateContentRequest, + fromCountTokenResponse, fromGenerateContentResponse, toCountTokenRequest, - fromCountTokenResponse, - CaCountTokenResponse, + toGenerateContentRequest, } from './converter.js'; import { PassThrough } from 'node:stream'; @@ -93,6 +95,21 @@ export class CodeAssistServer implements ContentGenerator { ); } + async getCodeAssistGlobalUserSetting(): Promise { + return await this.getEndpoint( + 'getCodeAssistGlobalUserSetting', + ); + } + + async setCodeAssistGlobalUserSetting( + req: SetCodeAssistGlobalUserSettingRequest, + ): Promise { + return await this.callEndpoint( + 'setCodeAssistGlobalUserSetting', + req, + ); + } + async countTokens(req: CountTokensParameters): Promise { const resp = await this.callEndpoint( 'countTokens', @@ -126,6 +143,20 @@ export class CodeAssistServer implements ContentGenerator { return res.data as T; } + async getEndpoint(method: string, signal?: AbortSignal): Promise { + const res = await this.auth.request({ + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...this.httpOptions.headers, + }, + responseType: 'json', + signal, + }); + return res.data as T; + } + async streamEndpoint( method: string, req: object, diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index 67257a50..4c395e57 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -173,3 +173,13 @@ export interface HelpLinkUrl { description: string; url: string; } + +export interface SetCodeAssistGlobalUserSettingRequest { + cloudaicompanionProject?: string; + freeTierDataCollectionOptin: boolean; +} + +export interface CodeAssistGlobalUserSettingResponse { + cloudaicompanionProject?: string; + freeTierDataCollectionOptin: boolean; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b266512c..75b73a85 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -226,11 +226,6 @@ export class Config { } async refreshAuth(authMethod: AuthType) { - // Check if this is actually a switch to a different auth method - const previousAuthType = this.contentGeneratorConfig?.authType; - const _isAuthMethodSwitch = - previousAuthType && previousAuthType !== authMethod; - // Always use the original default model when switching auth methods // This ensures users don't stay on Flash after switching between auth types // and allows API key users to get proper fallback behavior from getEffectiveModel diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ae67c3b4..1a629b2c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -71,7 +71,8 @@ export class GeminiClient { ); this.chat = await this.startChat(); } - private getContentGenerator(): ContentGenerator { + + getContentGenerator(): ContentGenerator { if (!this.contentGenerator) { throw new Error('Content generator not initialized'); } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 7021adc2..82fe5ee9 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -70,7 +70,6 @@ export async function createContentGeneratorConfig( return contentGeneratorConfig; } - // if (authType === AuthType.USE_GEMINI && geminiApiKey) { contentGeneratorConfig.apiKey = geminiApiKey; contentGeneratorConfig.model = await getEffectiveModel( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a123452..aff37f50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,8 @@ export * from './core/nonInteractiveToolExecutor.js'; export * from './code_assist/codeAssist.js'; export * from './code_assist/oauth2.js'; +export * from './code_assist/server.js'; +export * from './code_assist/types.js'; // Export utilities export * from './utils/paths.js';