opendev_http/adapters/
gemini.rs1use serde_json::{Value, json};
13
14const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
15
16#[derive(Debug, Clone)]
18pub struct GeminiAdapter {
19 base_url: String,
20 model: String,
21}
22
23impl GeminiAdapter {
24 pub fn new(model: impl Into<String>) -> Self {
26 Self {
27 base_url: DEFAULT_BASE_URL.to_string(),
28 model: model.into(),
29 }
30 }
31
32 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
34 self.base_url = url.into();
35 self
36 }
37
38 fn convert_messages(messages: &[Value]) -> (Option<String>, Vec<Value>) {
42 let mut system_text: Option<String> = None;
43 let mut contents: Vec<Value> = Vec::new();
44
45 for msg in messages {
46 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
47 match role {
48 "system" => {
49 let text = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
50 system_text = Some(text.to_string());
51 }
52 "user" => {
53 let parts = Self::content_to_parts(msg.get("content"));
54 contents.push(json!({
55 "role": "user",
56 "parts": parts,
57 }));
58 }
59 "assistant" => {
60 let mut parts: Vec<Value> = Vec::new();
61
62 if let Some(text) = msg.get("content").and_then(|c| c.as_str())
64 && !text.is_empty()
65 {
66 parts.push(json!({"text": text}));
67 }
68
69 if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
71 for tc in tool_calls {
72 let func = tc.get("function").cloned().unwrap_or(json!({}));
73 let args_str = func
74 .get("arguments")
75 .and_then(|a| a.as_str())
76 .unwrap_or("{}");
77 let args: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
78 parts.push(json!({
79 "functionCall": {
80 "name": func.get("name").and_then(|n| n.as_str()).unwrap_or(""),
81 "args": args,
82 }
83 }));
84 }
85 }
86
87 if !parts.is_empty() {
88 contents.push(json!({
89 "role": "model",
90 "parts": parts,
91 }));
92 }
93 }
94 "tool" => {
95 let name = msg
97 .get("name")
98 .and_then(|n| n.as_str())
99 .unwrap_or("function");
100 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
101 let response_val: Value = serde_json::from_str(content)
103 .unwrap_or_else(|_| json!({"result": content}));
104
105 contents.push(json!({
106 "role": "user",
107 "parts": [{
108 "functionResponse": {
109 "name": name,
110 "response": response_val,
111 }
112 }]
113 }));
114 }
115 _ => {}
116 }
117 }
118
119 (system_text, contents)
120 }
121
122 fn content_to_parts(content: Option<&Value>) -> Vec<Value> {
124 match content {
125 Some(Value::String(s)) => vec![json!({"text": s})],
126 Some(Value::Array(blocks)) => {
127 blocks
128 .iter()
129 .filter_map(|block| {
130 let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
131 match block_type {
132 "text" => {
133 let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
134 Some(json!({"text": text}))
135 }
136 "image_url" => {
137 if let Some(url) = block
139 .get("image_url")
140 .and_then(|iu| iu.get("url"))
141 .and_then(|u| u.as_str())
142 && let Some(rest) = url.strip_prefix("data:")
143 && let Some((mime, data)) = rest.split_once(";base64,")
144 {
145 return Some(json!({
146 "inlineData": {
147 "mimeType": mime,
148 "data": data,
149 }
150 }));
151 }
152 None
153 }
154 _ => Some(json!({"text": block.to_string()})),
155 }
156 })
157 .collect()
158 }
159 _ => vec![json!({"text": ""})],
160 }
161 }
162
163 fn convert_tools(tools: &[Value]) -> Vec<Value> {
165 tools
166 .iter()
167 .filter_map(|tool| {
168 let func = tool.get("function")?;
169 Some(json!({
170 "name": func.get("name").and_then(|n| n.as_str()).unwrap_or(""),
171 "description": func.get("description").and_then(|d| d.as_str()).unwrap_or(""),
172 "parameters": func.get("parameters").cloned().unwrap_or(json!({"type": "object", "properties": {}})),
173 }))
174 })
175 .collect()
176 }
177
178 fn response_to_chat_completions(&self, response: &Value) -> Value {
182 let candidates = response
183 .get("candidates")
184 .and_then(|c| c.as_array())
185 .cloned()
186 .unwrap_or_default();
187
188 let candidate = candidates.first().cloned().unwrap_or(json!({}));
189 let parts = candidate
190 .get("content")
191 .and_then(|c| c.get("parts"))
192 .and_then(|p| p.as_array())
193 .cloned()
194 .unwrap_or_default();
195
196 let text_parts: Vec<String> = parts
198 .iter()
199 .filter_map(|p| {
200 if p.get("thought").and_then(|t| t.as_bool()) == Some(true) {
202 return None;
203 }
204 p.get("text").and_then(|t| t.as_str()).map(String::from)
205 })
206 .collect();
207
208 let thinking_parts: Vec<String> = parts
210 .iter()
211 .filter_map(|p| {
212 if p.get("thought").and_then(|t| t.as_bool()) == Some(true) {
213 p.get("text").and_then(|t| t.as_str()).map(String::from)
214 } else {
215 None
216 }
217 })
218 .collect();
219 let reasoning_content = if thinking_parts.is_empty() {
220 None
221 } else {
222 Some(thinking_parts.join("\n\n"))
223 };
224
225 let tool_calls: Vec<Value> = parts
227 .iter()
228 .enumerate()
229 .filter_map(|(i, p)| {
230 let fc = p.get("functionCall")?;
231 let name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("");
232 let args = fc.get("args").cloned().unwrap_or(json!({}));
233 Some(json!({
234 "id": format!("call_{i}"),
235 "type": "function",
236 "function": {
237 "name": name,
238 "arguments": serde_json::to_string(&args).unwrap_or_default(),
239 }
240 }))
241 })
242 .collect();
243
244 let content = if text_parts.is_empty() {
245 Value::Null
246 } else {
247 Value::String(text_parts.join(""))
248 };
249
250 let finish_reason_raw = candidate
251 .get("finishReason")
252 .and_then(|r| r.as_str())
253 .unwrap_or("STOP");
254
255 let finish_reason = match finish_reason_raw {
256 "STOP" => {
257 if tool_calls.is_empty() {
258 "stop"
259 } else {
260 "tool_calls"
261 }
262 }
263 "MAX_TOKENS" => "length",
264 "SAFETY" => "content_filter",
265 _ => "stop",
266 };
267
268 let mut message = json!({
269 "role": "assistant",
270 "content": content,
271 });
272
273 if !tool_calls.is_empty() {
274 message["tool_calls"] = Value::Array(tool_calls);
275 }
276 if let Some(ref reasoning) = reasoning_content {
277 message["reasoning_content"] = Value::String(reasoning.clone());
278 }
279
280 let usage_meta = response.get("usageMetadata").cloned().unwrap_or(json!({}));
282 let prompt_tokens = usage_meta
283 .get("promptTokenCount")
284 .and_then(|t| t.as_u64())
285 .unwrap_or(0);
286 let completion_tokens = usage_meta
287 .get("candidatesTokenCount")
288 .and_then(|t| t.as_u64())
289 .unwrap_or(0);
290
291 json!({
292 "id": format!("gemini-{}", uuid::Uuid::new_v4()),
293 "object": "chat.completion",
294 "model": &self.model,
295 "choices": [{
296 "index": 0,
297 "message": message,
298 "finish_reason": finish_reason,
299 }],
300 "usage": {
301 "prompt_tokens": prompt_tokens,
302 "completion_tokens": completion_tokens,
303 "total_tokens": prompt_tokens + completion_tokens,
304 },
305 })
306 }
307
308 fn supports_thinking(model: &str) -> bool {
310 model.contains("2.5") || model.contains("2-5")
311 }
312
313 fn thinking_budget(effort: &str) -> u64 {
315 match effort {
316 "low" => 4000,
317 "high" => 24576,
318 _ => 16000, }
320 }
321}
322
323impl Default for GeminiAdapter {
324 fn default() -> Self {
325 Self::new("gemini-2.0-flash")
326 }
327}
328
329#[async_trait::async_trait]
330impl super::base::ProviderAdapter for GeminiAdapter {
331 fn provider_name(&self) -> &str {
332 "gemini"
333 }
334
335 fn convert_request(&self, payload: Value) -> Value {
336 let mut payload = payload;
337
338 let reasoning_effort = payload
340 .as_object_mut()
341 .and_then(|obj| obj.remove("_reasoning_effort"))
342 .and_then(|v| v.as_str().map(String::from));
343
344 let messages = payload
345 .get("messages")
346 .and_then(|m| m.as_array())
347 .cloned()
348 .unwrap_or_default();
349
350 let (system_instruction, contents) = Self::convert_messages(&messages);
351
352 let mut gemini_payload = json!({
353 "contents": contents,
354 });
355
356 if let Some(system) = system_instruction {
357 gemini_payload["systemInstruction"] = json!({
358 "parts": [{"text": system}]
359 });
360 }
361
362 let mut gen_config = json!({});
364 if let Some(temp) = payload.get("temperature") {
365 gen_config["temperature"] = temp.clone();
366 }
367 if let Some(top_p) = payload.get("top_p") {
368 gen_config["topP"] = top_p.clone();
369 }
370 let max_tok = payload
371 .get("max_tokens")
372 .or_else(|| payload.get("max_completion_tokens"));
373 if let Some(tok) = max_tok {
374 gen_config["maxOutputTokens"] = tok.clone();
375 }
376
377 if Self::supports_thinking(&self.model)
379 && let Some(ref effort) = reasoning_effort
380 {
381 gen_config["thinkingConfig"] = json!({
382 "includeThoughts": true,
383 "thinkingBudget": Self::thinking_budget(effort),
384 });
385 }
386
387 if gen_config.as_object().is_some_and(|o| !o.is_empty()) {
388 gemini_payload["generationConfig"] = gen_config;
389 }
390
391 if let Some(tools) = payload.get("tools").and_then(|t| t.as_array()) {
393 let declarations = Self::convert_tools(tools);
394 if !declarations.is_empty() {
395 gemini_payload["tools"] = json!([{
396 "functionDeclarations": declarations,
397 }]);
398 }
399 }
400
401 gemini_payload
402 }
403
404 fn convert_response(&self, response: Value) -> Value {
405 self.response_to_chat_completions(&response)
406 }
407
408 fn api_url(&self) -> &str {
409 &self.base_url
410 }
411
412 fn supports_streaming(&self) -> bool {
413 true
414 }
415
416 fn enable_streaming(&self, _payload: &mut Value) {
417 }
419
420 fn streaming_url(&self, base_url: &str) -> Option<String> {
421 Some(base_url.replace(":generateContent", ":streamGenerateContent?alt=sse"))
423 }
424
425 fn parse_stream_event(
426 &self,
427 _event_type: &str,
428 data: &Value,
429 ) -> Option<crate::streaming::StreamEvent> {
430 use crate::streaming::StreamEvent;
431
432 let candidates = data.get("candidates")?.as_array()?;
435 let candidate = candidates.first()?;
436 let parts = candidate.get("content")?.get("parts")?.as_array()?;
437
438 for part in parts {
439 let is_thought = part.get("thought").and_then(|t| t.as_bool()) == Some(true);
440 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
441 if is_thought {
442 return Some(StreamEvent::ReasoningDelta(text.to_string()));
443 } else {
444 return Some(StreamEvent::TextDelta(text.to_string()));
445 }
446 }
447 }
448
449 if let Some(error) = data.get("error") {
451 let msg = error
452 .get("message")
453 .and_then(|m| m.as_str())
454 .unwrap_or("Unknown Gemini error");
455 return Some(StreamEvent::Error(msg.to_string()));
456 }
457
458 None
459 }
460}
461
462pub fn gemini_api_url(base_url: &str, model: &str) -> String {
464 format!("{base_url}/models/{model}:generateContent")
465}
466
467#[cfg(test)]
468#[path = "gemini_tests.rs"]
469mod tests;