feat: add Gemini CLI provider integration

- Add @google/gemini-cli-core dependency
- Create GeminiCliHandler provider implementation using OAuth authentication
- Add type definitions for gemini-cli models
- Update provider exports and buildApiHandler
- Add comprehensive tests for the new provider

This integration allows users to authenticate with Gemini CLI using OAuth
and access Gemini models through the official CLI library.
This commit is contained in:
Roo Code 2025-08-29 06:54:55 +00:00
parent cd9e92fa9b
commit 0184d6eafb
9 changed files with 1494 additions and 17 deletions

View file

@ -62,5 +62,8 @@
"form-data": ">=4.0.4",
"bluebird": ">=3.7.2"
}
},
"dependencies": {
"@google/gemini-cli-core": "^0.2.2"
}
}

View file

@ -13,6 +13,7 @@ import {
featherlessModels,
fireworksModels,
geminiModels,
geminiCliModels,
groqModels,
ioIntelligenceModels,
mistralModels,
@ -470,7 +471,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
}
export const MODELS_BY_PROVIDER: Record<
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama">,
Exclude<ProviderName, "fake-ai" | "human-relay" | "lmstudio" | "openai" | "ollama">,
{ id: ProviderName; label: string; models: string[] }
> = {
anthropic: {
@ -515,6 +516,11 @@ export const MODELS_BY_PROVIDER: Record<
label: "Google Gemini",
models: Object.keys(geminiModels),
},
"gemini-cli": {
id: "gemini-cli",
label: "Google Gemini CLI",
models: Object.keys(geminiCliModels),
},
groq: { id: "groq", label: "Groq", models: Object.keys(groqModels) },
"io-intelligence": {
id: "io-intelligence",

View file

@ -0,0 +1,54 @@
import type { ModelInfo } from "../model.js"
// Gemini CLI models - using the same models as regular Gemini
// The CLI provides access to the same models through OAuth authentication
export type GeminiCliModelId = keyof typeof geminiCliModels
export const geminiCliDefaultModelId: GeminiCliModelId = "gemini-2.0-flash-001"
// Re-use the same model definitions as the regular Gemini provider
// since Gemini CLI provides access to the same models
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, // This is the pricing for prompts above 128k tokens.
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>

View file

@ -8,6 +8,7 @@ export * from "./doubao.js"
export * from "./featherless.js"
export * from "./fireworks.js"
export * from "./gemini.js"
export * from "./gemini-cli.js"
export * from "./glama.js"
export * from "./groq.js"
export * from "./huggingface.js"

888
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ import {
OpenAiHandler,
LmStudioHandler,
GeminiHandler,
GeminiCliHandler,
OpenAiNativeHandler,
DeepSeekHandler,
MoonshotHandler,
@ -162,8 +163,10 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new FeatherlessHandler(options)
case "vercel-ai-gateway":
return new VercelAiGatewayHandler(options)
case "gemini-cli":
return new GeminiCliHandler(options)
default:
apiProvider satisfies "gemini-cli" | undefined
apiProvider satisfies undefined
return new AnthropicHandler(options)
}
}

View file

@ -0,0 +1,268 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { GeminiCliHandler } from "../gemini-cli"
import { Anthropic } from "@anthropic-ai/sdk"
// Mock the @google/gemini-cli-core module
vi.mock("@google/gemini-cli-core", () => ({
GeminiClient: vi.fn().mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
startChat: vi.fn().mockResolvedValue(undefined),
addHistory: vi.fn().mockResolvedValue(undefined),
sendMessageStream: vi.fn().mockImplementation(async function* () {
yield { type: "content", value: "Test response" }
return {
getDebugResponses: vi.fn().mockReturnValue([
{
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
},
},
]),
}
}),
generateContent: vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: "Test completion response" }],
},
},
],
}),
getDebugResponses: vi.fn().mockReturnValue([
{
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
},
},
]),
})),
Config: vi.fn().mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
startChat: vi.fn().mockResolvedValue(undefined),
addHistory: vi.fn().mockResolvedValue(undefined),
sendMessageStream: vi.fn().mockImplementation(async function* () {
yield { type: "content", value: "Test response" }
return {
getDebugResponses: vi.fn().mockReturnValue([
{
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
},
},
]),
}
}),
generateContent: vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: "Test completion response" }],
},
},
],
}),
}),
})),
AuthType: {
LOGIN_WITH_GOOGLE: "oauth-personal",
},
createContentGeneratorConfig: vi.fn().mockReturnValue({
model: "gemini-2.0-flash-001",
authType: "oauth-personal",
}),
}))
describe("GeminiCliHandler", () => {
let handler: GeminiCliHandler
beforeEach(() => {
vi.clearAllMocks()
handler = new GeminiCliHandler({})
})
describe("constructor", () => {
it("should create an instance", () => {
expect(handler).toBeInstanceOf(GeminiCliHandler)
})
})
describe("getModel", () => {
it("should return default model when no apiModelId is provided", () => {
const model = handler.getModel()
expect(model.id).toBe("gemini-2.0-flash-001")
expect(model.info).toBeDefined()
expect(model.info.contextWindow).toBe(1_048_576)
})
it("should return specified model when apiModelId is provided", () => {
const customHandler = new GeminiCliHandler({
apiModelId: "gemini-1.5-flash-002",
})
const model = customHandler.getModel()
expect(model.id).toBe("gemini-1.5-flash-002")
})
it("should fall back to default model for invalid apiModelId", () => {
const customHandler = new GeminiCliHandler({
apiModelId: "invalid-model",
})
const model = customHandler.getModel()
expect(model.id).toBe("gemini-2.0-flash-001")
})
})
describe("createMessage", () => {
it("should stream messages from Gemini CLI", async () => {
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello, how are you?",
},
]
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
// Should have at least text chunk
expect(results.length).toBeGreaterThanOrEqual(1)
expect(results[0]).toEqual({ type: "text", text: "Test response" })
// Usage chunk may or may not be present depending on mock
const usageChunk = results.find((r) => r.type === "usage")
if (usageChunk) {
expect(usageChunk).toMatchObject({
type: "usage",
inputTokens: expect.any(Number),
outputTokens: expect.any(Number),
})
}
})
it("should handle reasoning/thought events", async () => {
// Mock a thought event
const mockClient = {
initialize: vi.fn().mockResolvedValue(undefined),
startChat: vi.fn().mockResolvedValue(undefined),
addHistory: vi.fn().mockResolvedValue(undefined),
sendMessageStream: vi.fn().mockImplementation(async function* () {
yield { type: "thought", value: { subject: "Analysis", description: "Thinking about the problem" } }
yield { type: "content", value: "Final answer" }
return {
getDebugResponses: vi.fn().mockReturnValue([]),
}
}),
}
const { Config } = await import("@google/gemini-cli-core")
;(Config as any).mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
}))
const customHandler = new GeminiCliHandler({})
const stream = customHandler.createMessage("System", [{ role: "user", content: "Test" }])
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results[0]).toEqual({ type: "reasoning", text: "Analysis: Thinking about the problem" })
expect(results[1]).toEqual({ type: "text", text: "Final answer" })
})
it("should handle authentication errors", async () => {
const mockClient = {
initialize: vi.fn().mockRejectedValue(new Error("OAuth authentication failed")),
}
const { Config } = await import("@google/gemini-cli-core")
;(Config as any).mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
}))
const customHandler = new GeminiCliHandler({})
const stream = customHandler.createMessage("System", [{ role: "user", content: "Test" }])
await expect(async () => {
for await (const _ of stream) {
// Should throw before yielding anything
}
}).rejects.toThrow(/auth/)
})
})
describe("completePrompt", () => {
it("should complete a prompt", async () => {
// Need to mock the initialized client properly
const mockClient = {
initialize: vi.fn().mockResolvedValue(undefined),
generateContent: vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: "Test completion response" }],
},
},
],
}),
}
const { Config } = await import("@google/gemini-cli-core")
;(Config as any).mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
}))
const customHandler = new GeminiCliHandler({})
const result = await customHandler.completePrompt("What is 2 + 2?")
expect(result).toBe("Test completion response")
})
it("should handle empty response", async () => {
const mockClient = {
initialize: vi.fn().mockResolvedValue(undefined),
generateContent: vi.fn().mockResolvedValue({
candidates: [],
}),
}
const { Config } = await import("@google/gemini-cli-core")
;(Config as any).mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
}))
const customHandler = new GeminiCliHandler({})
const result = await customHandler.completePrompt("Test")
expect(result).toBe("")
})
})
describe("countTokens", () => {
it("should fall back to base implementation", async () => {
const content: Anthropic.Messages.ContentBlockParam[] = [
{
type: "text",
text: "Test content for token counting",
},
]
// The implementation falls back to the base class
const count = await handler.countTokens(content)
expect(count).toBeGreaterThan(0)
})
})
})

View file

@ -0,0 +1,283 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import {
GeminiClient,
Config,
ConfigParameters,
AuthType,
ContentGeneratorConfig,
createContentGeneratorConfig,
} from "@google/gemini-cli-core"
import { Content, GenerateContentResponse } from "@google/genai"
import { type ModelInfo, type GeminiCliModelId, geminiCliDefaultModelId, geminiCliModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { t } from "i18next"
import type { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { convertAnthropicContentToGemini, convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { v4 as uuidv4 } from "uuid"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
/**
* Handler for Google Gemini CLI integration using OAuth authentication.
* This provider uses the @google/gemini-cli-core library to authenticate
* and interact with Gemini models through the official CLI OAuth flow.
*/
export class GeminiCliHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client?: GeminiClient
private config: Config
private initialized = false
constructor(options: ApiHandlerOptions) {
super()
this.options = options
// Create configuration for the Gemini CLI client
const configParams: ConfigParameters = {
sessionId: uuidv4(),
targetDir: process.cwd(),
cwd: process.cwd(),
debugMode: false,
model: this.getModel().id,
interactive: false,
}
this.config = new Config(configParams)
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.config.initialize()
this.client = this.config.getGeminiClient()
// Initialize the content generator with OAuth
const contentGeneratorConfig: ContentGeneratorConfig = createContentGeneratorConfig(
this.config,
AuthType.LOGIN_WITH_GOOGLE,
)
await this.client.initialize(contentGeneratorConfig)
this.initialized = true
}
}
async *createMessage(
systemInstruction: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
await this.ensureInitialized()
if (!this.client) {
throw new Error("Gemini CLI client not initialized")
}
const { id: model, info, maxTokens } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
try {
// Start a chat session
await this.client.startChat()
// Add system instruction as initial context
if (systemInstruction) {
await this.client.addHistory({
role: "user",
parts: [{ text: `System: ${systemInstruction}` }],
})
}
// Add message history
for (const content of contents) {
await this.client.addHistory(content)
}
// Get the last user message
const lastUserMessage = contents[contents.length - 1]
if (!lastUserMessage || !lastUserMessage.parts || lastUserMessage.parts.length === 0) {
throw new Error("No user message found")
}
// Send the message and stream the response
const abortController = new AbortController()
const promptId = uuidv4()
const stream = this.client.sendMessageStream(lastUserMessage.parts, abortController.signal, promptId)
let totalInputTokens = 0
let totalOutputTokens = 0
let turnResult: any = null
for await (const event of stream) {
// The stream returns Turn objects at the end
turnResult = event
// Handle content events
if (event.type === "content" && event.value) {
yield { type: "text", text: event.value }
}
// Handle thought events (reasoning)
if (event.type === "thought" && event.value) {
const thought = event.value
yield { type: "reasoning", text: `${thought.subject}: ${thought.description}` }
}
}
// The Turn object contains debug responses with usage metadata
if (turnResult && turnResult.getDebugResponses) {
const responses = turnResult.getDebugResponses()
if (responses && responses.length > 0) {
const lastResponse = responses[responses.length - 1]
if (lastResponse.usageMetadata) {
totalInputTokens = lastResponse.usageMetadata.promptTokenCount || 0
totalOutputTokens = lastResponse.usageMetadata.candidatesTokenCount || 0
}
}
}
// Yield usage information
if (totalInputTokens > 0 || totalOutputTokens > 0) {
yield {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalCost: this.calculateCost({
info,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
}),
}
}
} catch (error) {
if (error instanceof Error) {
// Check if it's an authentication error
if (error.message.includes("auth") || error.message.includes("OAuth")) {
throw new Error(
t("common:errors.gemini_cli.auth_failed", {
error: error.message,
help: "Please authenticate using the Gemini CLI",
}),
)
}
throw new Error(t("common:errors.gemini_cli.generate_stream", { error: error.message }))
}
throw error
}
}
override getModel() {
const modelId = this.options.apiModelId
let id = modelId && modelId in geminiCliModels ? (modelId as GeminiCliModelId) : geminiCliDefaultModelId
let 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> {
await this.ensureInitialized()
if (!this.client) {
throw new Error("Gemini CLI client not initialized")
}
try {
const { id: model } = this.getModel()
// Use generateContent method from the client
const contents: Content[] = [{ role: "user", parts: [{ text: prompt }] }]
const response = await this.client.generateContent(
contents,
{
temperature: this.options.modelTemperature ?? 0,
maxOutputTokens: this.options.modelMaxTokens,
},
new AbortController().signal,
model,
)
// Extract text from the response
if (response && response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0]
if (candidate.content && candidate.content.parts) {
const textParts = candidate.content.parts
.filter((part) => "text" in part)
.map((part) => part.text)
.join("")
return textParts
}
}
return ""
} catch (error) {
if (error instanceof Error) {
// Check if it's an authentication error
if (error.message.includes("auth") || error.message.includes("OAuth")) {
throw new Error(
t("common:errors.gemini_cli.auth_failed", {
error: error.message,
help: "Please authenticate using the Gemini CLI",
}),
)
}
throw new Error(t("common:errors.gemini_cli.generate_complete_prompt", { error: error.message }))
}
throw error
}
}
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
// The Gemini CLI library doesn't expose a direct token counting method
// Fall back to the base implementation
return super.countTokens(content)
}
private calculateCost({
info,
inputTokens,
outputTokens,
cacheReadTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
cacheReadTokens?: number
}) {
if (!info.inputPrice || !info.outputPrice) {
return undefined
}
let inputPrice = info.inputPrice
let outputPrice = info.outputPrice
let cacheReadsPrice = info.cacheReadsPrice || 0
// If there's tiered pricing then adjust the input and output token prices
// based on the input tokens used.
if (info.tiers) {
const tier = info.tiers.find((tier) => inputTokens <= tier.contextWindow)
if (tier) {
inputPrice = tier.inputPrice ?? inputPrice
outputPrice = tier.outputPrice ?? outputPrice
cacheReadsPrice = tier.cacheReadsPrice ?? cacheReadsPrice
}
}
// Subtract the cached input tokens from the total input tokens.
const uncachedInputTokens = inputTokens - cacheReadTokens
let cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0
const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000)
const outputTokensCost = outputPrice * (outputTokens / 1_000_000)
const totalCost = inputTokensCost + outputTokensCost + cacheReadCost
return totalCost
}
}

View file

@ -9,6 +9,7 @@ export { DoubaoHandler } from "./doubao"
export { MoonshotHandler } from "./moonshot"
export { FakeAIHandler } from "./fake-ai"
export { GeminiHandler } from "./gemini"
export { GeminiCliHandler } from "./gemini-cli"
export { GlamaHandler } from "./glama"
export { GroqHandler } from "./groq"
export { HuggingFaceHandler } from "./huggingface"