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 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 #[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}