Skip to main content

zeroclaw_providers/
gemini_cli.rs

1//! Gemini CLI subprocess model_provider.
2//!
3//! Integrates with the Gemini CLI, spawning the `gemini` binary
4//! as a subprocess for each inference request. This allows using Google's
5//! Gemini models via the CLI without an interactive UI session.
6//!
7//! # Usage
8//!
9//! The `gemini` binary must be available in `PATH`, or its location can be
10//! set via the typed alias's `binary_path` field.
11//!
12//! Gemini CLI is invoked as:
13//! ```text
14//! gemini --prompt ""
15//! ```
16//! with prompt content written to stdin. The empty `--prompt ""` is the
17//! headless-mode trigger; the CLI appends stdin to it, so the actual prompt
18//! is never visible in `ps` / `/proc/<pid>/cmdline`. Older CLI builds used
19//! `--print -` for this; that flag was removed in Gemini CLI v0.40.x.
20//!
21//! # Limitations
22//!
23//! - **Conversation history**: Only the system prompt (if present) and the last
24//!   user message are forwarded. Full multi-turn history is not preserved because
25//!   the CLI accepts a single prompt per invocation.
26//! - **System prompt**: The system prompt is prepended to the user message with a
27//!   blank-line separator, as the CLI does not provide a dedicated system-prompt flag.
28//! - **Temperature**: The CLI does not expose a temperature parameter.
29//!   Only default values are accepted; custom values return an explicit error.
30//!
31//! # Authentication
32//!
33//! Authentication is handled by the Gemini CLI itself (its own credential store).
34//! No explicit API key is required by this model_provider.
35//!
36use crate::traits::{ChatRequest, ChatResponse, ModelProvider, TokenUsage};
37use async_trait::async_trait;
38use std::path::PathBuf;
39use tokio::io::AsyncWriteExt;
40use tokio::process::Command;
41use tokio::time::{Duration, timeout};
42
43/// Default `gemini` binary name (resolved via `PATH`).
44const DEFAULT_GEMINI_CLI_BINARY: &str = "gemini";
45
46/// Model name used to signal "use the model_provider's own default model".
47const DEFAULT_MODEL_MARKER: &str = "default";
48/// Gemini CLI requests are bounded to avoid hung subprocesses.
49const GEMINI_CLI_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
50/// Avoid leaking oversized stderr payloads.
51const MAX_GEMINI_CLI_STDERR_CHARS: usize = 512;
52/// The CLI does not support sampling controls; allow only baseline defaults.
53const GEMINI_CLI_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
54const TEMP_EPSILON: f64 = 1e-9;
55
56/// ModelProvider that invokes the Gemini CLI as a subprocess.
57///
58/// Each inference request spawns a fresh `gemini` process. This is the
59/// non-interactive approach: the process handles the prompt and exits.
60pub struct GeminiCliModelProvider {
61    /// `[model_providers.<family>.<alias>]` config-key alias.
62    alias: String,
63    /// Path to the `gemini` binary.
64    binary_path: PathBuf,
65}
66
67impl GeminiCliModelProvider {
68    /// Create a new `GeminiCliModelProvider`. Pass `None` to use the default
69    /// `"gemini"` (PATH lookup); pass an explicit path to override.
70    pub fn new(alias: &str, binary_path: Option<&str>) -> Self {
71        let binary_path = binary_path
72            .map(str::trim)
73            .filter(|p| !p.is_empty())
74            .map(PathBuf::from)
75            .unwrap_or_else(|| PathBuf::from(DEFAULT_GEMINI_CLI_BINARY));
76        Self {
77            alias: alias.to_string(),
78            binary_path,
79        }
80    }
81    /// Returns true if the model argument should be forwarded to the CLI.
82    fn should_forward_model(model: &str) -> bool {
83        let trimmed = model.trim();
84        !trimmed.is_empty() && trimmed != DEFAULT_MODEL_MARKER
85    }
86
87    fn supports_temperature(temperature: f64) -> bool {
88        GEMINI_CLI_SUPPORTED_TEMPERATURES
89            .iter()
90            .any(|v| (temperature - v).abs() < TEMP_EPSILON)
91    }
92
93    fn validate_temperature(temperature: f64) -> anyhow::Result<()> {
94        if !temperature.is_finite() {
95            anyhow::bail!("Gemini CLI model_provider received non-finite temperature value");
96        }
97        if !Self::supports_temperature(temperature) {
98            anyhow::bail!(
99                "temperature unsupported by Gemini CLI: {temperature}. \
100                 Supported values: 0.7 or 1.0"
101            );
102        }
103        Ok(())
104    }
105
106    fn redact_stderr(stderr: &[u8]) -> String {
107        let text = String::from_utf8_lossy(stderr);
108        let trimmed = text.trim();
109        if trimmed.is_empty() {
110            return String::new();
111        }
112        if trimmed.chars().count() <= MAX_GEMINI_CLI_STDERR_CHARS {
113            return trimmed.to_string();
114        }
115        let clipped: String = trimmed.chars().take(MAX_GEMINI_CLI_STDERR_CHARS).collect();
116        format!("{clipped}...")
117    }
118
119    /// Build the argv tail (excluding the binary itself) for a CLI invocation.
120    ///
121    /// `--prompt ""` is the headless-mode trigger; the CLI appends stdin to
122    /// it, so the actual prompt stays out of `ps` / `/proc/<pid>/cmdline`.
123    /// The pre-v0.40 syntax `--print -` was removed upstream in #6520.
124    fn build_cli_args(model: &str) -> Vec<String> {
125        let mut args = vec!["--prompt".to_string(), String::new()];
126        if Self::should_forward_model(model) {
127            args.push("--model".to_string());
128            args.push(model.to_string());
129        }
130        args
131    }
132
133    /// Invoke the gemini binary with the given prompt and optional model.
134    /// Returns the trimmed stdout output as the assistant response.
135    async fn invoke_cli(&self, message: &str, model: &str) -> anyhow::Result<String> {
136        let mut cmd = Command::new(&self.binary_path);
137        cmd.args(Self::build_cli_args(model));
138
139        cmd.kill_on_drop(true);
140        cmd.stdin(std::process::Stdio::piped());
141        cmd.stdout(std::process::Stdio::piped());
142        cmd.stderr(std::process::Stdio::piped());
143
144        let mut child = cmd.spawn().map_err(|err| {
145            ::zeroclaw_log::record!(
146                ERROR,
147                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
148                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
149                    .with_attrs(::serde_json::json!({
150                        "binary": self.binary_path.display().to_string(),
151                        "phase": "spawn",
152                        "error": format!("{}", err),
153                    })),
154                "gemini_cli: failed to spawn binary"
155            );
156            anyhow::Error::msg(format!(
157                "Failed to spawn Gemini CLI binary at {}: {err}. \
158                 Ensure `gemini` is installed and in PATH, or set GEMINI_CLI_PATH.",
159                self.binary_path.display()
160            ))
161        })?;
162
163        if let Some(mut stdin) = child.stdin.take() {
164            stdin.write_all(message.as_bytes()).await.map_err(|err| {
165                ::zeroclaw_log::record!(
166                    ERROR,
167                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
168                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
169                        .with_attrs(::serde_json::json!({
170                            "phase": "stdin_write",
171                            "error": format!("{}", err),
172                        })),
173                    "gemini_cli: failed to write prompt to stdin"
174                );
175                anyhow::Error::msg(format!("Failed to write prompt to Gemini CLI stdin: {err}"))
176            })?;
177            stdin.shutdown().await.map_err(|err| {
178                ::zeroclaw_log::record!(
179                    ERROR,
180                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
181                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
182                        .with_attrs(::serde_json::json!({
183                            "phase": "stdin_shutdown",
184                            "error": format!("{}", err),
185                        })),
186                    "gemini_cli: failed to finalize stdin stream"
187                );
188                anyhow::Error::msg(format!("Failed to finalize Gemini CLI stdin stream: {err}"))
189            })?;
190        }
191
192        let output = timeout(GEMINI_CLI_REQUEST_TIMEOUT, child.wait_with_output())
193            .await
194            .map_err(|_| {
195                ::zeroclaw_log::record!(
196                    WARN,
197                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
198                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
199                        .with_attrs(::serde_json::json!({
200                            "binary": self.binary_path.display().to_string(),
201                            "timeout": format!("{:?}", GEMINI_CLI_REQUEST_TIMEOUT),
202                        })),
203                    "gemini_cli: request timed out"
204                );
205                anyhow::Error::msg(format!(
206                    "Gemini CLI request timed out after {:?} (binary: {})",
207                    GEMINI_CLI_REQUEST_TIMEOUT,
208                    self.binary_path.display()
209                ))
210            })?
211            .map_err(|err| {
212                ::zeroclaw_log::record!(
213                    ERROR,
214                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
215                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
216                        .with_attrs(::serde_json::json!({
217                            "phase": "process_wait",
218                            "error": format!("{}", err),
219                        })),
220                    "gemini_cli: process wait failed"
221                );
222                anyhow::Error::msg(format!("Gemini CLI process failed: {err}"))
223            })?;
224
225        if !output.status.success() {
226            let code = output.status.code().unwrap_or(-1);
227            let stderr_excerpt = Self::redact_stderr(&output.stderr);
228            let stderr_note = if stderr_excerpt.is_empty() {
229                String::new()
230            } else {
231                format!(" Stderr: {stderr_excerpt}")
232            };
233            anyhow::bail!(
234                "Gemini CLI exited with non-zero status {code}. \
235                 Check that Gemini CLI is authenticated and the CLI is supported.{stderr_note}"
236            );
237        }
238
239        let text = String::from_utf8(output.stdout).map_err(|err| {
240            ::zeroclaw_log::record!(
241                ERROR,
242                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
243                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
244                    .with_attrs(::serde_json::json!({
245                        "phase": "utf8_decode",
246                        "error": format!("{}", err),
247                    })),
248                "gemini_cli: non-UTF-8 stdout"
249            );
250            anyhow::Error::msg(format!("Gemini CLI produced non-UTF-8 output: {err}"))
251        })?;
252
253        Ok(text.trim().to_string())
254    }
255}
256
257#[async_trait]
258impl ModelProvider for GeminiCliModelProvider {
259    async fn chat_with_system(
260        &self,
261        system_prompt: Option<&str>,
262        message: &str,
263        model: &str,
264        temperature: Option<f64>,
265    ) -> anyhow::Result<String> {
266        let temperature = temperature.unwrap_or(self.default_temperature());
267        Self::validate_temperature(temperature)?;
268
269        let full_message = match system_prompt {
270            Some(system) if !system.is_empty() => {
271                format!("{system}\n\n{message}")
272            }
273            _ => message.to_string(),
274        };
275
276        self.invoke_cli(&full_message, model).await
277    }
278
279    async fn chat(
280        &self,
281        request: ChatRequest<'_>,
282        model: &str,
283        temperature: Option<f64>,
284    ) -> anyhow::Result<ChatResponse> {
285        let text = self
286            .chat_with_history(request.messages, model, temperature)
287            .await?;
288
289        Ok(ChatResponse {
290            text: Some(text),
291            tool_calls: Vec::new(),
292            usage: Some(TokenUsage::default()),
293            reasoning_content: None,
294        })
295    }
296}
297
298impl ::zeroclaw_api::attribution::Attributable for GeminiCliModelProvider {
299    fn role(&self) -> ::zeroclaw_api::attribution::Role {
300        ::zeroclaw_api::attribution::Role::Provider(
301            ::zeroclaw_api::attribution::ProviderKind::Model(
302                ::zeroclaw_api::attribution::ModelProviderKind::GeminiCli,
303            ),
304        )
305    }
306    fn alias(&self) -> &str {
307        &self.alias
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn new_uses_explicit_binary_path() {
317        let p = GeminiCliModelProvider::new("test", Some("/usr/local/bin/gemini"));
318        assert_eq!(p.binary_path, PathBuf::from("/usr/local/bin/gemini"));
319    }
320
321    #[test]
322    fn new_defaults_to_gemini() {
323        let p = GeminiCliModelProvider::new("test", None);
324        assert_eq!(p.binary_path, PathBuf::from("gemini"));
325    }
326
327    #[test]
328    fn new_ignores_blank_binary_path() {
329        let p = GeminiCliModelProvider::new("test", Some("   "));
330        assert_eq!(p.binary_path, PathBuf::from("gemini"));
331    }
332
333    #[test]
334    fn should_forward_model_standard() {
335        assert!(GeminiCliModelProvider::should_forward_model(
336            "gemini-2.5-pro"
337        ));
338        assert!(GeminiCliModelProvider::should_forward_model(
339            "gemini-2.5-flash"
340        ));
341    }
342
343    #[test]
344    fn should_not_forward_default_model() {
345        assert!(!GeminiCliModelProvider::should_forward_model(
346            DEFAULT_MODEL_MARKER
347        ));
348        assert!(!GeminiCliModelProvider::should_forward_model(""));
349        assert!(!GeminiCliModelProvider::should_forward_model("   "));
350    }
351
352    #[test]
353    fn validate_temperature_allows_defaults() {
354        assert!(GeminiCliModelProvider::validate_temperature(0.7).is_ok());
355        assert!(GeminiCliModelProvider::validate_temperature(1.0).is_ok());
356    }
357
358    #[test]
359    fn validate_temperature_rejects_custom_value() {
360        let err = GeminiCliModelProvider::validate_temperature(0.2).unwrap_err();
361        assert!(
362            err.to_string()
363                .contains("temperature unsupported by Gemini CLI")
364        );
365    }
366
367    // Regression for #6520. Gemini CLI v0.40.x removed `--print`; the
368    // headless-mode flag is now `--prompt`. The argv must (a) carry
369    // `--prompt ""` so stdin still feeds the prompt content (keeping the
370    // user message out of `ps`) and (b) NEVER carry `--print` or `-`.
371    #[test]
372    fn build_cli_args_uses_prompt_flag_with_empty_token_default_model() {
373        let args = GeminiCliModelProvider::build_cli_args(DEFAULT_MODEL_MARKER);
374        assert_eq!(args, vec!["--prompt".to_string(), String::new()]);
375        assert!(!args.iter().any(|a| a == "--print"));
376        assert!(!args.iter().any(|a| a == "-"));
377    }
378
379    #[test]
380    fn build_cli_args_forwards_explicit_model_after_prompt() {
381        let args = GeminiCliModelProvider::build_cli_args("gemini-2.5-pro");
382        assert_eq!(
383            args,
384            vec![
385                "--prompt".to_string(),
386                String::new(),
387                "--model".to_string(),
388                "gemini-2.5-pro".to_string(),
389            ]
390        );
391        assert!(!args.iter().any(|a| a == "--print"));
392    }
393
394    #[tokio::test]
395    async fn invoke_missing_binary_returns_error() {
396        let model_provider = GeminiCliModelProvider {
397            alias: "test".to_string(),
398            binary_path: PathBuf::from("/nonexistent/path/to/gemini"),
399        };
400        let result = model_provider.invoke_cli("hello", "default").await;
401        assert!(result.is_err());
402        let msg = result.unwrap_err().to_string();
403        assert!(
404            msg.contains("Failed to spawn Gemini CLI binary"),
405            "unexpected error message: {msg}"
406        );
407    }
408}