Skip to main content

jamjet_models/
google.rs

1//! Google Gemini adapter (Generative Language API).
2//!
3//! Supports gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, etc.
4//! Reads `GOOGLE_API_KEY` or `GEMINI_API_KEY` from the environment.
5//! Uses the REST API directly (no SDK dependency).
6
7use crate::adapter::{
8    warn_tools_not_forwarded, ChatMessage, ChatRole, ModelAdapter, ModelConfig, ModelError,
9    ModelRequest, ModelResponse, StructuredRequest,
10};
11use async_trait::async_trait;
12use serde_json::{json, Value};
13use tracing::{debug, instrument};
14
15const GEMINI_API_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
16const DEFAULT_MODEL: &str = "gemini-2.0-flash";
17const DEFAULT_MAX_TOKENS: u32 = 4096;
18
19/// Google Gemini adapter via the Generative Language REST API.
20///
21/// Uses API key authentication (not OAuth). Supports all Gemini models
22/// available through Google AI Studio.
23pub struct GoogleAdapter {
24    client: reqwest::Client,
25    api_key: String,
26    default_model: String,
27}
28
29impl GoogleAdapter {
30    pub fn new(api_key: impl Into<String>) -> Self {
31        Self {
32            client: reqwest::Client::new(),
33            api_key: api_key.into(),
34            default_model: DEFAULT_MODEL.into(),
35        }
36    }
37
38    /// Create adapter from `GOOGLE_API_KEY` or `GEMINI_API_KEY` env var.
39    pub fn from_env() -> Result<Self, ModelError> {
40        let key = std::env::var("GOOGLE_API_KEY")
41            .or_else(|_| std::env::var("GEMINI_API_KEY"))
42            .map_err(|_| ModelError::Network("GOOGLE_API_KEY or GEMINI_API_KEY not set".into()))?;
43        Ok(Self::new(key))
44    }
45
46    pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
47        self.default_model = model.into();
48        self
49    }
50
51    async fn call_api(&self, model: &str, body: Value) -> Result<Value, ModelError> {
52        // Gemini API URL: /v1beta/models/{model}:generateContent?key={key}
53        let url = format!(
54            "{}/models/{}:generateContent?key={}",
55            GEMINI_API_BASE, model, self.api_key
56        );
57
58        let resp = self
59            .client
60            .post(&url)
61            .json(&body)
62            .send()
63            .await
64            .map_err(|e| ModelError::Network(e.to_string()))?;
65
66        let status = resp.status().as_u16();
67        let body_text = resp
68            .text()
69            .await
70            .map_err(|e| ModelError::Network(e.to_string()))?;
71
72        if status == 429 {
73            return Err(ModelError::RateLimited {
74                retry_after_secs: 60,
75            });
76        }
77        if status != 200 {
78            return Err(ModelError::Api {
79                status,
80                body: body_text,
81            });
82        }
83
84        serde_json::from_str(&body_text).map_err(|e| ModelError::Serialization(e.to_string()))
85    }
86
87    fn build_request_body(
88        &self,
89        messages: &[ChatMessage],
90        config: &ModelConfig,
91        response_mime_type: Option<&str>,
92    ) -> Value {
93        let max_tokens = config.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
94
95        // Gemini uses "contents" with roles "user" and "model" (not "assistant").
96        let contents: Vec<Value> = messages
97            .iter()
98            .filter(|m| !matches!(m.role, ChatRole::System))
99            .map(|m| {
100                let role = match m.role {
101                    ChatRole::User | ChatRole::Tool => "user",
102                    ChatRole::Assistant => "model",
103                    ChatRole::System => unreachable!(),
104                };
105                json!({
106                    "role": role,
107                    "parts": [{"text": m.content}]
108                })
109            })
110            .collect();
111
112        let mut generation_config = json!({
113            "maxOutputTokens": max_tokens,
114        });
115
116        if let Some(temp) = config.temperature {
117            generation_config["temperature"] = json!(temp);
118        }
119        if let Some(stops) = &config.stop_sequences {
120            generation_config["stopSequences"] = json!(stops);
121        }
122        if let Some(mime) = response_mime_type {
123            generation_config["responseMimeType"] = json!(mime);
124        }
125
126        let mut body = json!({
127            "contents": contents,
128            "generationConfig": generation_config,
129        });
130
131        // System instruction (separate from contents in Gemini API).
132        let system_text = config.system_prompt.as_deref().or_else(|| {
133            messages
134                .iter()
135                .find(|m| matches!(m.role, ChatRole::System))
136                .map(|m| m.content.as_str())
137        });
138
139        if let Some(sys) = system_text {
140            body["systemInstruction"] = json!({
141                "parts": [{"text": sys}]
142            });
143        }
144
145        body
146    }
147
148    fn parse_response(&self, resp: Value) -> Result<ModelResponse, ModelError> {
149        // Extract text from candidates[0].content.parts[0].text
150        let candidate = resp["candidates"]
151            .as_array()
152            .and_then(|cs| cs.first())
153            .ok_or_else(|| ModelError::Api {
154                status: 200,
155                body: "no candidates in response".into(),
156            })?;
157
158        let content = candidate["content"]["parts"]
159            .as_array()
160            .and_then(|parts| parts.first())
161            .and_then(|p| p["text"].as_str())
162            .unwrap_or("")
163            .to_string();
164
165        let finish_reason = candidate["finishReason"]
166            .as_str()
167            .unwrap_or("STOP")
168            .to_string();
169
170        // Token counts from usageMetadata.
171        let usage = &resp["usageMetadata"];
172        let input_tokens = usage["promptTokenCount"].as_u64().unwrap_or(0);
173        let output_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0);
174
175        // Model name from modelVersion if available.
176        let model = resp["modelVersion"]
177            .as_str()
178            .unwrap_or(&self.default_model)
179            .to_string();
180
181        Ok(ModelResponse {
182            content,
183            model,
184            finish_reason,
185            input_tokens,
186            output_tokens,
187            structured: None,
188            tool_calls: vec![],
189        })
190    }
191}
192
193#[async_trait]
194impl ModelAdapter for GoogleAdapter {
195    fn system_name(&self) -> &'static str {
196        "google"
197    }
198
199    fn default_model(&self) -> &str {
200        &self.default_model
201    }
202
203    #[instrument(skip(self, request), fields(
204        gen_ai.system = "google",
205        gen_ai.request.model = tracing::field::Empty,
206        gen_ai.usage.input_tokens = tracing::field::Empty,
207        gen_ai.usage.output_tokens = tracing::field::Empty,
208    ))]
209    async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
210        // Native adapter: tools are not forwarded to the provider. Warn (once) so
211        // a tool-carrying call does not silently degenerate the agent loop.
212        if !request.tools.is_empty() {
213            warn_tools_not_forwarded(self.system_name());
214        }
215
216        let model = request
217            .config
218            .model
219            .as_deref()
220            .unwrap_or(&self.default_model)
221            .to_string();
222        tracing::Span::current().record("gen_ai.request.model", model.as_str());
223
224        debug!(model = %model, "Calling Gemini generateContent API");
225
226        let body = self.build_request_body(&request.messages, &request.config, None);
227        let resp_json = self.call_api(&model, body).await?;
228        let response = self.parse_response(resp_json)?;
229
230        tracing::Span::current()
231            .record("gen_ai.usage.input_tokens", response.input_tokens)
232            .record("gen_ai.usage.output_tokens", response.output_tokens);
233
234        Ok(response)
235    }
236
237    #[instrument(skip(self, request), fields(
238        gen_ai.system = "google",
239        gen_ai.request.model = tracing::field::Empty,
240    ))]
241    async fn structured_output(
242        &self,
243        request: StructuredRequest,
244    ) -> Result<ModelResponse, ModelError> {
245        let model = request
246            .config
247            .model
248            .as_deref()
249            .unwrap_or(&self.default_model)
250            .to_string();
251        tracing::Span::current().record("gen_ai.request.model", model.as_str());
252
253        // Gemini supports responseMimeType: "application/json" for JSON mode.
254        // Append schema to system prompt.
255        let mut config = request.config.clone();
256        let schema_str = serde_json::to_string_pretty(&request.output_schema)
257            .map_err(|e| ModelError::Serialization(e.to_string()))?;
258        let system = config.system_prompt.get_or_insert_with(String::new);
259        system.push_str(&format!(
260            "\n\nRespond ONLY with a valid JSON object matching this schema:\n{schema_str}"
261        ));
262
263        let body = self.build_request_body(&request.messages, &config, Some("application/json"));
264        let resp_json = self.call_api(&model, body).await?;
265        let mut response = self.parse_response(resp_json)?;
266
267        // Parse JSON from response content.
268        let structured =
269            serde_json::from_str::<serde_json::Value>(&response.content).map_err(|e| {
270                ModelError::Serialization(format!("structured output parse error: {e}"))
271            })?;
272        response.structured = Some(structured);
273
274        Ok(response)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_build_request_body_system_instruction() {
284        let adapter = GoogleAdapter::new("test-key");
285        let messages = vec![ChatMessage::user("Hello")];
286        let config = ModelConfig {
287            model: Some("gemini-2.0-flash".into()),
288            system_prompt: Some("You are helpful.".into()),
289            max_tokens: Some(100),
290            ..Default::default()
291        };
292        let body = adapter.build_request_body(&messages, &config, None);
293
294        assert!(body["systemInstruction"]["parts"][0]["text"]
295            .as_str()
296            .unwrap()
297            .contains("You are helpful"));
298        assert_eq!(body["contents"][0]["role"], "user");
299        assert_eq!(body["generationConfig"]["maxOutputTokens"], 100);
300    }
301
302    #[test]
303    fn test_parse_response() {
304        let adapter = GoogleAdapter::new("test-key");
305        let resp = json!({
306            "candidates": [{
307                "content": {
308                    "parts": [{"text": "Hello!"}],
309                    "role": "model"
310                },
311                "finishReason": "STOP"
312            }],
313            "usageMetadata": {
314                "promptTokenCount": 10,
315                "candidatesTokenCount": 3,
316                "totalTokenCount": 13
317            },
318            "modelVersion": "gemini-2.0-flash"
319        });
320
321        let parsed = adapter.parse_response(resp).unwrap();
322        assert_eq!(parsed.content, "Hello!");
323        assert_eq!(parsed.input_tokens, 10);
324        assert_eq!(parsed.output_tokens, 3);
325        assert_eq!(parsed.finish_reason, "STOP");
326    }
327}