From 8957ad7fc377622f9653641c7a349ca70c4ffa44 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Tue, 1 Jul 2025 15:28:15 -0700 Subject: [PATCH 1/8] Docs: Add a page detailing quota and cost information (#2894) Co-authored-by: Jenna Inouye --- docs/quota-and-pricing.md | 70 +++++++++++++++++++++++++++++++++++++++ docs/tos-privacy.md | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 docs/quota-and-pricing.md diff --git a/docs/quota-and-pricing.md b/docs/quota-and-pricing.md new file mode 100644 index 0000000000..c7dc359088 --- /dev/null +++ b/docs/quota-and-pricing.md @@ -0,0 +1,70 @@ +# Gemini CLI: Quotas and Pricing + +Your Gemini CLI quotas and pricing depends on the type of account you use to authenticate with Google. Additionally, both quotas and pricing may may be calculated differently based on the model version, requests, and tokens used. A summary of model usage is available through the `/stats` command and presented on exit at the end of a session. See [privacy and terms](./tos-privacy.md) for details on Privacy policy and Terms of Service. Note: published prices are list price; additional negotiated commercial discounting may apply. + +This article outlines the specific quotas and pricing applicable to the Gemini CLI when using different authentication methods. + +## 1. Log in with Google (Gemini Code Assist Free Tier) + +For users who authenticate by using their Google account to access Gemini Code Assist for individuals: + +- **Quota:** + - 60 requests per minute + - 1000 requests per day + - Token usage is not applicable +- **Cost:** Free +- **Details:** [Gemini Code Assist Quotas](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli) +- **Notes:** A specific quota for different models is not specified; model fallback may occur to preserve shared experience quality. + +## 2. Gemini API Key (Unpaid) + +If you are using a Gemini API key for the free tier: + +- **Quota:** + - Flash model only + - 10 requests per minute + - 250 requests per day +- **Cost:** Free +- **Details:** [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits) + +## 3. Gemini API Key (Paid) + +If you are using a Gemini API key with a paid plan: + +- **Quota:** Varies by pricing tier. +- **Cost:** Varies by pricing tier and model/token usage. +- **Details:** [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits), [Gemini API Pricing](https://ai.google.dev/gemini-api/docs/pricing) + +## 4. Login with Google (for Workspace or Licensed Code Assist users) + +For users of Standard or Enterprise editions of Gemini Code Assist, quotas and pricing are based on a fixed price subscription with assigned license seats: + +- **Standard Tier:** + - **Quota:** 120 requests per minute, 1500 per day +- **Enterprise Tier:** + - **Quota:** 120 requests per minute, 2000 per day +- **Cost:** Fixed price included with your Gemini for Google Workspace or Gemini Code Assist subscription. +- **Details:** [Gemini Code Assist Quotas](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli), [Gemini Code Assist Pricing](https://cloud.google.com/products/gemini/pricing) +- **Notes:** + - Specific quota for different models is not specified; model fallback may occur to preserve shared experience quality. + - Members of the Google Developer Program may have Gemini Code Assist licenses through their membership. + +## 5. Vertex AI (Express Mode) + +If you are using Vertex AI in Express Mode: + +- **Quota:** Quotas are variable and specific to your account. See the source for more details. +- **Cost:** After your Express Mode usage is consumed and you enable billing for your project, cost is based on standard [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). +- **Details:** [Vertex AI Express Mode Quotas](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas) + +## 6. Vertex AI (Regular Mode) + +If you are using the standard Vertex AI service: + +- **Quota:** Governed by a dynamic shared quota system or pre-purchased provisioned throughput. +- **Cost:** Based on model and token usage. See [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). +- **Details:** [Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota) + +## 7. Google One and Ultra plans, Gemini for Workspace plans + +These plans currently apply only to the use of Gemini web-based products provided by Google-based experiences (for example, the Gemini web app or the Flow video editor). These plans do not apply to the API usage which powers the Gemini CLI. Supporting these plans is under active consideration for future support. diff --git a/docs/tos-privacy.md b/docs/tos-privacy.md index b77ed8d277..200a4a71cb 100644 --- a/docs/tos-privacy.md +++ b/docs/tos-privacy.md @@ -1,6 +1,6 @@ # Gemini CLI: Terms of Service and Privacy Notice -Gemini CLI is an open-source tool that allows you to interact with Google's powerful language models directly from your command-line interface. The terms of service and privacy notices that apply to your usage of Gemini CLI depend on the type of account you use to authenticate with Google. +Gemini CLI is an open-source tool that lets you interact with Google's powerful language models directly from your command-line interface. The Terms of Service and Privacy notices that apply to your usage of Gemini CLI depend on the type of account you use to authenticate with Google. See [quota and pricing](./quota-and-pricing.md) for details on the quota and pricing details that apply to your usage of Gemini CLI. This article outlines the specific terms and privacy policies applicable for different auth methods. From 383306e17ea8bf88c8f7bc24a4873246af3cc983 Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Tue, 1 Jul 2025 15:51:43 -0700 Subject: [PATCH 2/8] Improve slashCommand autoCompletion logic (#2776) --- packages/cli/src/ui/components/InputPrompt.tsx | 17 +++++++++++------ packages/cli/src/ui/hooks/useCompletion.ts | 3 ++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d687cca91f..095c8cccf4 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -113,7 +113,7 @@ export const InputPrompt: React.FC = ({ return; } const query = buffer.text; - const selectedSuggestion = completionSuggestions[indexToUse]; + const suggestion = completionSuggestions[indexToUse].value; if (query.trimStart().startsWith('/')) { const parts = query.trimStart().substring(1).split(' '); @@ -122,11 +122,16 @@ export const InputPrompt: React.FC = ({ const base = query.substring(0, slashIndex + 1); const command = slashCommands.find((cmd) => cmd.name === commandName); - if (command && command.completion) { - const newValue = `${base}${commandName} ${selectedSuggestion.value}`; - buffer.setText(newValue); + // Make sure completion isn't the original command when command.completigion hasn't happened yet. + if (command && command.completion && suggestion !== commandName) { + const newValue = `${base}${commandName} ${suggestion}`; + if (newValue === query) { + handleSubmitAndClear(newValue); + } else { + buffer.setText(newValue); + } } else { - const newValue = base + selectedSuggestion.value; + const newValue = base + suggestion; buffer.setText(newValue); handleSubmitAndClear(newValue); } @@ -142,7 +147,7 @@ export const InputPrompt: React.FC = ({ buffer.replaceRangeByOffset( autoCompleteStartIndex, buffer.text.length, - selectedSuggestion.value, + suggestion, ); } resetCompletionState(); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 8e18e32ffc..27a1c70866 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -135,7 +135,8 @@ export function useCompletion( (cmd) => cmd.name === commandName || cmd.altName === commandName, ); - if (command && command.completion) { + // Continue to show command help until user types past command name. + if (command && command.completion && parts.length > 1) { const fetchAndSetSuggestions = async () => { setIsLoadingSuggestions(true); if (command.completion) { From 82afc753505c5a83770a9aa66b81a43d906ff9de Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Tue, 1 Jul 2025 16:05:33 -0700 Subject: [PATCH 3/8] Special case mime type for ts file. (#2902) --- packages/core/src/utils/fileUtils.test.ts | 5 +++++ packages/core/src/utils/fileUtils.ts | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 0455b6e1e0..c1381e8566 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -196,6 +196,11 @@ describe('fileUtils', () => { vi.restoreAllMocks(); // Restore spies on actualNodeFs }); + it('should detect typescript type by extension (ts)', () => { + expect(detectFileType('file.ts')).toBe('text'); + expect(detectFileType('file.test.ts')).toBe('text'); + }); + it('should detect image type by extension (png)', () => { mockMimeLookup.mockReturnValueOnce('image/png'); expect(detectFileType('file.png')).toBe('image'); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 5a05d5134b..72f2943681 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -100,8 +100,14 @@ export function detectFileType( filePath: string, ): 'text' | 'image' | 'pdf' | 'audio' | 'video' | 'binary' { const ext = path.extname(filePath).toLowerCase(); - const lookedUpMimeType = mime.lookup(filePath); // Returns false if not found, or the mime type string + // The mimetype for "ts" is MPEG transport stream (a video format) but we want + // to assume these are typescript files instead. + if (ext === '.ts') { + return 'text'; + } + + const lookedUpMimeType = mime.lookup(filePath); // Returns false if not found, or the mime type string if (lookedUpMimeType) { if (lookedUpMimeType.startsWith('image/')) { return 'image'; From 3a995305c00a4e19d9309491e41338abc892237d Mon Sep 17 00:00:00 2001 From: Billy Biggs Date: Tue, 1 Jul 2025 19:07:41 -0400 Subject: [PATCH 4/8] Fix characters being dropped in text-buffer (#2504) Co-authored-by: Sandy Tao Co-authored-by: Jacob Richman --- .../src/ui/components/shared/text-buffer.ts | 232 ++++++++---------- 1 file changed, 98 insertions(+), 134 deletions(-) diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 15fc6d3cec..7767fd2df1 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -420,6 +420,7 @@ export function useTextBuffer({ const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const historyLimit = 100; + const [opQueue, setOpQueue] = useState([]); const [clipboard, setClipboard] = useState(null); const [selectionAnchor, setSelectionAnchor] = useState< @@ -526,148 +527,110 @@ export function useTextBuffer({ return _restoreState(state); }, [redoStack, lines, cursorRow, cursorCol, _restoreState]); - const insertStr = useCallback( - (str: string): boolean => { - dbg('insertStr', { str, beforeCursor: [cursorRow, cursorCol] }); - if (str === '') return false; + const applyOperations = useCallback((ops: UpdateOperation[]) => { + if (ops.length === 0) return; + setOpQueue((prev) => [...prev, ...ops]); + }, []); - pushUndo(); - let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - normalised = stripUnsafeCharacters(normalised); + useEffect(() => { + if (opQueue.length === 0) return; - const parts = normalised.split('\n'); - - const newLines = [...lines]; - const lineContent = currentLine(cursorRow); - const before = cpSlice(lineContent, 0, cursorCol); - const after = cpSlice(lineContent, cursorCol); - newLines[cursorRow] = before + parts[0]; - - if (parts.length > 1) { - // Adjusted condition for inserting multiple lines - const remainingParts = parts.slice(1); - const lastPartOriginal = remainingParts.pop() ?? ''; - newLines.splice(cursorRow + 1, 0, ...remainingParts); - newLines.splice( - cursorRow + parts.length - 1, - 0, - lastPartOriginal + after, - ); - setCursorRow(cursorRow + parts.length - 1); - setCursorCol(cpLen(lastPartOriginal)); - } else { - setCursorCol(cpLen(before) + cpLen(parts[0])); - } - setLines(newLines); - setPreferredCol(null); - return true; - }, - [pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol], - ); - - const applyOperations = useCallback( - (ops: UpdateOperation[]) => { - if (ops.length === 0) return; - - const expandedOps: UpdateOperation[] = []; - for (const op of ops) { - if (op.type === 'insert') { - let currentText = ''; - for (const char of toCodePoints(op.payload)) { - if (char.codePointAt(0) === 127) { - // \x7f - if (currentText.length > 0) { - expandedOps.push({ type: 'insert', payload: currentText }); - currentText = ''; - } - expandedOps.push({ type: 'backspace' }); - } else { - currentText += char; + const expandedOps: UpdateOperation[] = []; + for (const op of opQueue) { + if (op.type === 'insert') { + let currentText = ''; + for (const char of toCodePoints(op.payload)) { + if (char.codePointAt(0) === 127) { + // \x7f + if (currentText.length > 0) { + expandedOps.push({ type: 'insert', payload: currentText }); + currentText = ''; } - } - if (currentText.length > 0) { - expandedOps.push({ type: 'insert', payload: currentText }); - } - } else { - expandedOps.push(op); - } - } - - if (expandedOps.length === 0) { - return; - } - - pushUndo(); // Snapshot before applying batch of updates - - const newLines = [...lines]; - let newCursorRow = cursorRow; - let newCursorCol = cursorCol; - - const currentLine = (r: number) => newLines[r] ?? ''; - - for (const op of expandedOps) { - if (op.type === 'insert') { - const str = stripUnsafeCharacters( - op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), - ); - const parts = str.split('\n'); - const lineContent = currentLine(newCursorRow); - const before = cpSlice(lineContent, 0, newCursorCol); - const after = cpSlice(lineContent, newCursorCol); - - if (parts.length > 1) { - newLines[newCursorRow] = before + parts[0]; - const remainingParts = parts.slice(1); - const lastPartOriginal = remainingParts.pop() ?? ''; - newLines.splice(newCursorRow + 1, 0, ...remainingParts); - newLines.splice( - newCursorRow + parts.length - 1, - 0, - lastPartOriginal + after, - ); - newCursorRow = newCursorRow + parts.length - 1; - newCursorCol = cpLen(lastPartOriginal); + expandedOps.push({ type: 'backspace' }); } else { - newLines[newCursorRow] = before + parts[0] + after; - - newCursorCol = cpLen(before) + cpLen(parts[0]); - } - } else if (op.type === 'backspace') { - if (newCursorCol === 0 && newCursorRow === 0) continue; - - if (newCursorCol > 0) { - const lineContent = currentLine(newCursorRow); - newLines[newCursorRow] = - cpSlice(lineContent, 0, newCursorCol - 1) + - cpSlice(lineContent, newCursorCol); - newCursorCol--; - } else if (newCursorRow > 0) { - const prevLineContent = currentLine(newCursorRow - 1); - const currentLineContentVal = currentLine(newCursorRow); - const newCol = cpLen(prevLineContent); - newLines[newCursorRow - 1] = - prevLineContent + currentLineContentVal; - newLines.splice(newCursorRow, 1); - newCursorRow--; - newCursorCol = newCol; + currentText += char; } } + if (currentText.length > 0) { + expandedOps.push({ type: 'insert', payload: currentText }); + } + } else { + expandedOps.push(op); } + } - setLines(newLines); - setCursorRow(newCursorRow); - setCursorCol(newCursorCol); - setPreferredCol(null); - }, - [lines, cursorRow, cursorCol, pushUndo, setPreferredCol], - ); + if (expandedOps.length === 0) { + setOpQueue([]); // Clear queue even if ops were no-ops + return; + } + + pushUndo(); // Snapshot before applying batch of updates + + const newLines = [...lines]; + let newCursorRow = cursorRow; + let newCursorCol = cursorCol; + + const currentLine = (r: number) => newLines[r] ?? ''; + + for (const op of expandedOps) { + if (op.type === 'insert') { + const str = stripUnsafeCharacters( + op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), + ); + const parts = str.split('\n'); + const lineContent = currentLine(newCursorRow); + const before = cpSlice(lineContent, 0, newCursorCol); + const after = cpSlice(lineContent, newCursorCol); + + if (parts.length > 1) { + newLines[newCursorRow] = before + parts[0]; + const remainingParts = parts.slice(1); + const lastPartOriginal = remainingParts.pop() ?? ''; + newLines.splice(newCursorRow + 1, 0, ...remainingParts); + newLines.splice( + newCursorRow + parts.length - 1, + 0, + lastPartOriginal + after, + ); + newCursorRow = newCursorRow + parts.length - 1; + newCursorCol = cpLen(lastPartOriginal); + } else { + newLines[newCursorRow] = before + parts[0] + after; + + newCursorCol = cpLen(before) + cpLen(parts[0]); + } + } else if (op.type === 'backspace') { + if (newCursorCol === 0 && newCursorRow === 0) continue; + + if (newCursorCol > 0) { + const lineContent = currentLine(newCursorRow); + newLines[newCursorRow] = + cpSlice(lineContent, 0, newCursorCol - 1) + + cpSlice(lineContent, newCursorCol); + newCursorCol--; + } else if (newCursorRow > 0) { + const prevLineContent = currentLine(newCursorRow - 1); + const currentLineContentVal = currentLine(newCursorRow); + const newCol = cpLen(prevLineContent); + newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal; + newLines.splice(newCursorRow, 1); + newCursorRow--; + newCursorCol = newCol; + } + } + } + + setLines(newLines); + setCursorRow(newCursorRow); + setCursorCol(newCursorCol); + setPreferredCol(null); + + // Clear the queue after processing + setOpQueue((prev) => prev.slice(opQueue.length)); + }, [opQueue, lines, cursorRow, cursorCol, pushUndo, setPreferredCol]); const insert = useCallback( (ch: string): void => { - if (/[\n\r]/.test(ch)) { - insertStr(ch); - return; - } dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] }); ch = stripUnsafeCharacters(ch); @@ -694,7 +657,7 @@ export function useTextBuffer({ } applyOperations([{ type: 'insert', payload: ch }]); }, - [applyOperations, cursorRow, cursorCol, isValidPath, insertStr], + [applyOperations, cursorRow, cursorCol, isValidPath], ); const newline = useCallback((): void => { @@ -1397,8 +1360,9 @@ export function useTextBuffer({ }, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]), paste: useCallback(() => { if (clipboard === null) return false; - return insertStr(clipboard); - }, [clipboard, insertStr]), + applyOperations([{ type: 'insert', payload: clipboard }]); + return true; + }, [clipboard, applyOperations]), startSelection: useCallback( () => setSelectionAnchor([cursorRow, cursorCol]), [cursorRow, cursorCol, setSelectionAnchor], From e94decea39c5d81cddbfc1fd3b5c3881947551cf Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 1 Jul 2025 16:09:21 -0700 Subject: [PATCH 5/8] feat(core): Add infinite loop protection to client (#2793) --- packages/core/src/core/client.test.ts | 178 ++++++++++++++++++++++++++ packages/core/src/core/client.ts | 6 +- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 6a2ab307ce..b5f645430e 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -402,5 +402,183 @@ describe('Gemini Client (client.ts)', () => { // Assert expect(finalResult).toBeInstanceOf(Turn); }); + + it('should stop infinite loop after MAX_TURNS when nextSpeaker always returns model', async () => { + // Get the mocked checkNextSpeaker function and configure it to trigger infinite loop + const { checkNextSpeaker } = await import( + '../utils/nextSpeakerChecker.js' + ); + const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker); + mockCheckNextSpeaker.mockResolvedValue({ + next_speaker: 'model', + reasoning: 'Test case - always continue', + }); + + // Mock Turn to have no pending tool calls (which would allow nextSpeaker check) + const mockStream = (async function* () { + yield { type: 'content', value: 'Continue...' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + // Use a signal that never gets aborted + const abortController = new AbortController(); + const signal = abortController.signal; + + // Act - Start the stream that should loop + const stream = client.sendMessageStream( + [{ text: 'Start conversation' }], + signal, + ); + + // Count how many stream events we get + let eventCount = 0; + let finalResult: Turn | undefined; + + // Consume the stream and count iterations + while (true) { + const result = await stream.next(); + if (result.done) { + finalResult = result.value; + break; + } + eventCount++; + + // Safety check to prevent actual infinite loop in test + if (eventCount > 200) { + abortController.abort(); + throw new Error( + 'Test exceeded expected event limit - possible actual infinite loop', + ); + } + } + + // Assert + expect(finalResult).toBeInstanceOf(Turn); + + // Debug: Check how many times checkNextSpeaker was called + const callCount = mockCheckNextSpeaker.mock.calls.length; + + // If infinite loop protection is working, checkNextSpeaker should be called many times + // but stop at MAX_TURNS (100). Since each recursive call should trigger checkNextSpeaker, + // we expect it to be called multiple times before hitting the limit + expect(mockCheckNextSpeaker).toHaveBeenCalled(); + + // The test should demonstrate that the infinite loop protection works: + // - If checkNextSpeaker is called many times (close to MAX_TURNS), it shows the loop was happening + // - If it's only called once, the recursive behavior might not be triggered + if (callCount === 0) { + throw new Error( + 'checkNextSpeaker was never called - the recursive condition was not met', + ); + } else if (callCount === 1) { + // This might be expected behavior if the turn has pending tool calls or other conditions prevent recursion + console.log( + 'checkNextSpeaker called only once - no infinite loop occurred', + ); + } else { + console.log( + `checkNextSpeaker called ${callCount} times - infinite loop protection worked`, + ); + // If called multiple times, we expect it to be stopped before MAX_TURNS + expect(callCount).toBeLessThanOrEqual(100); // Should not exceed MAX_TURNS + } + + // The stream should produce events and eventually terminate + expect(eventCount).toBeGreaterThanOrEqual(1); + expect(eventCount).toBeLessThan(200); // Should not exceed our safety limit + }); + + it('should respect MAX_TURNS limit even when turns parameter is set to a large value', async () => { + // This test verifies that the infinite loop protection works even when + // someone tries to bypass it by calling with a very large turns value + + // Get the mocked checkNextSpeaker function and configure it to trigger infinite loop + const { checkNextSpeaker } = await import( + '../utils/nextSpeakerChecker.js' + ); + const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker); + mockCheckNextSpeaker.mockResolvedValue({ + next_speaker: 'model', + reasoning: 'Test case - always continue', + }); + + // Mock Turn to have no pending tool calls (which would allow nextSpeaker check) + const mockStream = (async function* () { + yield { type: 'content', value: 'Continue...' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + // Use a signal that never gets aborted + const abortController = new AbortController(); + const signal = abortController.signal; + + // Act - Start the stream with an extremely high turns value + // This simulates a case where the turns protection is bypassed + const stream = client.sendMessageStream( + [{ text: 'Start conversation' }], + signal, + Number.MAX_SAFE_INTEGER, // Bypass the MAX_TURNS protection + ); + + // Count how many stream events we get + let eventCount = 0; + const maxTestIterations = 1000; // Higher limit to show the loop continues + + // Consume the stream and count iterations + try { + while (true) { + const result = await stream.next(); + if (result.done) { + break; + } + eventCount++; + + // This test should hit this limit, demonstrating the infinite loop + if (eventCount > maxTestIterations) { + abortController.abort(); + // This is the expected behavior - we hit the infinite loop + break; + } + } + } catch (error) { + // If the test framework times out, that also demonstrates the infinite loop + console.error('Test timed out or errored:', error); + } + + // Assert that the fix works - the loop should stop at MAX_TURNS + const callCount = mockCheckNextSpeaker.mock.calls.length; + + // With the fix: even when turns is set to a very high value, + // the loop should stop at MAX_TURNS (100) + expect(callCount).toBeLessThanOrEqual(100); // Should not exceed MAX_TURNS + expect(eventCount).toBeLessThanOrEqual(200); // Should have reasonable number of events + + console.log( + `Infinite loop protection working: checkNextSpeaker called ${callCount} times, ` + + `${eventCount} events generated (properly bounded by MAX_TURNS)`, + ); + }); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 9a74add624..b00a689b29 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -219,7 +219,9 @@ export class GeminiClient { signal: AbortSignal, turns: number = this.MAX_TURNS, ): AsyncGenerator { - if (!turns) { + // Ensure turns never exceeds MAX_TURNS to prevent infinite loops + const boundedTurns = Math.min(turns, this.MAX_TURNS); + if (!boundedTurns) { return new Turn(this.getChat()); } @@ -242,7 +244,7 @@ export class GeminiClient { const nextRequest = [{ text: 'Please continue.' }]; // This recursive call's events will be yielded out, but the final // turn object will be from the top-level call. - yield* this.sendMessageStream(nextRequest, signal, turns - 1); + yield* this.sendMessageStream(nextRequest, signal, boundedTurns - 1); } } return turn; From 3492c429b95b7e905cd7cc7538e95b38809cc53e Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Tue, 1 Jul 2025 16:13:46 -0700 Subject: [PATCH 6/8] Add excludedTools to extensions. (#2853) --- docs/extension.md | 4 +- packages/cli/src/config/config.test.ts | 128 +++++++++++++++++++++++++ packages/cli/src/config/config.ts | 17 +++- packages/cli/src/config/extension.ts | 1 + 4 files changed, 148 insertions(+), 2 deletions(-) diff --git a/docs/extension.md b/docs/extension.md index e669f56cc3..0bdede0b9b 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -28,7 +28,8 @@ The `gemini-extension.json` file contains the configuration for the extension. T "command": "node my-server.js" } }, - "contextFileName": "GEMINI.md" + "contextFileName": "GEMINI.md", + "excludeTools": ["run_shell_command"] } ``` @@ -36,5 +37,6 @@ The `gemini-extension.json` file contains the configuration for the extension. T - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded. +- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 9808c40770..d4820726c0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -350,3 +350,131 @@ describe('mergeMcpServers', () => { expect(settings).toEqual(originalSettings); }); }); + +describe('mergeExcludeTools', () => { + it('should merge excludeTools from settings and extensions', async () => { + const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const extensions: Extension[] = [ + { + config: { + name: 'ext1', + version: '1.0.0', + excludeTools: ['tool3', 'tool4'], + }, + contextFiles: [], + }, + { + config: { + name: 'ext2', + version: '1.0.0', + excludeTools: ['tool5'], + }, + contextFiles: [], + }, + ]; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual( + expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']), + ); + expect(config.getExcludeTools()).toHaveLength(5); + }); + + it('should handle overlapping excludeTools between settings and extensions', async () => { + const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const extensions: Extension[] = [ + { + config: { + name: 'ext1', + version: '1.0.0', + excludeTools: ['tool2', 'tool3'], + }, + contextFiles: [], + }, + ]; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual( + expect.arrayContaining(['tool1', 'tool2', 'tool3']), + ); + expect(config.getExcludeTools()).toHaveLength(3); + }); + + it('should handle overlapping excludeTools between extensions', async () => { + const settings: Settings = { excludeTools: ['tool1'] }; + const extensions: Extension[] = [ + { + config: { + name: 'ext1', + version: '1.0.0', + excludeTools: ['tool2', 'tool3'], + }, + contextFiles: [], + }, + { + config: { + name: 'ext2', + version: '1.0.0', + excludeTools: ['tool3', 'tool4'], + }, + contextFiles: [], + }, + ]; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual( + expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']), + ); + expect(config.getExcludeTools()).toHaveLength(4); + }); + + it('should return an empty array when no excludeTools are specified', async () => { + const settings: Settings = {}; + const extensions: Extension[] = []; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual([]); + }); + + it('should handle settings with excludeTools but no extensions', async () => { + const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const extensions: Extension[] = []; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual( + expect.arrayContaining(['tool1', 'tool2']), + ); + expect(config.getExcludeTools()).toHaveLength(2); + }); + + it('should handle extensions with excludeTools but no settings', async () => { + const settings: Settings = {}; + const extensions: Extension[] = [ + { + config: { + name: 'ext1', + version: '1.0.0', + excludeTools: ['tool1', 'tool2'], + }, + contextFiles: [], + }, + ]; + const config = await loadCliConfig(settings, extensions, 'test-session'); + expect(config.getExcludeTools()).toEqual( + expect.arrayContaining(['tool1', 'tool2']), + ); + expect(config.getExcludeTools()).toHaveLength(2); + }); + + it('should not modify the original settings object', async () => { + const settings: Settings = { excludeTools: ['tool1'] }; + const extensions: Extension[] = [ + { + config: { + name: 'ext1', + version: '1.0.0', + excludeTools: ['tool2'], + }, + contextFiles: [], + }, + ]; + const originalSettings = JSON.parse(JSON.stringify(settings)); + await loadCliConfig(settings, extensions, 'test-session'); + expect(settings).toEqual(originalSettings); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 552a8f673f..cecf904b34 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -194,6 +194,7 @@ export async function loadCliConfig( ); const mcpServers = mergeMcpServers(settings, extensions); + const excludeTools = mergeExcludeTools(settings, extensions); const sandboxConfig = await loadSandboxConfig(settings, argv); @@ -206,7 +207,7 @@ export async function loadCliConfig( question: argv.prompt || '', fullContext: argv.all_files || false, coreTools: settings.coreTools || undefined, - excludeTools: settings.excludeTools || undefined, + excludeTools, toolDiscoveryCommand: settings.toolDiscoveryCommand, toolCallCommand: settings.toolCallCommand, mcpServerCommand: settings.mcpServerCommand, @@ -265,6 +266,20 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) { } return mcpServers; } + +function mergeExcludeTools( + settings: Settings, + extensions: Extension[], +): string[] { + const allExcludeTools = new Set(settings.excludeTools || []); + for (const extension of extensions) { + for (const tool of extension.config.excludeTools || []) { + allExcludeTools.add(tool); + } + } + return [...allExcludeTools]; +} + function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 31a6111fa0..57e6632bef 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -22,6 +22,7 @@ export interface ExtensionConfig { version: string; mcpServers?: Record; contextFileName?: string | string[]; + excludeTools?: string[]; } export function loadExtensions(workspaceDir: string): Extension[] { From dbe88f6e0e8efb989b21fc8b46e0da124f5204ff Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 1 Jul 2025 19:16:09 -0400 Subject: [PATCH 7/8] Added support for session_id in API calls (#2886) --- packages/core/src/code_assist/codeAssist.ts | 3 +- .../core/src/code_assist/converter.test.ts | 29 +++++++++++++++++++ packages/core/src/code_assist/converter.ts | 6 +++- packages/core/src/code_assist/server.ts | 5 ++-- packages/core/src/core/client.ts | 1 + packages/core/src/core/contentGenerator.ts | 7 ++++- 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/core/src/code_assist/codeAssist.ts b/packages/core/src/code_assist/codeAssist.ts index c3cb929398..80d95ca9e9 100644 --- a/packages/core/src/code_assist/codeAssist.ts +++ b/packages/core/src/code_assist/codeAssist.ts @@ -12,11 +12,12 @@ import { CodeAssistServer, HttpOptions } from './server.js'; export async function createCodeAssistContentGenerator( httpOptions: HttpOptions, authType: AuthType, + sessionId?: string, ): Promise { if (authType === AuthType.LOGIN_WITH_GOOGLE) { const authClient = await getOauthClient(); const projectId = await setupUser(authClient); - return new CodeAssistServer(authClient, projectId, httpOptions); + return new CodeAssistServer(authClient, projectId, httpOptions, sessionId); } throw new Error(`Unsupported authType: ${authType}`); diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 2170c960cd..03f388dc28 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -37,6 +37,7 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, + session_id: undefined, }, }); }); @@ -59,6 +60,34 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, + session_id: undefined, + }, + }); + }); + + it('should convert a request with sessionId', () => { + const genaiReq: GenerateContentParameters = { + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-project', + 'session-123', + ); + expect(codeAssistReq).toEqual({ + model: 'gemini-pro', + project: 'my-project', + request: { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + systemInstruction: undefined, + cachedContent: undefined, + tools: undefined, + toolConfig: undefined, + labels: undefined, + safetySettings: undefined, + generationConfig: undefined, + session_id: 'session-123', }, }); }); diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index b9b854fc23..b27617c4a6 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -44,6 +44,7 @@ interface VertexGenerateContentRequest { labels?: Record; safetySettings?: SafetySetting[]; generationConfig?: VertexGenerationConfig; + session_id?: string; } interface VertexGenerationConfig { @@ -114,11 +115,12 @@ export function fromCountTokenResponse( export function toGenerateContentRequest( req: GenerateContentParameters, project?: string, + sessionId?: string, ): CAGenerateContentRequest { return { model: req.model, project, - request: toVertexGenerateContentRequest(req), + request: toVertexGenerateContentRequest(req, sessionId), }; } @@ -136,6 +138,7 @@ export function fromGenerateContentResponse( function toVertexGenerateContentRequest( req: GenerateContentParameters, + sessionId?: string, ): VertexGenerateContentRequest { return { contents: toContents(req.contents), @@ -146,6 +149,7 @@ function toVertexGenerateContentRequest( labels: req.config?.labels, safetySettings: req.config?.safetySettings, generationConfig: toVertexGenerationConfig(req.config), + session_id: sessionId, }; } diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 3cf0c721b8..f285dba88d 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -48,6 +48,7 @@ export class CodeAssistServer implements ContentGenerator { readonly client: OAuth2Client, readonly projectId?: string, readonly httpOptions: HttpOptions = {}, + readonly sessionId?: string, ) {} async generateContentStream( @@ -55,7 +56,7 @@ export class CodeAssistServer implements ContentGenerator { ): Promise> { const resps = await this.requestStreamingPost( 'streamGenerateContent', - toGenerateContentRequest(req, this.projectId), + toGenerateContentRequest(req, this.projectId, this.sessionId), req.config?.abortSignal, ); return (async function* (): AsyncGenerator { @@ -70,7 +71,7 @@ export class CodeAssistServer implements ContentGenerator { ): Promise { const resp = await this.requestPost( 'generateContent', - toGenerateContentRequest(req, this.projectId), + toGenerateContentRequest(req, this.projectId, this.sessionId), req.config?.abortSignal, ); return fromGenerateContentResponse(resp); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b00a689b29..fe60112d00 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -68,6 +68,7 @@ export class GeminiClient { async initialize(contentGeneratorConfig: ContentGeneratorConfig) { this.contentGenerator = await createContentGenerator( contentGeneratorConfig, + this.config.getSessionId(), ); this.chat = await this.startChat(); } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 4740c4eef4..f0c163d2fc 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -101,6 +101,7 @@ export async function createContentGeneratorConfig( export async function createContentGenerator( config: ContentGeneratorConfig, + sessionId?: string, ): Promise { const version = process.env.CLI_VERSION || process.version; const httpOptions = { @@ -109,7 +110,11 @@ export async function createContentGenerator( }, }; if (config.authType === AuthType.LOGIN_WITH_GOOGLE) { - return createCodeAssistContentGenerator(httpOptions, config.authType); + return createCodeAssistContentGenerator( + httpOptions, + config.authType, + sessionId, + ); } if ( From 34935d6558dfca743e457ecd7684d0aab69f1c1b Mon Sep 17 00:00:00 2001 From: Brandon Keiji Date: Tue, 1 Jul 2025 23:30:04 +0000 Subject: [PATCH 8/8] chore: bump version to 0.1.9 (#2906) --- package-lock.json | 8 ++++---- package.json | 4 ++-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09eb6d968e..205f5fecfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.1.8", + "version": "0.1.9", "workspaces": [ "packages/*" ], @@ -11200,7 +11200,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@google/gemini-cli-core": "*", "@types/update-notifier": "^6.0.8", @@ -11379,7 +11379,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@google/genai": "^1.4.0", "@modelcontextprotocol/sdk": "^1.11.0", diff --git a/package.json b/package.json index 20dadd96a6..38014c1b3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.8", + "version": "0.1.9", "engines": { "node": ">=18.0.0" }, @@ -10,7 +10,7 @@ ], "repository": "google-gemini/gemini-cli", "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.7" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.8" }, "scripts": { "generate": "node scripts/generate-git-commit-info.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index fee5965b54..df8cb4fab6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.8", + "version": "0.1.9", "description": "Gemini CLI", "repository": "google-gemini/gemini-cli", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index cababb8c01..9bf2713a4e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.1.8", + "version": "0.1.9", "description": "Gemini CLI Server", "repository": "google-gemini/gemini-cli", "type": "module",