1use 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
43const DEFAULT_GEMINI_CLI_BINARY: &str = "gemini";
45
46const DEFAULT_MODEL_MARKER: &str = "default";
48const GEMINI_CLI_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
50const MAX_GEMINI_CLI_STDERR_CHARS: usize = 512;
52const GEMINI_CLI_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
54const TEMP_EPSILON: f64 = 1e-9;
55
56pub struct GeminiCliModelProvider {
61 alias: String,
63 binary_path: PathBuf,
65}
66
67impl GeminiCliModelProvider {
68 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 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 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 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 if let Some(t) = temperature {
267 Self::validate_temperature(t)?;
268 }
269
270 let full_message = match system_prompt {
271 Some(system) if !system.is_empty() => {
272 format!("{system}\n\n{message}")
273 }
274 _ => message.to_string(),
275 };
276
277 self.invoke_cli(&full_message, model).await
278 }
279
280 async fn chat(
281 &self,
282 request: ChatRequest<'_>,
283 model: &str,
284 temperature: Option<f64>,
285 ) -> anyhow::Result<ChatResponse> {
286 let text = self
287 .chat_with_history(request.messages, model, temperature)
288 .await?;
289
290 Ok(ChatResponse {
291 text: Some(text),
292 tool_calls: Vec::new(),
293 usage: Some(TokenUsage::default()),
294 reasoning_content: None,
295 })
296 }
297}
298
299impl ::zeroclaw_api::attribution::Attributable for GeminiCliModelProvider {
300 fn role(&self) -> ::zeroclaw_api::attribution::Role {
301 ::zeroclaw_api::attribution::Role::Provider(
302 ::zeroclaw_api::attribution::ProviderKind::Model(
303 ::zeroclaw_api::attribution::ModelProviderKind::GeminiCli,
304 ),
305 )
306 }
307 fn alias(&self) -> &str {
308 &self.alias
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn new_uses_explicit_binary_path() {
318 let p = GeminiCliModelProvider::new("test", Some("/usr/local/bin/gemini"));
319 assert_eq!(p.binary_path, PathBuf::from("/usr/local/bin/gemini"));
320 }
321
322 #[test]
323 fn new_defaults_to_gemini() {
324 let p = GeminiCliModelProvider::new("test", None);
325 assert_eq!(p.binary_path, PathBuf::from("gemini"));
326 }
327
328 #[test]
329 fn new_ignores_blank_binary_path() {
330 let p = GeminiCliModelProvider::new("test", Some(" "));
331 assert_eq!(p.binary_path, PathBuf::from("gemini"));
332 }
333
334 #[test]
335 fn should_forward_model_standard() {
336 assert!(GeminiCliModelProvider::should_forward_model(
337 "gemini-2.5-pro"
338 ));
339 assert!(GeminiCliModelProvider::should_forward_model(
340 "gemini-2.5-flash"
341 ));
342 }
343
344 #[test]
345 fn should_not_forward_default_model() {
346 assert!(!GeminiCliModelProvider::should_forward_model(
347 DEFAULT_MODEL_MARKER
348 ));
349 assert!(!GeminiCliModelProvider::should_forward_model(""));
350 assert!(!GeminiCliModelProvider::should_forward_model(" "));
351 }
352
353 #[test]
354 fn validate_temperature_allows_defaults() {
355 assert!(GeminiCliModelProvider::validate_temperature(0.7).is_ok());
356 assert!(GeminiCliModelProvider::validate_temperature(1.0).is_ok());
357 }
358
359 #[test]
360 fn validate_temperature_rejects_custom_value() {
361 let err = GeminiCliModelProvider::validate_temperature(0.2).unwrap_err();
362 assert!(
363 err.to_string()
364 .contains("temperature unsupported by Gemini CLI")
365 );
366 }
367
368 #[test]
373 fn build_cli_args_uses_prompt_flag_with_empty_token_default_model() {
374 let args = GeminiCliModelProvider::build_cli_args(DEFAULT_MODEL_MARKER);
375 assert_eq!(args, vec!["--prompt".to_string(), String::new()]);
376 assert!(!args.iter().any(|a| a == "--print"));
377 assert!(!args.iter().any(|a| a == "-"));
378 }
379
380 #[test]
381 fn build_cli_args_forwards_explicit_model_after_prompt() {
382 let args = GeminiCliModelProvider::build_cli_args("gemini-2.5-pro");
383 assert_eq!(
384 args,
385 vec![
386 "--prompt".to_string(),
387 String::new(),
388 "--model".to_string(),
389 "gemini-2.5-pro".to_string(),
390 ]
391 );
392 assert!(!args.iter().any(|a| a == "--print"));
393 }
394
395 #[tokio::test]
396 async fn invoke_missing_binary_returns_error() {
397 let model_provider = GeminiCliModelProvider {
398 alias: "test".to_string(),
399 binary_path: PathBuf::from("/nonexistent/path/to/gemini"),
400 };
401 let result = model_provider.invoke_cli("hello", "default").await;
402 assert!(result.is_err());
403 let msg = result.unwrap_err().to_string();
404 assert!(
405 msg.contains("Failed to spawn Gemini CLI binary"),
406 "unexpected error message: {msg}"
407 );
408 }
409}