mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
* feat: add Gemini CLI provider for AI model execution - Add GeminiProvider class extending CliProvider for Gemini CLI integration - Add Gemini models (Gemini 3 Pro/Flash Preview, 2.5 Pro/Flash/Flash-Lite) - Add gemini-models.ts with model definitions and types - Update ModelProvider type to include 'gemini' - Add isGeminiModel() to provider-utils.ts for model detection - Register Gemini provider in provider-factory with priority 4 - Add Gemini setup detection routes (status, auth, deauth) - Add GeminiCliStatus to setup store for UI state management - Add Gemini to PROVIDER_ICON_COMPONENTS for UI icon display - Add GEMINI_MODELS to model-display for dropdown population - Support thinking levels: off, low, medium, high Based on https://github.com/google-gemini/gemini-cli * chore: update package-lock.json * feat(ui): add Gemini provider to settings and setup wizard - Add GeminiCliStatus component for CLI detection display - Add GeminiSettingsTab component for global settings - Update provider-tabs.tsx to include Gemini as 5th tab - Update providers-setup-step.tsx with Gemini provider detection - Add useGeminiCliStatus hook for querying CLI status - Add getGeminiStatus, authGemini, deauthGemini to HTTP API client - Add gemini query key for React Query - Fix GeminiModelId type to not double-prefix model IDs * feat(ui): add Gemini to settings sidebar navigation - Add 'gemini-provider' to SettingsViewId type - Add GeminiIcon and gemini-provider to navigation config - Add gemini-provider to NAV_ID_TO_PROVIDER mapping - Add gemini-provider case in settings-view switch - Export GeminiSettingsTab from providers index This fixes the missing Gemini entry in the AI Providers sidebar menu. * feat(ui): add Gemini model configuration in settings - Create GeminiModelConfiguration component for model selection - Add enabledGeminiModels and geminiDefaultModel state to app-store - Add setEnabledGeminiModels, setGeminiDefaultModel, toggleGeminiModel actions - Update GeminiSettingsTab to show model configuration when CLI is installed - Import GeminiModelId and getAllGeminiModelIds from types This adds the ability to configure which Gemini models are available in the feature modal, similar to other providers like Codex and OpenCode. * feat(ui): add Gemini models to all model dropdowns - Add GEMINI_MODELS to model-constants.ts for UI dropdowns - Add Gemini to ALL_MODELS array used throughout the app - Add GeminiIcon to PROFILE_ICONS mapping - Fix GEMINI_MODELS in model-display.ts to use correct model IDs - Update getModelDisplayName to handle Gemini models correctly Gemini models now appear in all model selection dropdowns including Model Defaults, Feature Defaults, and feature card settings. * fix(gemini): fix CLI integration and event handling - Fix model ID prefix handling: strip gemini- prefix in agent-service, add it back in buildCliArgs for CLI invocation - Fix event normalization to match actual Gemini CLI output format: - type: 'init' (not 'system') - type: 'message' with role (not 'assistant') - tool_name/tool_id/parameters/output field names - Add --sandbox false and --approval-mode yolo for faster execution - Remove thinking level selector from UI (Gemini CLI doesn't support it) - Update auth status to show errors properly * test: update provider-factory tests for Gemini provider - Add GeminiProvider import and spy mock - Update expected provider count from 4 to 5 - Add test for GeminiProvider inclusion - Add gemini key to checkAllProviders test * fix(gemini): address PR review feedback - Fix npm package name from @anthropic-ai/gemini-cli to @google/gemini-cli - Fix comments in gemini-provider.ts to match actual CLI output format - Convert sync fs operations to async using fs/promises * fix(settings): add Gemini and Codex settings to sync Add enabledGeminiModels, geminiDefaultModel, enabledCodexModels, and codexDefaultModel to SETTINGS_FIELDS_TO_SYNC for persistence across sessions. * fix(gemini): address additional PR review feedback - Use 'Speed' badge for non-thinking Gemini models (consistency) - Fix installCommand mapping in gemini-settings-tab.tsx - Add hasEnvApiKey to GeminiCliStatus interface for API parity - Clarify GeminiThinkingLevel comment (CLI doesn't support --thinking-level) * fix(settings): restore Codex and Gemini settings from server Add sanitization and restoration logic for enabledCodexModels, codexDefaultModel, enabledGeminiModels, and geminiDefaultModel in refreshSettingsFromServer() to match the fields in SETTINGS_FIELDS_TO_SYNC. * feat(gemini): normalize tool names and fix workspace restrictions - Add tool name mapping to normalize Gemini CLI tool names to standard names (e.g., write_todos -> TodoWrite, read_file -> Read) - Add normalizeGeminiToolInput to convert write_todos format to TodoWrite format (description -> content, handle cancelled status) - Pass --include-directories with cwd to fix workspace restriction errors when Gemini CLI has a different cached workspace from previous sessions --------- Co-authored-by: Claude <noreply@anthropic.com>
816 lines
25 KiB
TypeScript
816 lines
25 KiB
TypeScript
/**
|
|
* Gemini Provider - Executes queries using the Gemini CLI
|
|
*
|
|
* Extends CliProvider with Gemini-specific:
|
|
* - Event normalization for Gemini's JSONL streaming format
|
|
* - Google account and API key authentication support
|
|
* - Thinking level configuration
|
|
*
|
|
* Based on https://github.com/google-gemini/gemini-cli
|
|
*/
|
|
|
|
import { execSync } from 'child_process';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
|
import type {
|
|
ProviderConfig,
|
|
ExecuteOptions,
|
|
ProviderMessage,
|
|
InstallationStatus,
|
|
ModelDefinition,
|
|
ContentBlock,
|
|
} from './types.js';
|
|
import { validateBareModelId } from '@automaker/types';
|
|
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
|
import { createLogger, isAbortError } from '@automaker/utils';
|
|
import { spawnJSONLProcess } from '@automaker/platform';
|
|
|
|
// Create logger for this module
|
|
const logger = createLogger('GeminiProvider');
|
|
|
|
// =============================================================================
|
|
// Gemini Stream Event Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Base event structure from Gemini CLI --output-format stream-json
|
|
*
|
|
* Actual CLI output format:
|
|
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
|
|
* {"type":"message","timestamp":"...","role":"user","content":"..."}
|
|
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
|
|
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
|
|
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
|
|
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
|
|
*/
|
|
interface GeminiStreamEvent {
|
|
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
|
|
timestamp?: string;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface GeminiInitEvent extends GeminiStreamEvent {
|
|
type: 'init';
|
|
session_id: string;
|
|
model: string;
|
|
}
|
|
|
|
interface GeminiMessageEvent extends GeminiStreamEvent {
|
|
type: 'message';
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
delta?: boolean;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface GeminiToolUseEvent extends GeminiStreamEvent {
|
|
type: 'tool_use';
|
|
tool_id: string;
|
|
tool_name: string;
|
|
parameters: Record<string, unknown>;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface GeminiToolResultEvent extends GeminiStreamEvent {
|
|
type: 'tool_result';
|
|
tool_id: string;
|
|
status: 'success' | 'error';
|
|
output: string;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface GeminiResultEvent extends GeminiStreamEvent {
|
|
type: 'result';
|
|
status: 'success' | 'error';
|
|
stats?: {
|
|
total_tokens?: number;
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cached?: number;
|
|
input?: number;
|
|
duration_ms?: number;
|
|
tool_calls?: number;
|
|
};
|
|
error?: string;
|
|
session_id?: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Error Codes
|
|
// =============================================================================
|
|
|
|
export enum GeminiErrorCode {
|
|
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
|
|
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
|
|
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
|
|
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
|
|
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
|
|
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
|
|
TIMEOUT = 'GEMINI_TIMEOUT',
|
|
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
|
|
}
|
|
|
|
export interface GeminiError extends Error {
|
|
code: GeminiErrorCode;
|
|
recoverable: boolean;
|
|
suggestion?: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tool Name Normalization
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Gemini CLI tool name to standard tool name mapping
|
|
* This allows the UI to properly categorize and display Gemini tool calls
|
|
*/
|
|
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
|
|
write_todos: 'TodoWrite',
|
|
read_file: 'Read',
|
|
read_many_files: 'Read',
|
|
replace: 'Edit',
|
|
write_file: 'Write',
|
|
run_shell_command: 'Bash',
|
|
search_file_content: 'Grep',
|
|
glob: 'Glob',
|
|
list_directory: 'Ls',
|
|
web_fetch: 'WebFetch',
|
|
google_web_search: 'WebSearch',
|
|
};
|
|
|
|
/**
|
|
* Normalize Gemini tool names to standard tool names
|
|
*/
|
|
function normalizeGeminiToolName(geminiToolName: string): string {
|
|
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
|
|
}
|
|
|
|
/**
|
|
* Normalize Gemini tool input parameters to standard format
|
|
*
|
|
* Gemini `write_todos` format:
|
|
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
|
*
|
|
* Claude `TodoWrite` format:
|
|
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
|
|
*/
|
|
function normalizeGeminiToolInput(
|
|
toolName: string,
|
|
input: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
|
|
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
|
|
return {
|
|
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
|
|
content: todo.description || '',
|
|
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
|
|
status: todo.status === 'cancelled' ? 'completed' : todo.status,
|
|
// Use description as activeForm since Gemini doesn't have it
|
|
activeForm: todo.description || '',
|
|
})),
|
|
};
|
|
}
|
|
return input;
|
|
}
|
|
|
|
/**
|
|
* GeminiProvider - Integrates Gemini CLI as an AI provider
|
|
*
|
|
* Features:
|
|
* - Google account OAuth login support
|
|
* - API key authentication (GEMINI_API_KEY)
|
|
* - Vertex AI support
|
|
* - Thinking level configuration
|
|
* - Streaming JSON output
|
|
*/
|
|
export class GeminiProvider extends CliProvider {
|
|
constructor(config: ProviderConfig = {}) {
|
|
super(config);
|
|
// Trigger CLI detection on construction
|
|
this.ensureCliDetected();
|
|
}
|
|
|
|
// ==========================================================================
|
|
// CliProvider Abstract Method Implementations
|
|
// ==========================================================================
|
|
|
|
getName(): string {
|
|
return 'gemini';
|
|
}
|
|
|
|
getCliName(): string {
|
|
return 'gemini';
|
|
}
|
|
|
|
getSpawnConfig(): CliSpawnConfig {
|
|
return {
|
|
windowsStrategy: 'npx', // Gemini CLI can be run via npx
|
|
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
|
|
commonPaths: {
|
|
linux: [
|
|
path.join(os.homedir(), '.local/bin/gemini'),
|
|
'/usr/local/bin/gemini',
|
|
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
|
],
|
|
darwin: [
|
|
path.join(os.homedir(), '.local/bin/gemini'),
|
|
'/usr/local/bin/gemini',
|
|
'/opt/homebrew/bin/gemini',
|
|
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
|
],
|
|
win32: [
|
|
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
|
|
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract prompt text from ExecuteOptions
|
|
*/
|
|
private extractPromptText(options: ExecuteOptions): string {
|
|
if (typeof options.prompt === 'string') {
|
|
return options.prompt;
|
|
} else if (Array.isArray(options.prompt)) {
|
|
return options.prompt
|
|
.filter((p) => p.type === 'text' && p.text)
|
|
.map((p) => p.text)
|
|
.join('\n');
|
|
} else {
|
|
throw new Error('Invalid prompt format');
|
|
}
|
|
}
|
|
|
|
buildCliArgs(options: ExecuteOptions): string[] {
|
|
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
|
|
// We need to add 'gemini-' back since it's part of the actual CLI model name
|
|
const bareModel = options.model || '2.5-flash';
|
|
const cliArgs: string[] = [];
|
|
|
|
// Streaming JSON output format for real-time updates
|
|
cliArgs.push('--output-format', 'stream-json');
|
|
|
|
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
|
|
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
|
|
// the 'gemini-' is part of the actual model name Google expects
|
|
if (bareModel && bareModel !== 'auto') {
|
|
// Add gemini- prefix if not already present (handles edge cases)
|
|
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
|
|
cliArgs.push('--model', cliModel);
|
|
}
|
|
|
|
// Disable sandbox mode for faster execution (sandbox adds overhead)
|
|
cliArgs.push('--sandbox', 'false');
|
|
|
|
// YOLO mode for automatic approval (required for non-interactive use)
|
|
// Use explicit approval-mode for clearer semantics
|
|
cliArgs.push('--approval-mode', 'yolo');
|
|
|
|
// Explicitly include the working directory in allowed workspace directories
|
|
// This ensures Gemini CLI allows file operations in the project directory,
|
|
// even if it has a different workspace cached from a previous session
|
|
if (options.cwd) {
|
|
cliArgs.push('--include-directories', options.cwd);
|
|
}
|
|
|
|
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
|
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
|
// The model handles thinking internally based on the task complexity.
|
|
|
|
// The prompt will be passed as the last positional argument
|
|
// We'll append it in executeQuery after extracting the text
|
|
|
|
return cliArgs;
|
|
}
|
|
|
|
/**
|
|
* Convert Gemini event to AutoMaker ProviderMessage format
|
|
*/
|
|
normalizeEvent(event: unknown): ProviderMessage | null {
|
|
const geminiEvent = event as GeminiStreamEvent;
|
|
|
|
switch (geminiEvent.type) {
|
|
case 'init': {
|
|
// Init event - capture session but don't yield a message
|
|
const initEvent = geminiEvent as GeminiInitEvent;
|
|
logger.debug(
|
|
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
case 'message': {
|
|
const messageEvent = geminiEvent as GeminiMessageEvent;
|
|
|
|
// Skip user messages - already handled by caller
|
|
if (messageEvent.role === 'user') {
|
|
return null;
|
|
}
|
|
|
|
// Handle assistant messages
|
|
if (messageEvent.role === 'assistant') {
|
|
return {
|
|
type: 'assistant',
|
|
session_id: messageEvent.session_id,
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: messageEvent.content }],
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
case 'tool_use': {
|
|
const toolEvent = geminiEvent as GeminiToolUseEvent;
|
|
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
|
|
const normalizedInput = normalizeGeminiToolInput(
|
|
toolEvent.tool_name,
|
|
toolEvent.parameters as Record<string, unknown>
|
|
);
|
|
|
|
return {
|
|
type: 'assistant',
|
|
session_id: toolEvent.session_id,
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: normalizedName,
|
|
tool_use_id: toolEvent.tool_id,
|
|
input: normalizedInput,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'tool_result': {
|
|
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
|
|
// If tool result is an error, prefix with error indicator
|
|
const content =
|
|
toolResultEvent.status === 'error'
|
|
? `[ERROR] ${toolResultEvent.output}`
|
|
: toolResultEvent.output;
|
|
return {
|
|
type: 'assistant',
|
|
session_id: toolResultEvent.session_id,
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: toolResultEvent.tool_id,
|
|
content,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'result': {
|
|
const resultEvent = geminiEvent as GeminiResultEvent;
|
|
|
|
if (resultEvent.status === 'error') {
|
|
return {
|
|
type: 'error',
|
|
session_id: resultEvent.session_id,
|
|
error: resultEvent.error || 'Unknown error',
|
|
};
|
|
}
|
|
|
|
// Success result - include stats for logging
|
|
logger.debug(
|
|
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
|
|
);
|
|
return {
|
|
type: 'result',
|
|
subtype: 'success',
|
|
session_id: resultEvent.session_id,
|
|
};
|
|
}
|
|
|
|
case 'error': {
|
|
const errorEvent = geminiEvent as GeminiResultEvent;
|
|
return {
|
|
type: 'error',
|
|
session_id: errorEvent.session_id,
|
|
error: errorEvent.error || 'Unknown error',
|
|
};
|
|
}
|
|
|
|
default:
|
|
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// CliProvider Overrides
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Override error mapping for Gemini-specific error codes
|
|
*/
|
|
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
|
const lower = stderr.toLowerCase();
|
|
|
|
if (
|
|
lower.includes('not authenticated') ||
|
|
lower.includes('please log in') ||
|
|
lower.includes('unauthorized') ||
|
|
lower.includes('login required') ||
|
|
lower.includes('error authenticating') ||
|
|
lower.includes('loadcodeassist') ||
|
|
(lower.includes('econnrefused') && lower.includes('8888'))
|
|
) {
|
|
return {
|
|
code: GeminiErrorCode.NOT_AUTHENTICATED,
|
|
message: 'Gemini CLI is not authenticated',
|
|
recoverable: true,
|
|
suggestion:
|
|
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
|
|
};
|
|
}
|
|
|
|
if (
|
|
lower.includes('rate limit') ||
|
|
lower.includes('too many requests') ||
|
|
lower.includes('429') ||
|
|
lower.includes('quota exceeded')
|
|
) {
|
|
return {
|
|
code: GeminiErrorCode.RATE_LIMITED,
|
|
message: 'Gemini API rate limit exceeded',
|
|
recoverable: true,
|
|
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
|
|
};
|
|
}
|
|
|
|
if (
|
|
lower.includes('model not available') ||
|
|
lower.includes('invalid model') ||
|
|
lower.includes('unknown model') ||
|
|
lower.includes('modelnotfounderror') ||
|
|
lower.includes('model not found') ||
|
|
(lower.includes('not found') && lower.includes('404'))
|
|
) {
|
|
return {
|
|
code: GeminiErrorCode.MODEL_UNAVAILABLE,
|
|
message: 'Requested model is not available',
|
|
recoverable: true,
|
|
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
|
|
};
|
|
}
|
|
|
|
if (
|
|
lower.includes('network') ||
|
|
lower.includes('connection') ||
|
|
lower.includes('econnrefused') ||
|
|
lower.includes('timeout')
|
|
) {
|
|
return {
|
|
code: GeminiErrorCode.NETWORK_ERROR,
|
|
message: 'Network connection error',
|
|
recoverable: true,
|
|
suggestion: 'Check your internet connection and try again',
|
|
};
|
|
}
|
|
|
|
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
|
return {
|
|
code: GeminiErrorCode.PROCESS_CRASHED,
|
|
message: 'Gemini CLI process was terminated',
|
|
recoverable: true,
|
|
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
code: GeminiErrorCode.UNKNOWN,
|
|
message: stderr || `Gemini CLI exited with code ${exitCode}`,
|
|
recoverable: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Override install instructions for Gemini-specific guidance
|
|
*/
|
|
protected getInstallInstructions(): string {
|
|
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
|
|
}
|
|
|
|
/**
|
|
* Execute a prompt using Gemini CLI with streaming
|
|
*/
|
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
|
this.ensureCliDetected();
|
|
|
|
// Validate that model doesn't have a provider prefix
|
|
validateBareModelId(options.model, 'GeminiProvider');
|
|
|
|
if (!this.cliPath) {
|
|
throw this.createError(
|
|
GeminiErrorCode.NOT_INSTALLED,
|
|
'Gemini CLI is not installed',
|
|
true,
|
|
this.getInstallInstructions()
|
|
);
|
|
}
|
|
|
|
// Extract prompt text to pass as positional argument
|
|
const promptText = this.extractPromptText(options);
|
|
|
|
// Build CLI args and append the prompt as the last positional argument
|
|
const cliArgs = this.buildCliArgs(options);
|
|
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
|
|
|
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
|
|
|
let sessionId: string | undefined;
|
|
|
|
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
|
|
|
|
try {
|
|
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
|
const event = rawEvent as GeminiStreamEvent;
|
|
|
|
// Capture session ID from init event
|
|
if (event.type === 'init') {
|
|
const initEvent = event as GeminiInitEvent;
|
|
sessionId = initEvent.session_id;
|
|
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
|
|
}
|
|
|
|
// Normalize and yield the event
|
|
const normalized = this.normalizeEvent(event);
|
|
if (normalized) {
|
|
if (!normalized.session_id && sessionId) {
|
|
normalized.session_id = sessionId;
|
|
}
|
|
yield normalized;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
logger.debug('Query aborted');
|
|
return;
|
|
}
|
|
|
|
// Map CLI errors to GeminiError
|
|
if (error instanceof Error && 'stderr' in error) {
|
|
const errorInfo = this.mapError(
|
|
(error as { stderr?: string }).stderr || error.message,
|
|
(error as { exitCode?: number | null }).exitCode ?? null
|
|
);
|
|
throw this.createError(
|
|
errorInfo.code as GeminiErrorCode,
|
|
errorInfo.message,
|
|
errorInfo.recoverable,
|
|
errorInfo.suggestion
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Gemini-Specific Methods
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Create a GeminiError with details
|
|
*/
|
|
private createError(
|
|
code: GeminiErrorCode,
|
|
message: string,
|
|
recoverable: boolean = false,
|
|
suggestion?: string
|
|
): GeminiError {
|
|
const error = new Error(message) as GeminiError;
|
|
error.code = code;
|
|
error.recoverable = recoverable;
|
|
error.suggestion = suggestion;
|
|
error.name = 'GeminiError';
|
|
return error;
|
|
}
|
|
|
|
/**
|
|
* Get Gemini CLI version
|
|
*/
|
|
async getVersion(): Promise<string | null> {
|
|
this.ensureCliDetected();
|
|
if (!this.cliPath) return null;
|
|
|
|
try {
|
|
const result = execSync(`"${this.cliPath}" --version`, {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
stdio: 'pipe',
|
|
}).trim();
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check authentication status
|
|
*
|
|
* Uses a fast credential check approach:
|
|
* 1. Check for GEMINI_API_KEY environment variable
|
|
* 2. Check for Google Cloud credentials
|
|
* 3. Check for Gemini settings file with stored credentials
|
|
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
|
|
*/
|
|
async checkAuth(): Promise<GeminiAuthStatus> {
|
|
this.ensureCliDetected();
|
|
if (!this.cliPath) {
|
|
logger.debug('checkAuth: CLI not found');
|
|
return { authenticated: false, method: 'none' };
|
|
}
|
|
|
|
logger.debug('checkAuth: Starting credential check');
|
|
|
|
// Determine the likely auth method based on environment
|
|
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
|
const hasEnvApiKey = hasApiKey;
|
|
const hasVertexAi = !!(
|
|
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
|
|
);
|
|
|
|
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
|
|
|
|
// Check for Gemini credentials file (~/.gemini/settings.json)
|
|
const geminiConfigDir = path.join(os.homedir(), '.gemini');
|
|
const settingsPath = path.join(geminiConfigDir, 'settings.json');
|
|
let hasCredentialsFile = false;
|
|
let authType: string | null = null;
|
|
|
|
try {
|
|
await fs.access(settingsPath);
|
|
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
|
|
try {
|
|
const content = await fs.readFile(settingsPath, 'utf8');
|
|
const settings = JSON.parse(content);
|
|
|
|
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
|
|
const selectedType = settings?.security?.auth?.selectedType;
|
|
if (selectedType) {
|
|
hasCredentialsFile = true;
|
|
authType = selectedType;
|
|
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
|
|
} else {
|
|
logger.debug(`checkAuth: Settings file found but no auth type configured`);
|
|
}
|
|
} catch (e) {
|
|
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
|
|
}
|
|
} catch {
|
|
logger.debug('checkAuth: No settings file found');
|
|
}
|
|
|
|
// If we have an API key, we're authenticated
|
|
if (hasApiKey) {
|
|
logger.debug('checkAuth: Using API key authentication');
|
|
return {
|
|
authenticated: true,
|
|
method: 'api_key',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
};
|
|
}
|
|
|
|
// If we have Vertex AI credentials, we're authenticated
|
|
if (hasVertexAi) {
|
|
logger.debug('checkAuth: Using Vertex AI authentication');
|
|
return {
|
|
authenticated: true,
|
|
method: 'vertex_ai',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
};
|
|
}
|
|
|
|
// Check if settings file indicates configured authentication
|
|
if (hasCredentialsFile && authType) {
|
|
// OAuth types: "oauth-personal", "oauth-adc"
|
|
// API key type: "api-key"
|
|
// Code assist: "code-assist" (requires IDE integration)
|
|
if (authType.startsWith('oauth')) {
|
|
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
|
|
return {
|
|
authenticated: true,
|
|
method: 'google_login',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
};
|
|
}
|
|
|
|
if (authType === 'api-key') {
|
|
logger.debug('checkAuth: API key authentication configured in settings');
|
|
return {
|
|
authenticated: true,
|
|
method: 'api_key',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
};
|
|
}
|
|
|
|
if (authType === 'code-assist' || authType === 'codeassist') {
|
|
logger.debug('checkAuth: Code Assist auth configured but requires local server');
|
|
return {
|
|
authenticated: false,
|
|
method: 'google_login',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
error:
|
|
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
|
|
};
|
|
}
|
|
|
|
// Unknown auth type but something is configured
|
|
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
|
|
return {
|
|
authenticated: true,
|
|
method: 'google_login',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
};
|
|
}
|
|
|
|
// No credentials found
|
|
logger.debug('checkAuth: No valid credentials found');
|
|
return {
|
|
authenticated: false,
|
|
method: 'none',
|
|
hasApiKey,
|
|
hasEnvApiKey,
|
|
hasCredentialsFile,
|
|
error:
|
|
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect installation status (required by BaseProvider)
|
|
*/
|
|
async detectInstallation(): Promise<InstallationStatus> {
|
|
const installed = await this.isInstalled();
|
|
const version = installed ? await this.getVersion() : undefined;
|
|
const auth = await this.checkAuth();
|
|
|
|
return {
|
|
installed,
|
|
version: version || undefined,
|
|
path: this.cliPath || undefined,
|
|
method: 'cli',
|
|
hasApiKey: !!process.env.GEMINI_API_KEY,
|
|
authenticated: auth.authenticated,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the detected CLI path (public accessor for status endpoints)
|
|
*/
|
|
getCliPath(): string | null {
|
|
this.ensureCliDetected();
|
|
return this.cliPath;
|
|
}
|
|
|
|
/**
|
|
* Get available Gemini models
|
|
*/
|
|
getAvailableModels(): ModelDefinition[] {
|
|
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
|
|
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
|
|
name: config.label,
|
|
modelString: id, // Same as id - CLI uses the full model name
|
|
provider: 'gemini',
|
|
description: config.description,
|
|
supportsTools: true,
|
|
supportsVision: config.supportsVision,
|
|
contextWindow: config.contextWindow,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Check if a feature is supported
|
|
*/
|
|
supportsFeature(feature: string): boolean {
|
|
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
|
|
return supported.includes(feature);
|
|
}
|
|
}
|