chasm_cli/providers/cloud/
gemini.rs1use 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
24pub 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, #[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
136pub fn parse_gemini_export(json_data: &str) -> Result<Vec<CloudConversation>> {
138 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}