|
| 1 | +import * as assert from "assert" |
| 2 | + |
| 3 | +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" |
| 4 | + |
| 5 | +import { setDefaultSuiteTimeout } from "../test-utils" |
| 6 | +import { waitUntilCompleted } from "../utils" |
| 7 | + |
| 8 | +const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY |
| 9 | +const GEMINI_MODEL_ID = "gemini-3.1-pro-preview" |
| 10 | + |
| 11 | +type CapturedGeminiRequest = { |
| 12 | + model?: string |
| 13 | + lastUserMessage: string |
| 14 | + thinkingConfig?: Record<string, unknown> |
| 15 | + hasTools: boolean |
| 16 | + toolDeclarationCount: number |
| 17 | +} |
| 18 | + |
| 19 | +function getRequestUrl(input: RequestInfo | URL): string { |
| 20 | + return typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url |
| 21 | +} |
| 22 | + |
| 23 | +function isUrlWithOrigin(rawUrl: string, expectedOrigin: string): boolean { |
| 24 | + try { |
| 25 | + return new URL(rawUrl).origin === expectedOrigin |
| 26 | + } catch { |
| 27 | + return false |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +function isGeminiGenerateContentUrl(rawUrl: string): boolean { |
| 32 | + try { |
| 33 | + const pathname = new URL(rawUrl).pathname |
| 34 | + return pathname.includes(":streamGenerateContent") || pathname.includes(":generateContent") |
| 35 | + } catch { |
| 36 | + return false |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +function extractGeminiModel(rawUrl: string): string | undefined { |
| 41 | + try { |
| 42 | + const pathname = new URL(rawUrl).pathname |
| 43 | + const match = pathname.match(/\/models\/([^:]+):(streamGenerateContent|generateContent)$/) |
| 44 | + return match?.[1] |
| 45 | + } catch { |
| 46 | + return undefined |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +function extractLastUserMessage( |
| 51 | + contents?: Array<{ |
| 52 | + role?: string |
| 53 | + parts?: Array<{ text?: string }> |
| 54 | + }>, |
| 55 | +): string { |
| 56 | + const lastUser = [...(contents ?? [])].reverse().find((content) => content.role === "user") |
| 57 | + |
| 58 | + if (!lastUser?.parts) { |
| 59 | + return "" |
| 60 | + } |
| 61 | + |
| 62 | + return lastUser.parts |
| 63 | + .map((part) => (typeof part?.text === "string" ? part.text : JSON.stringify(part ?? ""))) |
| 64 | + .join("") |
| 65 | +} |
| 66 | + |
| 67 | +function installGeminiRequestCapture(capture: CapturedGeminiRequest[], baseUrl: string): () => void { |
| 68 | + const originalFetch = globalThis.fetch |
| 69 | + const targetOrigin = new URL(baseUrl).origin |
| 70 | + |
| 71 | + globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 72 | + const url = getRequestUrl(input) |
| 73 | + |
| 74 | + if (isUrlWithOrigin(url, targetOrigin) && isGeminiGenerateContentUrl(url)) { |
| 75 | + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {} |
| 76 | + const tools = Array.isArray(body.tools) ? body.tools : [] |
| 77 | + const toolDeclarationCount = tools.reduce((count: number, tool: { functionDeclarations?: unknown[] }) => { |
| 78 | + return count + (Array.isArray(tool.functionDeclarations) ? tool.functionDeclarations.length : 0) |
| 79 | + }, 0) |
| 80 | + |
| 81 | + capture.push({ |
| 82 | + model: extractGeminiModel(url), |
| 83 | + lastUserMessage: extractLastUserMessage(body.contents), |
| 84 | + thinkingConfig: |
| 85 | + body.generationConfig && typeof body.generationConfig === "object" |
| 86 | + ? (body.generationConfig.thinkingConfig as Record<string, unknown> | undefined) |
| 87 | + : undefined, |
| 88 | + hasTools: tools.length > 0, |
| 89 | + toolDeclarationCount, |
| 90 | + }) |
| 91 | + } |
| 92 | + |
| 93 | + return originalFetch.call(globalThis, input, init as RequestInit) |
| 94 | + } as typeof globalThis.fetch |
| 95 | + |
| 96 | + return () => { |
| 97 | + globalThis.fetch = originalFetch |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +suite("Gemini provider", function () { |
| 102 | + setDefaultSuiteTimeout(this) |
| 103 | + |
| 104 | + let restoreFetch: (() => void) | undefined |
| 105 | + const requests: CapturedGeminiRequest[] = [] |
| 106 | + |
| 107 | + setup(function () { |
| 108 | + if (!process.env.AIMOCK_URL && !GEMINI_API_KEY) { |
| 109 | + this.skip() |
| 110 | + } |
| 111 | + }) |
| 112 | + |
| 113 | + suiteSetup(() => { |
| 114 | + restoreFetch = installGeminiRequestCapture( |
| 115 | + requests, |
| 116 | + process.env.AIMOCK_URL || "https://generativelanguage.googleapis.com", |
| 117 | + ) |
| 118 | + }) |
| 119 | + |
| 120 | + suiteTeardown(async () => { |
| 121 | + restoreFetch?.() |
| 122 | + restoreFetch = undefined |
| 123 | + |
| 124 | + const aimockUrl = process.env.AIMOCK_URL |
| 125 | + const isRecord = process.env.AIMOCK_RECORD === "true" |
| 126 | + await globalThis.api.setConfiguration({ |
| 127 | + apiProvider: "openrouter" as const, |
| 128 | + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, |
| 129 | + openRouterModelId: "openai/gpt-4.1", |
| 130 | + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), |
| 131 | + }) |
| 132 | + }) |
| 133 | + |
| 134 | + for (const reasoningEnabled of [true, false] as const) { |
| 135 | + test(`Should complete a task end-to-end using ${GEMINI_MODEL_ID} via Gemini provider with reasoning ${ |
| 136 | + reasoningEnabled ? "enabled" : "disabled" |
| 137 | + }`, async () => { |
| 138 | + requests.length = 0 |
| 139 | + |
| 140 | + const api = globalThis.api |
| 141 | + const aimockUrl = process.env.AIMOCK_URL |
| 142 | + const isRecord = process.env.AIMOCK_RECORD === "true" |
| 143 | + const promptTag = reasoningEnabled ? "gemini-e2e:reasoning-on" : "gemini-e2e:reasoning-off" |
| 144 | + |
| 145 | + await api.setConfiguration({ |
| 146 | + apiProvider: "gemini" as const, |
| 147 | + geminiApiKey: aimockUrl && !isRecord ? "mock-key" : GEMINI_API_KEY!, |
| 148 | + apiModelId: GEMINI_MODEL_ID, |
| 149 | + enableReasoningEffort: reasoningEnabled, |
| 150 | + reasoningEffort: reasoningEnabled ? ("high" as const) : ("disable" as const), |
| 151 | + ...(aimockUrl && { googleGeminiBaseUrl: aimockUrl }), |
| 152 | + }) |
| 153 | + |
| 154 | + const messages: ClineMessage[] = [] |
| 155 | + const messageHandler = ({ message }: { message: ClineMessage }) => { |
| 156 | + if (message.type === "say" && message.partial === false) { |
| 157 | + messages.push(message) |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + api.on(RooCodeEventName.Message, messageHandler) |
| 162 | + |
| 163 | + try { |
| 164 | + const taskId = await api.startNewTask({ |
| 165 | + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, |
| 166 | + text: `${promptTag}: what is 2+2? Reply with only the number.`, |
| 167 | + }) |
| 168 | + |
| 169 | + await waitUntilCompleted({ api, taskId }) |
| 170 | + } finally { |
| 171 | + api.off(RooCodeEventName.Message, messageHandler) |
| 172 | + } |
| 173 | + |
| 174 | + const firstRequest = requests.find((request) => request.lastUserMessage.includes(promptTag)) |
| 175 | + assert.ok(firstRequest, "Gemini provider should issue a generate content request for the task prompt") |
| 176 | + assert.strictEqual(firstRequest.model, GEMINI_MODEL_ID) |
| 177 | + assert.ok(firstRequest.hasTools, "Gemini provider should include tool declarations in the request") |
| 178 | + assert.ok( |
| 179 | + firstRequest.toolDeclarationCount > 0, |
| 180 | + "Gemini provider should declare at least one callable tool", |
| 181 | + ) |
| 182 | + |
| 183 | + if (reasoningEnabled) { |
| 184 | + assert.ok( |
| 185 | + firstRequest.thinkingConfig, |
| 186 | + "Reasoning-enabled Gemini requests should include thinkingConfig", |
| 187 | + ) |
| 188 | + } else { |
| 189 | + assert.strictEqual( |
| 190 | + firstRequest.thinkingConfig, |
| 191 | + undefined, |
| 192 | + "Reasoning-disabled Gemini requests should omit thinkingConfig", |
| 193 | + ) |
| 194 | + } |
| 195 | + |
| 196 | + const completionMessage = messages.find( |
| 197 | + ({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4", |
| 198 | + ) |
| 199 | + |
| 200 | + assert.ok(completionMessage, "Task should complete with the expected Gemini provider response") |
| 201 | + }) |
| 202 | + } |
| 203 | +}) |
0 commit comments