1use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
34use crate::ai::types::{
35 ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
36 StreamDelta, StreamEvent, ToolCall, Usage,
37};
38use crate::config::ai::AiProviderKind;
39use serde_json::{json, Map, Value};
40
41pub struct GeminiAdapter;
43
44impl ProviderAdapter for GeminiAdapter {
45 fn kind(&self) -> AiProviderKind {
46 AiProviderKind::Gemini
47 }
48
49 fn build_request(
50 &self,
51 req: &ChatRequest,
52 provider_model: &str,
53 ) -> Result<ProviderRequest, AiError> {
54 let mut body = Map::new();
55 let system: Vec<String> = req
56 .messages
57 .iter()
58 .filter(|m| m.role == ChatRole::System)
59 .map(|m| m.text_content())
60 .filter(|s| !s.is_empty())
61 .collect();
62 if !system.is_empty() {
63 body.insert(
64 "systemInstruction".into(),
65 json!({"parts": [{"text": system.join("\n\n")}]}),
66 );
67 }
68 let mut contents: Vec<Value> = Vec::new();
69 for m in &req.messages {
70 match m.role {
71 ChatRole::System => continue,
72 ChatRole::User => contents.push(json!({
73 "role": "user",
74 "parts": user_parts(m),
75 })),
76 ChatRole::Assistant => {
77 let mut parts: Vec<Value> = Vec::new();
78 let mut text = String::new();
79 for p in &m.content {
80 if let ContentPart::Text { text: t } = p {
81 text.push_str(t);
82 }
83 }
84 if !text.is_empty() {
85 parts.push(json!({"text": text}));
86 }
87 for tc in &m.tool_calls {
88 parts.push(json!({
89 "functionCall": {"name": tc.name, "args": tc.arguments_object()}
90 }));
91 }
92 contents.push(json!({"role": "model", "parts": parts}));
93 }
94 ChatRole::Tool => {
95 let name = tool_name_for(&req.messages, m);
98 let response: Value = match m.text_content().trim().parse() {
105 Ok(v @ Value::Object(_)) => v,
106 Ok(other) => json!({"result": other}),
107 Err(_) => json!({"result": m.text_content()}),
108 };
109 contents.push(json!({
110 "role": "user",
111 "parts": [{
112 "functionResponse": {"name": name, "response": response}
113 }],
114 }));
115 }
116 }
117 }
118 if contents.is_empty() {
119 return Err(AiError::InvalidRequest(
120 "the conversation has no non-system messages".to_string(),
121 ));
122 }
123 body.insert("contents".into(), Value::Array(contents));
124 if !req.tools.is_empty() {
125 let decls: Vec<Value> = req
126 .tools
127 .iter()
128 .map(|t| {
129 let mut o = Map::new();
130 o.insert("name".into(), json!(t.name));
131 if let Some(d) = &t.description {
132 o.insert("description".into(), json!(d));
133 }
134 if let Some(p) = &t.parameters {
135 if let Some(pm) = p.as_object() {
138 let filtered: Map<String, Value> = pm
139 .iter()
140 .filter(|(k, _)| k.as_str() != "$schema")
141 .map(|(k, v)| (k.clone(), v.clone()))
142 .collect();
143 o.insert("parameters".into(), Value::Object(filtered));
144 }
145 }
146 Value::Object(o)
147 })
148 .collect();
149 body.insert("tools".into(), json!([{"functionDeclarations": decls}]));
150 if let Some(choice) = &req.tool_choice {
151 let (mode, allowed) = match choice {
152 crate::ai::types::ToolChoice::Auto => ("AUTO", None),
153 crate::ai::types::ToolChoice::None => ("NONE", None),
154 crate::ai::types::ToolChoice::Any => ("ANY", None),
155 crate::ai::types::ToolChoice::Tool(name) => ("ANY", Some(vec![name.clone()])),
156 };
157 let mut tc = Map::new();
158 tc.insert("mode".into(), json!(mode));
159 if let Some(a) = allowed {
160 tc.insert("allowed_function_names".into(), json!(a));
161 }
162 body.insert("toolConfig".into(), Value::Object(tc));
163 }
164 }
165 let mut generation = Map::new();
166 if let Some(t) = req.temperature {
167 generation.insert("temperature".into(), json!(t));
168 }
169 if let Some(p) = req.top_p {
170 generation.insert("topP".into(), json!(p));
171 }
172 if let Some(m) = req.max_tokens {
173 generation.insert("maxOutputTokens".into(), json!(m));
174 }
175 if let Some(stop) = &req.stop {
176 generation.insert("stopSequences".into(), json!(stop));
177 }
178 if let Some(rf) = &req.response_format {
181 apply_gemini_response_format(&mut generation, rf);
182 }
183 if !generation.is_empty() {
184 body.insert("generationConfig".into(), Value::Object(generation));
185 }
186 let path = if req.stream {
187 format!("/v1beta/models/{provider_model}:streamGenerateContent?alt=sse")
188 } else {
189 format!("/v1beta/models/{provider_model}:generateContent")
190 };
191 Ok(ProviderRequest {
192 method: http::Method::POST,
193 path,
194 headers: vec![(http::header::CONTENT_TYPE, "application/json".to_string())],
195 body: Value::Object(body),
196 })
197 }
198
199 fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
200 let obj = body.as_object().ok_or_else(|| {
201 AiError::Translation("gemini response is not a JSON object".to_string())
202 })?;
203 let candidates = obj
204 .get("candidates")
205 .and_then(Value::as_array)
206 .ok_or_else(|| AiError::Translation("gemini response has no candidates".to_string()))?;
207 let mut choices = Vec::with_capacity(candidates.len());
208 for (i, c) in candidates.iter().enumerate() {
209 let mut text = String::new();
210 let mut tool_calls = Vec::new();
211 if let Some(parts) = c
212 .get("content")
213 .and_then(|c| c.get("parts"))
214 .and_then(Value::as_array)
215 {
216 for p in parts {
217 if let Some(t) = p.get("text").and_then(Value::as_str) {
218 text.push_str(t);
219 }
220 if let Some(fc) = p.get("functionCall") {
221 tool_calls.push(ToolCall {
222 id: format!("call-{i}-{}", tool_calls.len()),
227 name: fc
228 .get("name")
229 .and_then(Value::as_str)
230 .unwrap_or_default()
231 .to_string(),
232 arguments: fc.get("args").cloned().unwrap_or(json!({})).to_string(),
233 });
234 }
235 }
236 }
237 let finish_reason = match c.get("finishReason").and_then(Value::as_str) {
238 Some("STOP") | None => FinishReason::Stop,
239 Some("MAX_TOKENS") => FinishReason::Length,
240 Some("SAFETY") | Some("PROHIBITED_CONTENT") => FinishReason::ContentFilter,
241 Some(other) => FinishReason::Other(other.to_string()),
242 };
243 choices.push(Choice {
244 index: i as u64,
245 message: ChatMessage {
246 role: ChatRole::Assistant,
247 content: vec![ContentPart::Text { text }],
248 name: None,
249 tool_calls,
250 tool_call_id: None,
251 },
252 finish_reason,
253 });
254 }
255 let usage = obj.get("usageMetadata").map(|u| Usage {
256 prompt_tokens: u.get("promptTokenCount").and_then(Value::as_u64),
257 completion_tokens: u.get("candidatesTokenCount").and_then(Value::as_u64),
258 total_tokens: u.get("totalTokenCount").and_then(Value::as_u64),
259 cached_tokens: u.get("cachedContentTokenCount").and_then(Value::as_u64),
260 });
261 Ok(ChatResponse {
262 id: obj
263 .get("responseId")
264 .and_then(Value::as_str)
265 .map(String::from),
266 model: None,
267 choices,
268 usage,
269 })
270 }
271
272 fn parse_error(&self, body: &Value) -> ProviderErrorBody {
273 let e = body.get("error").and_then(|e| e.as_object());
275 ProviderErrorBody {
276 message: e
277 .and_then(|e| e.get("message"))
278 .and_then(Value::as_str)
279 .unwrap_or("provider returned an error")
280 .to_string(),
281 error_type: e
282 .and_then(|e| e.get("status"))
283 .and_then(Value::as_str)
284 .map(String::from),
285 code: e.and_then(|e| e.get("code")).map(|c| match c {
286 Value::String(s) => s.clone(),
287 other => other.to_string(),
288 }),
289 }
290 }
291
292 fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
293 let resp = match self.parse_response(data) {
299 Ok(r) => r,
300 Err(_) if data.get("candidates").is_none() => return Ok(Vec::new()),
301 Err(e) => return Err(e),
302 };
303 let mut out = Vec::new();
304 for choice in &resp.choices {
305 let mut content = None;
306 if !choice.message.content.is_empty() {
307 content = Some(choice.message.text_content());
308 }
309 if content.is_some() || !choice.message.tool_calls.is_empty() {
310 out.push(StreamEvent::Delta(StreamDelta {
311 index: choice.index,
312 role: None,
313 content,
314 tool_calls: choice
315 .message
316 .tool_calls
317 .iter()
318 .map(|tc| crate::ai::types::ToolCallDelta {
319 index: choice.index,
320 id: Some(tc.id.clone()),
321 name: Some(tc.name.clone()),
322 arguments: tc.arguments.clone(),
323 })
324 .collect(),
325 finish_reason: None,
326 }));
327 }
328 if choice.finish_reason != FinishReason::Stop || resp.usage.is_some() {
329 out.push(StreamEvent::Delta(StreamDelta {
330 index: choice.index,
331 role: None,
332 content: None,
333 tool_calls: Vec::new(),
334 finish_reason: Some(choice.finish_reason.clone()),
335 }));
336 }
337 }
338 if let Some(u) = resp.usage {
339 out.push(StreamEvent::Usage(u));
340 }
341 Ok(out)
342 }
343}
344
345fn user_parts(m: &ChatMessage) -> Value {
348 let mut parts: Vec<Value> = Vec::new();
349 let mut text = String::new();
350 for p in &m.content {
351 match p {
352 ContentPart::Text { text: t } => text.push_str(t),
353 ContentPart::Image {
354 url,
355 media_type,
356 data_b64,
357 } => {
358 if let Some(data) = data_b64 {
359 parts.push(json!({
360 "inline_data": {
361 "mime_type": media_type.clone().unwrap_or_else(|| "image/png".into()),
362 "data": data,
363 }
364 }));
365 } else if let Some(url) = url {
366 if let Some((mime, data)) = crate::ai::types::split_data_uri(url) {
367 parts.push(json!({
368 "inline_data": {"mime_type": mime, "data": data}
369 }));
370 }
371 }
373 }
374 }
375 }
376 if !text.is_empty() {
377 parts.insert(0, json!({"text": text}));
378 }
379 Value::Array(parts)
380}
381
382fn tool_name_for(history: &[ChatMessage], tool_msg: &ChatMessage) -> String {
385 let id = tool_msg.tool_call_id.as_deref().unwrap_or("");
386 for m in history.iter().rev() {
387 if m.role == ChatRole::Assistant {
388 for tc in &m.tool_calls {
389 if tc.id == id {
390 return tc.name.clone();
391 }
392 }
393 }
394 }
395 id.to_string()
396}
397
398fn apply_gemini_response_format(
403 generation: &mut Map<String, Value>,
404 rf: &crate::ai::types::ResponseFormat,
405) {
406 use crate::ai::types::ResponseFormat;
407 match rf {
408 ResponseFormat::Text => { }
409 ResponseFormat::JsonObject => {
410 generation.insert("responseMimeType".into(), json!("application/json"));
411 }
412 ResponseFormat::JsonSchema { schema, .. } => {
413 generation.insert("responseMimeType".into(), json!("application/json"));
414 generation.insert("responseSchema".into(), schema.clone());
415 }
416 }
417}