Merge remote-tracking branch 'origin/main' into mk-packing

This commit is contained in:
mkorwel
2025-07-01 18:57:38 -05:00
22 changed files with 577 additions and 159 deletions

View File

@@ -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.

70
docs/quota-and-pricing.md Normal file
View File

@@ -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.

View File

@@ -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.

8
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.1.8",
"version": "0.1.9",
"engines": {
"node": ">=18.0.0"
},

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -22,6 +22,7 @@ export interface ExtensionConfig {
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): Extension[] {

View File

@@ -113,7 +113,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
selectedSuggestion.value,
suggestion,
);
}
resetCompletionState();

View File

@@ -420,6 +420,7 @@ export function useTextBuffer({
const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
const historyLimit = 100;
const [opQueue, setOpQueue] = useState<UpdateOperation[]>([]);
const [clipboard, setClipboard] = useState<string | null>(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],

View File

@@ -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) {

View File

@@ -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",

View File

@@ -12,11 +12,12 @@ import { CodeAssistServer, HttpOptions } from './server.js';
export async function createCodeAssistContentGenerator(
httpOptions: HttpOptions,
authType: AuthType,
sessionId?: string,
): Promise<ContentGenerator> {
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}`);

View File

@@ -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',
},
});
});

View File

@@ -44,6 +44,7 @@ interface VertexGenerateContentRequest {
labels?: Record<string, string>;
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,
};
}

View File

@@ -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<AsyncGenerator<GenerateContentResponse>> {
const resps = await this.requestStreamingPost<CaGenerateContentResponse>(
'streamGenerateContent',
toGenerateContentRequest(req, this.projectId),
toGenerateContentRequest(req, this.projectId, this.sessionId),
req.config?.abortSignal,
);
return (async function* (): AsyncGenerator<GenerateContentResponse> {
@@ -70,7 +71,7 @@ export class CodeAssistServer implements ContentGenerator {
): Promise<GenerateContentResponse> {
const resp = await this.requestPost<CaGenerateContentResponse>(
'generateContent',
toGenerateContentRequest(req, this.projectId),
toGenerateContentRequest(req, this.projectId, this.sessionId),
req.config?.abortSignal,
);
return fromGenerateContentResponse(resp);

View File

@@ -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<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const mockGenerator: Partial<ContentGenerator> = {
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<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const mockGenerator: Partial<ContentGenerator> = {
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)`,
);
});
});
});

View File

@@ -68,6 +68,7 @@ export class GeminiClient {
async initialize(contentGeneratorConfig: ContentGeneratorConfig) {
this.contentGenerator = await createContentGenerator(
contentGeneratorConfig,
this.config.getSessionId(),
);
this.chat = await this.startChat();
}
@@ -219,7 +220,9 @@ export class GeminiClient {
signal: AbortSignal,
turns: number = this.MAX_TURNS,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
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 +245,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;

View File

@@ -101,6 +101,7 @@ export async function createContentGeneratorConfig(
export async function createContentGenerator(
config: ContentGeneratorConfig,
sessionId?: string,
): Promise<ContentGenerator> {
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 (

View File

@@ -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');

View File

@@ -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';