diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 26e5a7cc..a21c0720 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -262,7 +262,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; + const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."]; notes.push("Added --approval-mode yolo for unattended execution."); if (!instructionsFilePath) return notes; if (instructionsPrefix.length > 0) { @@ -324,7 +324,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - args.push(prompt); + args.push("--prompt", prompt); return args; }; diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index 4fe98fb6..10bc169e 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -231,6 +231,8 @@ export function describeGeminiFailure(parsed: Record): string | } const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; +const GEMINI_QUOTA_EXHAUSTED_RE = + /(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i; export function detectGeminiAuthRequired(input: { parsed: Record | null; @@ -248,6 +250,22 @@ export function detectGeminiAuthRequired(input: { return { requiresAuth }; } +export function detectGeminiQuotaExhausted(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { exhausted: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const exhausted = messages.some((line) => GEMINI_QUOTA_EXHAUSTED_RE.test(line)); + return { exhausted }; +} + export function isGeminiTurnLimitResult( parsed: Record | null | undefined, exitCode?: number | null, diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 8f63e5e2..145c3b7a 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -6,6 +6,7 @@ import type { } from "@paperclipai/adapter-utils"; import { asBoolean, + asNumber, asString, asStringArray, ensureAbsoluteDirectory, @@ -15,7 +16,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; -import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; +import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { @@ -134,13 +135,14 @@ export async function testEnvironment( const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); const sandbox = asBoolean(config.sandbox, false); + const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10)); const extraArgs = (() => { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; return asStringArray(config.args); })(); - const args = ["--output-format", "stream-json"]; + const args = ["--output-format", "stream-json", "--prompt", "Respond with hello."]; if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); if (approvalMode !== "default") args.push("--approval-mode", approvalMode); if (sandbox) { @@ -149,7 +151,6 @@ export async function testEnvironment( args.push("--sandbox=none"); } if (extraArgs.length > 0) args.push(...extraArgs); - args.push("Respond with hello."); const probe = await runChildProcess( `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -158,7 +159,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, onLog: async () => { }, }, @@ -170,8 +171,23 @@ export async function testEnvironment( stdout: probe.stdout, stderr: probe.stderr, }); + const quotaMeta = detectGeminiQuotaExhausted({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); - if (probe.timedOut) { + if (quotaMeta.exhausted) { + checks.push({ + code: "gemini_hello_probe_quota_exhausted", + level: "warn", + message: probe.timedOut + ? "Gemini CLI is retrying after quota exhaustion." + : "Gemini CLI authentication is configured, but the current account or API key is over quota.", + ...(detail ? { detail } : {}), + hint: "The configured Gemini account or API key is over quota. Check ai.google.dev usage/billing, then retry the probe.", + }); + } else if (probe.timedOut) { checks.push({ code: "gemini_hello_probe_timed_out", level: "warn", diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts index d4170e31..0aa49554 100644 --- a/server/src/__tests__/gemini-local-adapter-environment.test.ts +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -27,6 +27,20 @@ console.log(JSON.stringify({ return commandPath; } +async function writeQuotaGeminiCommand(binDir: string): Promise { + const commandPath = path.join(binDir, "gemini"); + const script = `#!/usr/bin/env node +if (process.argv.includes("--help")) { + process.exit(0); +} +console.error("429 RESOURCE_EXHAUSTED: You exceeded your current quota and billing details."); +process.exit(1); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); + return commandPath; +} + describe("gemini_local environment diagnostics", () => { it("creates a missing working directory when cwd is absolute", async () => { const cwd = path.join( @@ -86,6 +100,35 @@ describe("gemini_local environment diagnostics", () => { expect(args).toContain("gemini-2.5-pro"); expect(args).toContain("--approval-mode"); expect(args).toContain("yolo"); + expect(args).toContain("--prompt"); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("classifies quota exhaustion as a quota warning instead of a generic failure", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-gemini-local-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + await fs.mkdir(binDir, { recursive: true }); + await writeQuotaGeminiCommand(binDir); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "gemini_local", + config: { + command: "gemini", + cwd, + env: { + GEMINI_API_KEY: "test-key", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true); await fs.rm(root, { recursive: true, force: true }); }); }); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 92badecf..06fdaf03 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -45,7 +45,7 @@ type CapturePayload = { }; describe("gemini execute", () => { - it("passes prompt as final argument and injects paperclip env vars", async () => { + it("passes prompt via --prompt and injects paperclip env vars", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-")); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "gemini"); @@ -96,10 +96,13 @@ describe("gemini execute", () => { const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; expect(capture.argv).toContain("--output-format"); expect(capture.argv).toContain("stream-json"); + expect(capture.argv).toContain("--prompt"); expect(capture.argv).toContain("--approval-mode"); expect(capture.argv).toContain("yolo"); - expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat."); - expect(capture.argv.at(-1)).toContain("Paperclip runtime note:"); + const promptFlagIndex = capture.argv.indexOf("--prompt"); + const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : ""; + expect(promptArg).toContain("Follow the paperclip heartbeat."); + expect(promptArg).toContain("Paperclip runtime note:"); expect(capture.paperclipEnvKeys).toEqual( expect.arrayContaining([ "PAPERCLIP_AGENT_ID",