chasm_cli/providers/cloud/
gemini.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Google Gemini cloud provider
4//!
5//! Fetches conversation history from Google Gemini (formerly Bard).
6//!
7//! ## Authentication
8//!
9//! Requires either:
10//! - API key via `GOOGLE_API_KEY` or `GEMINI_API_KEY` environment variable
11//! - Session token for web interface access
12
13use super::common::{
14    build_http_client, CloudConversation, CloudMessage, CloudProvider, FetchOptions,
15    HttpClientConfig,
16};
17use anyhow::{anyhow, Result};
18use chrono::{DateTime, Utc};
19use serde::Deserialize;
20
21const GEMINI_API: &str = "https://generativelanguage.googleapis.com/v1";
22const GEMINI_WEB_API: &str = "https://gemini.google.com/_/BardChatUi";
23
24/// Google Gemini provider for fetching conversation history
25pub struct GeminiProvider {
26    api_key: Option<String>,
27    session_token: Option<String>,
28    client: Option<reqwest::blocking::Client>,
29}
30
31impl GeminiProvider {
32    pub fn new(api_key: Option<String>) -> Self {
33        Self {
34            api_key,
35            session_token: None,
36            client: None,
37        }
38    }
39
40    fn ensure_client(&mut self) -> Result<&reqwest::blocking::Client> {
41        if self.client.is_none() {
42            let config = HttpClientConfig::default();
43            self.client = Some(build_http_client(&config)?);
44        }
45        Ok(self.client.as_ref().unwrap())
46    }
47}
48
49#[derive(Debug, Deserialize)]
50struct GeminiConversation {
51    #[serde(rename = "conversationId")]
52    id: String,
53    #[serde(default)]
54    title: Option<String>,
55    #[serde(rename = "createTime")]
56    created_at: String,
57    #[serde(rename = "updateTime")]
58    updated_at: Option<String>,
59    #[serde(default)]
60    messages: Vec<GeminiMessage>,
61}
62
63#[derive(Debug, Deserialize)]
64struct GeminiMessage {
65    #[serde(default)]
66    id: Option<String>,
67    content: GeminiContent,
68    role: String, // "user" or "model"
69    #[serde(rename = "createTime")]
70    created_at: Option<String>,
71}
72
73#[derive(Debug, Deserialize)]
74struct GeminiContent {
75    parts: Vec<GeminiPart>,
76}
77
78#[derive(Debug, Deserialize)]
79struct GeminiPart {
80    #[serde(default)]
81    text: Option<String>,
82}
83
84impl CloudProvider for GeminiProvider {
85    fn name(&self) -> &'static str {
86        "Gemini"
87    }
88
89    fn api_base_url(&self) -> &str {
90        GEMINI_API
91    }
92
93    fn is_authenticated(&self) -> bool {
94        self.api_key.is_some() || self.session_token.is_some()
95    }
96
97    fn set_credentials(&mut self, api_key: Option<String>, session_token: Option<String>) {
98        self.api_key = api_key;
99        self.session_token = session_token;
100    }
101
102    fn list_conversations(&self, _options: &FetchOptions) -> Result<Vec<CloudConversation>> {
103        if !self.is_authenticated() {
104            return Err(anyhow!(
105                "Gemini requires authentication. Set GOOGLE_API_KEY or GEMINI_API_KEY, or provide a session token."
106            ));
107        }
108
109        eprintln!("Note: Gemini conversation history requires web session authentication.");
110        eprintln!("The Gemini API is stateless and doesn't store conversation history.");
111
112        Ok(vec![])
113    }
114
115    fn fetch_conversation(&self, _id: &str) -> Result<CloudConversation> {
116        if !self.is_authenticated() {
117            return Err(anyhow!("Gemini requires authentication"));
118        }
119
120        Err(anyhow!(
121            "Fetching Gemini conversations requires web session authentication."
122        ))
123    }
124
125    fn api_key_env_var(&self) -> &'static str {
126        "GOOGLE_API_KEY"
127    }
128
129    fn load_api_key_from_env(&self) -> Option<String> {
130        std::env::var("GOOGLE_API_KEY")
131            .or_else(|_| std::env::var("GEMINI_API_KEY"))
132            .ok()
133    }
134}
135
136/// Parse Gemini/Bard export data (Google Takeout format)
137pub fn parse_gemini_export(json_data: &str) -> Result<Vec<CloudConversation>> {
138    // Google Takeout exports Bard/Gemini data in a specific format
139    let conversations: Vec<GeminiExportConversation> = serde_json::from_str(json_data)?;
140
141    Ok(conversations
142        .into_iter()
143        .map(|conv| CloudConversation {
144            id: conv.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
145            title: conv.title,
146            created_at: conv
147                .created_at
148                .and_then(|s| parse_iso_timestamp(&s).ok())
149                .unwrap_or_else(Utc::now),
150            updated_at: conv.updated_at.and_then(|s| parse_iso_timestamp(&s).ok()),
151            model: Some("gemini".to_string()),
152            messages: conv
153                .messages
154                .into_iter()
155                .map(|msg| {
156                    let content = msg
157                        .content
158                        .parts
159                        .iter()
160                        .filter_map(|p| p.text.clone())
161                        .collect::<Vec<_>>()
162                        .join("\n");
163
164                    CloudMessage {
165                        id: msg.id,
166                        role: if msg.role == "model" {
167                            "assistant".to_string()
168                        } else {
169                            msg.role
170                        },
171                        content,
172                        timestamp: msg.created_at.and_then(|s| parse_iso_timestamp(&s).ok()),
173                        model: Some("gemini".to_string()),
174                    }
175                })
176                .collect(),
177            metadata: None,
178        })
179        .collect())
180}
181
182#[derive(Debug, Deserialize)]
183struct GeminiExportConversation {
184    #[serde(default)]
185    id: Option<String>,
186    #[serde(default)]
187    title: Option<String>,
188    #[serde(rename = "createTime", default)]
189    created_at: Option<String>,
190    #[serde(rename = "updateTime", default)]
191    updated_at: Option<String>,
192    #[serde(default)]
193    messages: Vec<GeminiExportMessage>,
194}
195
196#[derive(Debug, Deserialize)]
197struct GeminiExportMessage {
198    #[serde(default)]
199    id: Option<String>,
200    content: GeminiExportContent,
201    role: String,
202    #[serde(rename = "createTime", default)]
203    created_at: Option<String>,
204}
205
206#[derive(Debug, Deserialize)]
207struct GeminiExportContent {
208    #[serde(default)]
209    parts: Vec<GeminiExportPart>,
210}
211
212#[derive(Debug, Deserialize)]
213struct GeminiExportPart {
214    #[serde(default)]
215    text: Option<String>,
216}
217
218fn parse_iso_timestamp(s: &str) -> Result<DateTime<Utc>> {
219    DateTime::parse_from_rfc3339(s)
220        .map(|dt| dt.with_timezone(&Utc))
221        .map_err(|e| anyhow!("Failed to parse timestamp: {}", e))
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_gemini_provider_new() {
230        let provider = GeminiProvider::new(Some("test-key".to_string()));
231        assert_eq!(provider.name(), "Gemini");
232        assert!(provider.is_authenticated());
233    }
234
235    #[test]
236    fn test_gemini_provider_unauthenticated() {
237        let provider = GeminiProvider::new(None);
238        assert!(!provider.is_authenticated());
239    }
240
241    #[test]
242    fn test_api_key_env_var() {
243        let provider = GeminiProvider::new(None);
244        assert_eq!(provider.api_key_env_var(), "GOOGLE_API_KEY");
245    }
246}