mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: add Gemini-CLI provider support
- Add GeminiCliHandler class to interface with gemini CLI tool - Implement OAuth authentication flow handling - Add telemetry collection for token usage tracking - Create UI component for provider configuration - Add comprehensive test coverage for the provider - Support both free and paid (project ID) usage modes Fixes #6043
This commit is contained in:
parent
b1bc085aa6
commit
41d96f13ad
10 changed files with 772 additions and 0 deletions
52
packages/types/src/providers/gemini-cli.ts
Normal file
52
packages/types/src/providers/gemini-cli.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// Gemini-CLI specific model IDs
|
||||
export type GeminiCliModelId = keyof typeof geminiCliModels
|
||||
|
||||
export const geminiCliDefaultModelId: GeminiCliModelId = "gemini-2.0-flash-001"
|
||||
|
||||
// Models available through Gemini CLI
|
||||
export const geminiCliModels = {
|
||||
"gemini-2.0-flash-001": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.025,
|
||||
cacheWritesPrice: 1.0,
|
||||
},
|
||||
"gemini-1.5-flash-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.0375,
|
||||
cacheWritesPrice: 1.0,
|
||||
tiers: [
|
||||
{
|
||||
contextWindow: 128_000,
|
||||
inputPrice: 0.075,
|
||||
outputPrice: 0.3,
|
||||
cacheReadsPrice: 0.01875,
|
||||
},
|
||||
{
|
||||
contextWindow: Infinity,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.0375,
|
||||
},
|
||||
],
|
||||
},
|
||||
"gemini-1.5-pro-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
@ -4,6 +4,7 @@ export * from "./chutes.js"
|
|||
export * from "./claude-code.js"
|
||||
export * from "./deepseek.js"
|
||||
export * from "./gemini.js"
|
||||
export * from "./gemini-cli.js"
|
||||
export * from "./glama.js"
|
||||
export * from "./groq.js"
|
||||
export * from "./lite-llm.js"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
ChutesHandler,
|
||||
LiteLLMHandler,
|
||||
ClaudeCodeHandler,
|
||||
GeminiCliHandler,
|
||||
} from "./providers"
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
|
@ -85,6 +86,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
|||
return new LmStudioHandler(options)
|
||||
case "gemini":
|
||||
return new GeminiHandler(options)
|
||||
case "gemini-cli":
|
||||
return new GeminiCliHandler(options)
|
||||
case "openai-native":
|
||||
return new OpenAiNativeHandler(options)
|
||||
case "deepseek":
|
||||
|
|
|
|||
330
src/api/providers/__tests__/gemini-cli.spec.ts
Normal file
330
src/api/providers/__tests__/gemini-cli.spec.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
// npx vitest run src/api/providers/__tests__/gemini-cli.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { spawn } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
import { GeminiCliHandler } from "../gemini-cli"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("GeminiCliHandler", () => {
|
||||
let handler: GeminiCliHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOptions = {
|
||||
apiModelId: "gemini-2.0-flash-001",
|
||||
modelTemperature: 0.7,
|
||||
geminiCliProjectId: "test-project",
|
||||
}
|
||||
handler = new GeminiCliHandler(mockOptions)
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the correct model configuration", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("gemini-2.0-flash-001")
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
expect(model.info.contextWindow).toBe(1_048_576)
|
||||
})
|
||||
|
||||
it("should use default model when apiModelId is not provided", () => {
|
||||
handler = new GeminiCliHandler({})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("gemini-2.0-flash-001")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should execute gemini CLI and return response", async () => {
|
||||
const mockProcess = new EventEmitter() as any
|
||||
mockProcess.stdout = new EventEmitter()
|
||||
mockProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess)
|
||||
|
||||
const promptPromise = handler.completePrompt("Test prompt")
|
||||
|
||||
// Simulate CLI response
|
||||
mockProcess.stdout.emit("data", JSON.stringify({ text: "Test response" }))
|
||||
mockProcess.emit("close", 0)
|
||||
|
||||
const result = await promptPromise
|
||||
expect(result).toBe("Test response")
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"gemini",
|
||||
expect.arrayContaining([
|
||||
"prompt",
|
||||
"Test prompt",
|
||||
"--model",
|
||||
"gemini-2.0-flash-001",
|
||||
"--project",
|
||||
"test-project",
|
||||
"--temperature",
|
||||
"0.7",
|
||||
"--json",
|
||||
]),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle CLI errors", async () => {
|
||||
const mockProcess = new EventEmitter() as any
|
||||
mockProcess.stdout = new EventEmitter()
|
||||
mockProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess)
|
||||
|
||||
const promptPromise = handler.completePrompt("Test prompt")
|
||||
|
||||
// Simulate CLI error
|
||||
mockProcess.stderr.emit("data", "Error message")
|
||||
mockProcess.emit("close", 1)
|
||||
|
||||
await expect(promptPromise).rejects.toThrow("Gemini CLI failed with code 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle authentication flow when not authenticated", async () => {
|
||||
// Mock auth check to fail
|
||||
const mockAuthProcess = new EventEmitter() as any
|
||||
mockAuthProcess.stdout = new EventEmitter()
|
||||
mockAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
// Mock OAuth flow
|
||||
const mockOAuthProcess = new EventEmitter() as any
|
||||
mockOAuthProcess.stdout = new EventEmitter()
|
||||
mockOAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn)
|
||||
.mockReturnValueOnce(mockAuthProcess) // auth status check
|
||||
.mockReturnValueOnce(mockOAuthProcess) // auth login
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
const generator = handler.createMessage("System prompt", messages)
|
||||
|
||||
// Simulate auth check failure immediately
|
||||
setImmediate(() => {
|
||||
mockAuthProcess.emit("close", 1)
|
||||
})
|
||||
|
||||
// Simulate OAuth success
|
||||
setImmediate(() => {
|
||||
mockOAuthProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Please authenticate with Google in your browser. Once authenticated, please retry your request.",
|
||||
})
|
||||
})
|
||||
|
||||
it("should process messages and return response with usage", async () => {
|
||||
// Mock successful auth check
|
||||
const mockAuthProcess = new EventEmitter() as any
|
||||
mockAuthProcess.stdout = new EventEmitter()
|
||||
mockAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
// Mock gemini execution
|
||||
const mockGeminiProcess = new EventEmitter() as any
|
||||
mockGeminiProcess.stdout = new EventEmitter()
|
||||
mockGeminiProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn)
|
||||
.mockReturnValueOnce(mockAuthProcess) // auth status check
|
||||
.mockReturnValueOnce(mockGeminiProcess) // gemini prompt
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
const generator = handler.createMessage("System prompt", messages)
|
||||
|
||||
// Simulate successful auth
|
||||
setImmediate(() => {
|
||||
mockAuthProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
// Simulate gemini response with telemetry
|
||||
setImmediate(() => {
|
||||
mockGeminiProcess.stderr.emit("data", "Input tokens: 100\nOutput tokens: 50")
|
||||
mockGeminiProcess.stdout.emit("data", JSON.stringify({ text: "Hello response" }))
|
||||
mockGeminiProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Hello response",
|
||||
})
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it("should format complex messages correctly", async () => {
|
||||
// Mock successful auth and execution
|
||||
const mockAuthProcess = new EventEmitter() as any
|
||||
mockAuthProcess.stdout = new EventEmitter()
|
||||
mockAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
const mockGeminiProcess = new EventEmitter() as any
|
||||
mockGeminiProcess.stdout = new EventEmitter()
|
||||
mockGeminiProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn).mockReturnValueOnce(mockAuthProcess).mockReturnValueOnce(mockGeminiProcess)
|
||||
|
||||
const messages = [
|
||||
{ role: "user" as const, content: "Hello" },
|
||||
{ role: "assistant" as const, content: "Hi there!" },
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ type: "text" as const, text: "Check this image" },
|
||||
{
|
||||
type: "image" as const,
|
||||
source: { type: "base64" as const, media_type: "image/png" as const, data: "base64data" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const generator = handler.createMessage("System prompt", messages)
|
||||
|
||||
// Simulate successful auth
|
||||
setImmediate(() => {
|
||||
mockAuthProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
// Simulate gemini response
|
||||
setImmediate(() => {
|
||||
// Check the formatted prompt after auth completes
|
||||
const spawnCalls = vi.mocked(spawn).mock.calls
|
||||
if (spawnCalls.length > 1) {
|
||||
const callArgs = spawnCalls[1][1]
|
||||
const promptArg = callArgs[1] // The prompt is the second argument
|
||||
|
||||
expect(promptArg).toContain("System prompt")
|
||||
expect(promptArg).toContain("User: Hello")
|
||||
expect(promptArg).toContain("Assistant: Hi there!")
|
||||
expect(promptArg).toContain("User: Check this image")
|
||||
expect(promptArg).toContain("[Image provided]")
|
||||
}
|
||||
|
||||
mockGeminiProcess.stdout.emit("data", JSON.stringify({ text: "Response" }))
|
||||
mockGeminiProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Response",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("telemetry parsing", () => {
|
||||
it("should parse token usage from stderr output", async () => {
|
||||
const mockAuthProcess = new EventEmitter() as any
|
||||
mockAuthProcess.stdout = new EventEmitter()
|
||||
mockAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
const mockGeminiProcess = new EventEmitter() as any
|
||||
mockGeminiProcess.stdout = new EventEmitter()
|
||||
mockGeminiProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn).mockReturnValueOnce(mockAuthProcess).mockReturnValueOnce(mockGeminiProcess)
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test" }]
|
||||
const generator = handler.createMessage("System prompt", messages)
|
||||
|
||||
// Simulate successful auth
|
||||
setImmediate(() => {
|
||||
mockAuthProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
// Simulate various telemetry outputs
|
||||
setImmediate(() => {
|
||||
mockGeminiProcess.stderr.emit("data", "Input tokens: 150")
|
||||
mockGeminiProcess.stderr.emit("data", "Output tokens: 75")
|
||||
mockGeminiProcess.stderr.emit("data", "Cache read tokens: 25")
|
||||
mockGeminiProcess.stderr.emit("data", "Cache write tokens: 10")
|
||||
mockGeminiProcess.stdout.emit("data", JSON.stringify({ text: "Response" }))
|
||||
mockGeminiProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = results.find((r) => r.type === "usage")
|
||||
expect(usageChunk).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 150,
|
||||
outputTokens: 75,
|
||||
cacheReadTokens: 25,
|
||||
cacheWriteTokens: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("cost calculation", () => {
|
||||
it("should calculate cost correctly", async () => {
|
||||
const mockAuthProcess = new EventEmitter() as any
|
||||
mockAuthProcess.stdout = new EventEmitter()
|
||||
mockAuthProcess.stderr = new EventEmitter()
|
||||
|
||||
const mockGeminiProcess = new EventEmitter() as any
|
||||
mockGeminiProcess.stdout = new EventEmitter()
|
||||
mockGeminiProcess.stderr = new EventEmitter()
|
||||
|
||||
vi.mocked(spawn).mockReturnValueOnce(mockAuthProcess).mockReturnValueOnce(mockGeminiProcess)
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test" }]
|
||||
const generator = handler.createMessage("System prompt", messages)
|
||||
|
||||
// Simulate successful auth
|
||||
setImmediate(() => {
|
||||
mockAuthProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
// Simulate telemetry with known values
|
||||
setImmediate(() => {
|
||||
mockGeminiProcess.stderr.emit("data", "Input tokens: 1000000") // 1M tokens
|
||||
mockGeminiProcess.stderr.emit("data", "Output tokens: 1000000") // 1M tokens
|
||||
mockGeminiProcess.stdout.emit("data", JSON.stringify({ text: "Response" }))
|
||||
mockGeminiProcess.emit("close", 0)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = results.find((r) => r.type === "usage")
|
||||
expect(usageChunk?.totalCost).toBe(0.5) // 0.1 + 0.4 = 0.5 for 1M input + 1M output
|
||||
})
|
||||
})
|
||||
})
|
||||
311
src/api/providers/gemini-cli.ts
Normal file
311
src/api/providers/gemini-cli.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { spawn, ChildProcess } from "child_process"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { type ModelInfo, type GeminiCliModelId, geminiCliDefaultModelId, geminiCliModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
interface GeminiCliUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
}
|
||||
|
||||
export class GeminiCliHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private telemetryCollector: Map<string, GeminiCliUsage> = new Map()
|
||||
private currentRequestId: string | null = null
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
this.options = options
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
// Generate a unique request ID for telemetry tracking
|
||||
this.currentRequestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Initialize usage tracking for this request
|
||||
this.telemetryCollector.set(this.currentRequestId, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
})
|
||||
|
||||
try {
|
||||
// Prepare the prompt combining system prompt and messages
|
||||
const fullPrompt = this.formatPrompt(systemPrompt, messages)
|
||||
|
||||
// Build the command arguments
|
||||
const args = this.buildCommandArgs(modelId, fullPrompt)
|
||||
|
||||
// Check if user needs to authenticate
|
||||
const needsAuth = await this.checkAuthentication()
|
||||
if (needsAuth) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "Please authenticate with Google in your browser. Once authenticated, please retry your request.",
|
||||
}
|
||||
|
||||
// Trigger OAuth flow
|
||||
await this.triggerOAuthFlow()
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the gemini CLI command
|
||||
const { text, usage } = await this.executeGeminiCli(args)
|
||||
|
||||
// Yield the response text
|
||||
yield {
|
||||
type: "text",
|
||||
text,
|
||||
}
|
||||
|
||||
// Yield usage information
|
||||
if (usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
totalCost: this.calculateCost(info, usage),
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clean up telemetry data for this request
|
||||
if (this.currentRequestId) {
|
||||
this.telemetryCollector.delete(this.currentRequestId)
|
||||
this.currentRequestId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
let id = modelId && modelId in geminiCliModels ? (modelId as GeminiCliModelId) : geminiCliDefaultModelId
|
||||
const info: ModelInfo = geminiCliModels[id]
|
||||
const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options })
|
||||
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId } = this.getModel()
|
||||
const args = this.buildCommandArgs(modelId, prompt)
|
||||
|
||||
const { text } = await this.executeGeminiCli(args)
|
||||
return text
|
||||
}
|
||||
|
||||
private formatPrompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
|
||||
let fullPrompt = systemPrompt + "\n\n"
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
fullPrompt += "User: "
|
||||
} else if (message.role === "assistant") {
|
||||
fullPrompt += "Assistant: "
|
||||
}
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
fullPrompt += message.content + "\n\n"
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
fullPrompt += content.text + "\n"
|
||||
} else if (content.type === "image") {
|
||||
fullPrompt += "[Image provided]\n"
|
||||
}
|
||||
}
|
||||
fullPrompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return fullPrompt.trim()
|
||||
}
|
||||
|
||||
private buildCommandArgs(modelId: string, prompt: string): string[] {
|
||||
const args = ["prompt", prompt, "--model", modelId]
|
||||
|
||||
// Add project ID if configured
|
||||
if (this.options.geminiCliProjectId) {
|
||||
args.push("--project", this.options.geminiCliProjectId)
|
||||
}
|
||||
|
||||
// Add temperature if configured
|
||||
if (this.options.modelTemperature !== undefined && this.options.modelTemperature !== null) {
|
||||
args.push("--temperature", this.options.modelTemperature.toString())
|
||||
}
|
||||
|
||||
// Add max tokens if configured
|
||||
if (this.options.modelMaxTokens) {
|
||||
args.push("--max-output-tokens", this.options.modelMaxTokens.toString())
|
||||
}
|
||||
|
||||
// Enable JSON output for easier parsing
|
||||
args.push("--json")
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
private async checkAuthentication(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const checkAuth = spawn("gemini", ["auth", "status"], {
|
||||
env: { ...process.env },
|
||||
})
|
||||
|
||||
checkAuth.on("close", (code) => {
|
||||
// If auth check fails, user needs to authenticate
|
||||
resolve(code !== 0)
|
||||
})
|
||||
|
||||
checkAuth.on("error", () => {
|
||||
// If command fails, assume auth is needed
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async triggerOAuthFlow(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const authProcess = spawn("gemini", ["auth", "login"], {
|
||||
env: { ...process.env },
|
||||
})
|
||||
|
||||
authProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error("Authentication failed"))
|
||||
}
|
||||
})
|
||||
|
||||
authProcess.on("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async executeGeminiCli(args: string[]): Promise<{ text: string; usage?: GeminiCliUsage }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const geminiProcess = spawn("gemini", args, {
|
||||
env: { ...process.env },
|
||||
})
|
||||
|
||||
geminiProcess.stdout.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
geminiProcess.stderr.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
|
||||
// Try to parse telemetry data from stderr
|
||||
this.parseTelemetryFromOutput(data.toString())
|
||||
})
|
||||
|
||||
geminiProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Gemini CLI failed with code ${code}: ${stderr}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse JSON response
|
||||
const response = JSON.parse(stdout)
|
||||
|
||||
// Extract text and usage
|
||||
const text = response.text || response.content || ""
|
||||
const usage = this.currentRequestId ? this.telemetryCollector.get(this.currentRequestId) : undefined
|
||||
|
||||
resolve({ text, usage })
|
||||
} catch (error) {
|
||||
// Fallback to plain text if JSON parsing fails
|
||||
resolve({ text: stdout.trim() })
|
||||
}
|
||||
})
|
||||
|
||||
geminiProcess.on("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private parseTelemetryFromOutput(output: string): void {
|
||||
if (!this.currentRequestId) return
|
||||
|
||||
// Look for token usage patterns in the output
|
||||
// This is a simplified example - actual implementation would depend on gemini CLI output format
|
||||
const patterns = {
|
||||
inputTokens: /Input tokens:\s*(\d+)/i,
|
||||
outputTokens: /Output tokens:\s*(\d+)/i,
|
||||
cacheRead: /Cache read tokens:\s*(\d+)/i,
|
||||
cacheWrite: /Cache write tokens:\s*(\d+)/i,
|
||||
}
|
||||
|
||||
const usage = this.telemetryCollector.get(this.currentRequestId)
|
||||
if (!usage) return
|
||||
|
||||
for (const [key, pattern] of Object.entries(patterns)) {
|
||||
const match = output.match(pattern)
|
||||
if (match) {
|
||||
const value = parseInt(match[1], 10)
|
||||
switch (key) {
|
||||
case "inputTokens":
|
||||
usage.inputTokens = value
|
||||
break
|
||||
case "outputTokens":
|
||||
usage.outputTokens = value
|
||||
break
|
||||
case "cacheRead":
|
||||
usage.cacheReadTokens = value
|
||||
break
|
||||
case "cacheWrite":
|
||||
usage.cacheWriteTokens = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.telemetryCollector.set(this.currentRequestId, usage)
|
||||
}
|
||||
|
||||
private calculateCost(info: ModelInfo, usage: GeminiCliUsage): number | undefined {
|
||||
if (!info.inputPrice || !info.outputPrice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const inputCost = (usage.inputTokens / 1_000_000) * info.inputPrice
|
||||
const outputCost = (usage.outputTokens / 1_000_000) * info.outputPrice
|
||||
|
||||
let cacheReadCost = 0
|
||||
if (usage.cacheReadTokens && info.cacheReadsPrice) {
|
||||
cacheReadCost = (usage.cacheReadTokens / 1_000_000) * info.cacheReadsPrice
|
||||
}
|
||||
|
||||
let cacheWriteCost = 0
|
||||
if (usage.cacheWriteTokens && info.cacheWritesPrice) {
|
||||
cacheWriteCost = (usage.cacheWriteTokens / 1_000_000) * info.cacheWritesPrice
|
||||
}
|
||||
|
||||
return inputCost + outputCost + cacheReadCost + cacheWriteCost
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export { ClaudeCodeHandler } from "./claude-code"
|
|||
export { DeepSeekHandler } from "./deepseek"
|
||||
export { FakeAIHandler } from "./fake-ai"
|
||||
export { GeminiHandler } from "./gemini"
|
||||
export { GeminiCliHandler } from "./gemini-cli"
|
||||
export { GlamaHandler } from "./glama"
|
||||
export { GroqHandler } from "./groq"
|
||||
export { HumanRelayHandler } from "./human-relay"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
anthropicDefaultModelId,
|
||||
claudeCodeDefaultModelId,
|
||||
geminiDefaultModelId,
|
||||
geminiCliDefaultModelId,
|
||||
deepSeekDefaultModelId,
|
||||
mistralDefaultModelId,
|
||||
xaiDefaultModelId,
|
||||
|
|
@ -56,6 +57,7 @@ import {
|
|||
ClaudeCode,
|
||||
DeepSeek,
|
||||
Gemini,
|
||||
GeminiCli,
|
||||
Glama,
|
||||
Groq,
|
||||
LMStudio,
|
||||
|
|
@ -286,6 +288,7 @@ const ApiOptions = ({
|
|||
"claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId },
|
||||
"openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
|
||||
gemini: { field: "apiModelId", default: geminiDefaultModelId },
|
||||
"gemini-cli": { field: "apiModelId", default: geminiCliDefaultModelId },
|
||||
deepseek: { field: "apiModelId", default: deepSeekDefaultModelId },
|
||||
mistral: { field: "apiModelId", default: mistralDefaultModelId },
|
||||
xai: { field: "apiModelId", default: xaiDefaultModelId },
|
||||
|
|
@ -447,6 +450,10 @@ const ApiOptions = ({
|
|||
<Gemini apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
||||
{selectedProvider === "gemini-cli" && (
|
||||
<GeminiCli apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
||||
{selectedProvider === "openai" && (
|
||||
<OpenAICompatible
|
||||
apiConfiguration={apiConfiguration}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
claudeCodeModels,
|
||||
deepSeekModels,
|
||||
geminiModels,
|
||||
geminiCliModels,
|
||||
mistralModels,
|
||||
openAiNativeModels,
|
||||
vertexModels,
|
||||
|
|
@ -20,6 +21,7 @@ export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, Mod
|
|||
bedrock: bedrockModels,
|
||||
deepseek: deepSeekModels,
|
||||
gemini: geminiModels,
|
||||
"gemini-cli": geminiCliModels,
|
||||
mistral: mistralModels,
|
||||
"openai-native": openAiNativeModels,
|
||||
vertex: vertexModels,
|
||||
|
|
@ -33,6 +35,7 @@ export const PROVIDERS = [
|
|||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "claude-code", label: "Claude Code" },
|
||||
{ value: "gemini", label: "Google Gemini" },
|
||||
{ value: "gemini-cli", label: "Google Gemini CLI" },
|
||||
{ value: "deepseek", label: "DeepSeek" },
|
||||
{ value: "openai-native", label: "OpenAI" },
|
||||
{ value: "openai", label: "OpenAI Compatible" },
|
||||
|
|
|
|||
63
webview-ui/src/components/settings/providers/GeminiCli.tsx
Normal file
63
webview-ui/src/components/settings/providers/GeminiCli.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from "react"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { buildDocLink } from "@src/utils/docLinks"
|
||||
|
||||
interface GeminiCliProps {
|
||||
apiConfiguration: ProviderSettings
|
||||
setApiConfigurationField: <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K]) => void
|
||||
}
|
||||
|
||||
export const GeminiCli: React.FC<GeminiCliProps> = ({ apiConfiguration, setApiConfigurationField }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
Use Google Gemini models through the Gemini CLI, which provides free access to Gemini Pro through Google
|
||||
Code Assist.
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
<VSCodeLink href={buildDocLink("providers/gemini-cli", "provider_docs")} target="_blank">
|
||||
View setup instructions
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-medium mb-1">Project ID (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiConfiguration.geminiCliProjectId || ""}
|
||||
onChange={(e) => setApiConfigurationField("geminiCliProjectId", e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-vscode-input text-vscode-foreground border border-vscode-inputBorder rounded focus:outline-none focus:border-vscode-focusBorder"
|
||||
placeholder="your-gcp-project-id"
|
||||
/>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-1">
|
||||
For paid Google Cloud accounts. Leave empty for free tier access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-vscode-editorWidget-background border border-vscode-editorWidget-border rounded">
|
||||
<h4 className="font-medium mb-2">Authentication</h4>
|
||||
<div className="text-sm text-vscode-descriptionForeground space-y-2">
|
||||
<p>
|
||||
1. Install the Gemini CLI: <code>npm install -g @google/generative-ai-cli</code>
|
||||
</p>
|
||||
<p>2. When you send your first message, a browser window will open for Google authentication</p>
|
||||
<p>3. After authenticating, your session will be saved for future use</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-vscode-editorWidget-background border border-vscode-editorWidget-border rounded">
|
||||
<h4 className="font-medium mb-2">Features</h4>
|
||||
<ul className="text-sm text-vscode-descriptionForeground space-y-1 list-disc list-inside">
|
||||
<li>Free access to Gemini Pro models through Google Code Assist</li>
|
||||
<li>Automatic OAuth authentication flow</li>
|
||||
<li>Built-in telemetry for token usage tracking</li>
|
||||
<li>Support for advanced features like debug mode and IDE mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export { Chutes } from "./Chutes"
|
|||
export { ClaudeCode } from "./ClaudeCode"
|
||||
export { DeepSeek } from "./DeepSeek"
|
||||
export { Gemini } from "./Gemini"
|
||||
export { GeminiCli } from "./GeminiCli"
|
||||
export { Glama } from "./Glama"
|
||||
export { Groq } from "./Groq"
|
||||
export { LMStudio } from "./LMStudio"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue