1use aither_core::{
2 LanguageModel,
3 llm::{
4 Attachment, Event, GenerateError, LLMRequest, Message, ReasoningState, RetryPolicy, Role,
5 Usage,
6 model::{Ability, Parameters, Profile, ToolChoice},
7 tool::ToolDefinition,
8 },
9};
10use futures_core::Stream;
11use futures_lite::StreamExt;
12use schemars::JsonSchema;
13use serde::de::DeserializeOwned;
14use std::collections::HashMap;
15use tracing::debug;
16
17use crate::PROVIDER_NAME;
18use crate::{
19 client::{RETRY_POLICY, stream_generate},
20 config::Gemini,
21 error::GeminiError,
22 types::{
23 FunctionCallingConfig, FunctionCallingMode, FunctionDeclaration, GeminiContent, GeminiTool,
24 GenerateContentRequest, GenerationConfig, GoogleSearch, Part, PromptFeedback, SafetyRating,
25 ThinkingConfig, ThinkingLevel, ToolConfig, UsageMetadata,
26 },
27};
28use schemars::schema_for;
29
30impl LanguageModel for Gemini {
31 type Error = GeminiError;
32
33 fn respond(
34 &self,
35 request: LLMRequest,
36 ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
37 let cfg = self.config();
38 respond_stream(cfg.clone(), request)
39 }
40
41 fn generate<T: JsonSchema + DeserializeOwned + 'static>(
42 &self,
43 mut request: LLMRequest,
44 ) -> impl core::future::Future<Output = Result<T, GenerateError<Self::Error>>> + Send {
45 let schema = schema_for!(T);
46 let mut params = request.parameters().clone();
47 params.structured_outputs = true;
48 params.response_format = Some(schema);
49 request = request.with_parameters(params);
50
51 let stream = self.respond(request);
52 async move {
53 let text = aither_core::llm::collect_text(stream)
54 .await
55 .map_err(GenerateError::Provider)?;
56 serde_json::from_str::<T>(&text).map_err(|source| GenerateError::Parse {
57 source,
58 response: text.chars().take(500).collect(),
59 })
60 }
61 }
62
63 fn retry_policy(&self) -> RetryPolicy {
64 RETRY_POLICY
65 }
66
67 fn profile(&self) -> impl core::future::Future<Output = Result<Profile, Self::Error>> + Send {
68 let cfg = self.config().clone();
69 async move {
70 let model_name = cfg.text_model.trim_start_matches("models/").to_string();
71
72 let context_length = match crate::client::get_model_info(&cfg, &cfg.text_model).await {
74 Ok(info) => info.input_token_limit,
75 Err(e) => {
76 tracing::debug!("API did not return context_length: {e}");
77 aither_models::lookup(&model_name)
79 .and_then(aither_models::ModelEntry::max_input_tokens)
80 .ok_or_else(|| {
81 GeminiError::Api(format!(
82 "cannot determine context length for model '{model_name}': \
83 model info request failed ({e}) and aither-models has no entry"
84 ))
85 })?
86 }
87 };
88
89 let mut profile = Profile::new(
90 model_name.clone(),
91 "google",
92 model_name,
93 "Gemini Developer API model",
94 context_length,
95 )
96 .with_abilities([Ability::ToolUse, Ability::Vision, Ability::Audio]);
97 for ability in &cfg.native_abilities {
98 if !profile.abilities.contains(ability) {
99 profile.abilities.push(*ability);
100 }
101 }
102 Ok(profile)
103 }
104 }
105}
106
107fn respond_stream(
108 cfg: crate::config::GeminiConfig,
109 request: LLMRequest,
110) -> impl Stream<Item = Result<Event, GeminiError>> + Send {
111 Box::pin(respond_stream_inner(cfg, request))
112}
113
114async fn build_generate_request(
124 cfg: &crate::config::GeminiConfig,
125 request: LLMRequest,
126) -> Result<GenerateContentRequest, GeminiError> {
127 let (messages, parameters, tool_defs) = request.into_parts();
128
129 #[cfg(not(target_arch = "wasm32"))]
130 let messages = crate::attachments::resolve_messages(cfg, messages).await?;
131 #[cfg(target_arch = "wasm32")]
134 let _ = cfg;
135
136 let (system_instruction, contents) = messages_to_gemini(&messages).await?;
137
138 let tool_defs: Vec<ToolDefinition> = match ¶meters.tool_choice {
139 ToolChoice::None => Vec::new(),
140 ToolChoice::Exact(name) => tool_defs
141 .into_iter()
142 .filter(|tool| tool.name() == name)
143 .collect(),
144 ToolChoice::Auto | ToolChoice::Required => tool_defs,
145 };
146 let has_function_tools = !tool_defs.is_empty();
147
148 if let ToolChoice::Exact(name) = ¶meters.tool_choice
149 && !has_function_tools
150 {
151 return Err(GeminiError::Api(format!(
152 "Exact tool choice '{name}' is not present in tool definitions"
153 )));
154 }
155
156 let mut tools: Vec<GeminiTool> = Vec::new();
157 if has_function_tools {
158 tools.push(GeminiTool::FunctionTool {
159 function_declarations: convert_tool_definitions(tool_defs),
160 });
161 }
162
163 let builtins_allowed = !matches!(
166 parameters.tool_choice,
167 ToolChoice::None | ToolChoice::Exact(_)
168 );
169 let native = ¶meters.native_tools.gemini;
170 if builtins_allowed && (parameters.websearch || native.google_search) {
171 tools.push(GeminiTool::GoogleSearchTool {
172 google_search: GoogleSearch {},
173 });
174 }
175 if builtins_allowed && (parameters.code_execution || native.code_execution) {
176 tools.push(GeminiTool::CodeExecutionTool {
177 code_execution: crate::types::CodeExecution {},
178 });
179 }
180 if builtins_allowed && native.url_context {
181 tools.push(GeminiTool::UrlContextTool {
182 url_context: crate::types::UrlContext {},
183 });
184 }
185
186 Ok(GenerateContentRequest {
187 system_instruction,
188 contents,
189 generation_config: build_generation_config(¶meters, None)?,
190 tools,
191 tool_config: build_tool_config(¶meters, has_function_tools),
192 safety_settings: Vec::new(),
193 cached_content: parameters
194 .cache
195 .gemini
196 .as_ref()
197 .map(|cache| cache.cached_content.clone()),
198 })
199}
200
201fn events_from_content(content: &GeminiContent) -> Vec<Event> {
204 let mut events = Vec::new();
205
206 events.extend(content.reasoning_chunks().into_iter().map(Event::Reasoning));
207 events.extend(
208 content
209 .text_chunks()
210 .into_iter()
211 .filter(|text| !text.is_empty())
212 .map(Event::Text),
213 );
214
215 for part in &content.parts {
216 if let Some(code) = &part.executable_code {
217 events.push(Event::BuiltInToolCall {
218 id: String::new(),
219 tool: "code_execution".to_string(),
220 detail: format!("```{}\n{}\n```", code.language.to_lowercase(), code.code),
221 });
222 }
223 if let Some(result) = &part.code_execution_result {
224 events.push(Event::BuiltInToolResult {
225 id: String::new(),
226 tool: "code_execution".to_string(),
227 result: format!("```output\n{}\n```", result.output),
228 });
229 }
230 }
231
232 events.extend(
234 content
235 .function_call_parts()
236 .into_iter()
237 .map(|(call, signature)| {
238 Event::ToolCall(aither_core::llm::ToolCall {
241 id: tool_call_id(),
242 name: call.name,
243 arguments: call.args,
244 reasoning_state: signature
245 .map(|value| ReasoningState::new(PROVIDER_NAME, value)),
246 })
247 }),
248 );
249
250 events
251}
252
253fn respond_stream_inner(
254 cfg: crate::config::GeminiConfig,
255 request: LLMRequest,
256) -> impl Stream<Item = Result<Event, GeminiError>> + Send {
257 async_stream::stream! {
258 let gemini_request = match build_generate_request(&cfg, request).await {
259 Ok(request) => request,
260 Err(e) => {
261 yield Err(e);
262 return;
263 }
264 };
265
266 debug!("Gemini request: {:?}", gemini_request);
267
268 let stream = match stream_generate(&cfg, &cfg.text_model, gemini_request).await {
270 Ok(s) => s,
271 Err(e) => {
272 yield Err(e);
273 return;
274 }
275 };
276 futures_lite::pin!(stream);
277 let mut usage: Option<Usage> = None;
278 let mut finish_reason: Option<String> = None;
279
280 while let Some(result) = stream.next().await {
281 let response = match result {
282 Ok(r) => r,
283 Err(e) => {
284 yield Err(e);
285 continue;
286 }
287 };
288
289 debug!("Gemini stream chunk: {:?}", response);
290
291 if let Some(meta) = &response.usage_metadata {
292 usage = Some(usage_from_metadata(meta));
293 }
294
295 let Some(candidate) = response.primary_candidate() else {
296 if let Some(feedback) = &response.prompt_feedback {
299 yield Err(GeminiError::Api(format_prompt_feedback(feedback)));
300 }
301 continue;
302 };
303
304 let Some(content) = &candidate.content else {
305 if let Some(reason) = candidate.finish_reason.clone() {
306 finish_reason = Some(reason);
307 break;
308 }
309 continue;
310 };
311
312 for event in events_from_content(content) {
313 yield Ok(event);
314 }
315
316 if let Some(reason) = candidate.finish_reason.clone() {
317 finish_reason = Some(reason);
318 break;
319 }
320 }
321
322 if let Some(mut final_usage) = usage {
323 final_usage.stop_reason = finish_reason;
324 yield Ok(Event::Usage(final_usage));
325 } else if let Some(reason) = finish_reason {
326 yield Ok(Event::Usage(Usage {
327 stop_reason: Some(reason),
328 ..Usage::default()
329 }));
330 }
331 }
332}
333
334fn uuid_v4() -> String {
336 use std::time::{SystemTime, UNIX_EPOCH};
337 let timestamp = SystemTime::now()
338 .duration_since(UNIX_EPOCH)
339 .map_or(0, |d| d.as_nanos());
340 format!("{timestamp:032x}")
341}
342
343fn tool_call_id() -> String {
344 format!("gemini_{}", uuid_v4())
345}
346
347fn parse_tool_response(content: &str) -> Option<(String, serde_json::Value, Option<String>)> {
348 let content = content.trim_start();
349 if !content.starts_with('[') {
350 return None;
351 }
352 let end = content.find(']')?;
353 let header = &content[1..end];
354 let mut header_split = header.splitn(2, ':');
355 let id = header_split.next()?.trim();
356 let name = header_split.next()?.trim().to_string();
357 let rest = &content[end + 1..];
358 let output = rest.strip_prefix(' ').unwrap_or(rest);
359 let _ = id;
360 let signature = None;
361 let response_value = match serde_json::from_str::<serde_json::Value>(output) {
362 Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
363 Ok(other) => {
364 let mut map = serde_json::Map::new();
365 map.insert("result".to_string(), other);
366 serde_json::Value::Object(map)
367 }
368 Err(_) => {
369 let mut map = serde_json::Map::new();
370 map.insert(
371 "result".to_string(),
372 serde_json::Value::String(output.to_string()),
373 );
374 serde_json::Value::Object(map)
375 }
376 };
377 Some((name, response_value, signature))
378}
379
380const fn usage_from_metadata(meta: &UsageMetadata) -> Usage {
381 Usage {
382 prompt_tokens: meta.prompt_token_count,
383 completion_tokens: meta.candidates_token_count,
384 total_tokens: meta.total_token_count,
385 reasoning_tokens: meta.thoughts_token_count,
386 cache_read_tokens: meta.cached_content_token_count,
387 cache_write_tokens: None,
388 cost_usd: None,
389 stop_reason: None,
390 }
391}
392
393fn index_tool_calls(messages: &[Message]) -> ToolCallIndex<'_> {
399 let mut names = HashMap::new();
400 let mut signatures = HashMap::new();
401 let mut positions = HashMap::new();
402 for message in messages {
403 for tc in message.tool_calls() {
404 positions.insert(tc.id.as_str(), positions.len());
405 names.insert(tc.id.as_str(), tc.name.as_str());
406 if let Some(signature) = tc
407 .reasoning_state
408 .as_ref()
409 .and_then(|state| state.payload_for(PROVIDER_NAME))
410 {
411 signatures.insert(tc.id.as_str(), signature);
412 }
413 }
414 }
415 ToolCallIndex {
416 names,
417 signatures,
418 positions,
419 }
420}
421
422fn tool_response_value(content: &str) -> serde_json::Value {
424 match serde_json::from_str::<serde_json::Value>(content) {
425 Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
426 Ok(other) => serde_json::json!({ "result": other }),
427 Err(_) => serde_json::json!({ "result": content }),
428 }
429}
430
431struct ToolCallIndex<'a> {
433 names: HashMap<&'a str, &'a str>,
434 signatures: HashMap<&'a str, &'a str>,
435 positions: HashMap<&'a str, usize>,
438}
439
440fn flush_tool_results(contents: &mut Vec<GeminiContent>, pending: &mut Vec<(usize, Part)>) {
446 if pending.is_empty() {
447 return;
448 }
449 pending.sort_by_key(|(position, _)| *position);
450 let parts = pending.drain(..).map(|(_, part)| part).collect();
451 contents.push(GeminiContent::with_parts("user", parts));
452}
453
454async fn messages_to_gemini(
455 messages: &[Message],
456) -> Result<(Option<GeminiContent>, Vec<GeminiContent>), GeminiError> {
457 let mut system_parts = Vec::new();
458 let mut contents = Vec::new();
459
460 let index = index_tool_calls(messages);
461 let mut pending_results: Vec<(usize, Part)> = Vec::new();
462
463 for message in messages {
464 if message.role() != Role::Tool {
465 flush_tool_results(&mut contents, &mut pending_results);
466 }
467 match message.role() {
468 Role::System => system_parts.push(Part::text(message.content())),
469 Role::User => {
470 let attachments = message.attachments();
471 if attachments.is_empty() {
472 contents.push(GeminiContent::text("user", message.content()));
473 } else {
474 let mut parts = Vec::new();
476
477 for attachment in attachments {
479 parts.push(url_to_part(attachment).await?);
480 }
481
482 if !message.content().is_empty() {
484 parts.push(Part::text(message.content()));
485 }
486
487 contents.push(GeminiContent::with_parts("user", parts));
488 }
489 }
490 Role::Tool => {
491 let tool_call_id = message.tool_call_id().unwrap_or("");
493 let base_id = tool_call_id.to_string();
494 let signature = index
495 .signatures
496 .get(tool_call_id)
497 .map(|value| (*value).to_string());
498
499 if let Some(&function_name) = index.names.get(tool_call_id) {
500 pending_results.push((
501 index.positions[tool_call_id],
502 Part::function_response_with_signature(
503 function_name.to_string(),
504 tool_response_value(message.content()),
505 signature,
506 ),
507 ));
508 } else {
509 if let Some((name, response, sig)) = parse_tool_response(message.content()) {
511 pending_results.push((
512 usize::MAX,
513 Part::function_response_with_signature(name, response, sig),
514 ));
515 } else {
516 debug!("Tool message without matching tool_call_id: {}", base_id);
518 flush_tool_results(&mut contents, &mut pending_results);
519 contents.push(GeminiContent::text("user", message.content()));
520 }
521 }
522 }
523 Role::Assistant => {
524 let tool_calls = message.tool_calls();
525 if tool_calls.is_empty() {
526 contents.push(GeminiContent::text("model", message.content()));
528 } else {
529 let mut parts = Vec::new();
531
532 if !message.content().is_empty() {
534 parts.push(Part::text(message.content()));
535 }
536
537 for tc in tool_calls {
539 let signature = tc
540 .reasoning_state
541 .as_ref()
542 .and_then(|state| state.payload_for(PROVIDER_NAME))
543 .map(String::from);
544 parts.push(Part::function_call_with_signature(
545 tc.name.clone(),
546 tc.arguments.clone(),
547 signature,
548 ));
549 }
550
551 contents.push(GeminiContent::with_parts("model", parts));
552 }
553 }
554 }
555 }
556
557 flush_tool_results(&mut contents, &mut pending_results);
558
559 let system_instruction = if system_parts.is_empty() {
560 None
561 } else {
562 Some(GeminiContent::system(system_parts))
563 };
564
565 Ok((system_instruction, contents))
566}
567
568fn format_prompt_feedback(feedback: &PromptFeedback) -> String {
569 let reason = feedback
570 .block_reason
571 .as_deref()
572 .unwrap_or("unknown reason")
573 .to_string();
574 if feedback.safety_ratings.is_empty() {
575 return format!("Gemini response blocked: {reason}");
576 }
577
578 let ratings = feedback
579 .safety_ratings
580 .iter()
581 .map(format_safety_rating)
582 .collect::<Vec<_>>()
583 .join(", ");
584 format!("Gemini response blocked: {reason}; safety: {ratings}")
585}
586
587fn format_safety_rating(rating: &SafetyRating) -> String {
588 let status = rating
589 .blocked
590 .map_or("unspecified", |b| if b { "blocked" } else { "allowed" });
591 let probability = rating.probability.as_deref().unwrap_or("unknown");
592 format!("{} ({status}, probability: {probability})", rating.category)
593}
594
595fn build_generation_config(
600 parameters: &Parameters,
601 modalities: Option<Vec<String>>,
602) -> Result<Option<GenerationConfig>, GeminiError> {
603 let thinking_config = build_thinking_config(parameters)?;
604 let mut config = GenerationConfig {
605 temperature: parameters.temperature,
606 top_p: parameters.top_p,
607 top_k: parameters.top_k,
608 max_output_tokens: parameters.max_tokens.map(
609 #[allow(clippy::cast_possible_wrap)]
610 |value| value as i32,
611 ),
612 stop_sequences: parameters.stop.clone(),
613 response_modalities: modalities,
614 thinking_config,
615 seed: parameters.seed.map(
616 #[allow(clippy::cast_possible_wrap)]
617 |value| value as i32,
618 ),
619 presence_penalty: parameters.presence_penalty,
620 frequency_penalty: parameters.frequency_penalty,
621 response_logprobs: parameters.logprobs,
622 logprobs: parameters.top_logprobs.map(i32::from),
623 ..Default::default()
624 };
625 if let Some(schema) = ¶meters.response_format {
626 config.response_mime_type = Some("application/json".into());
627 config.response_json_schema = Some(schema.clone().to_value());
628 } else if parameters.structured_outputs {
629 config.response_mime_type = Some("application/json".into());
630 }
631 if config.is_meaningful() {
632 Ok(Some(config))
633 } else {
634 Ok(None)
635 }
636}
637
638fn build_tool_config(parameters: &Parameters, has_tools: bool) -> Option<ToolConfig> {
639 if !has_tools {
640 return None;
641 }
642 let (mode, allowed) = match ¶meters.tool_choice {
643 ToolChoice::None => return None,
644 ToolChoice::Auto => (FunctionCallingMode::Auto, None),
645 ToolChoice::Required => (FunctionCallingMode::Any, None),
646 ToolChoice::Exact(name) => (FunctionCallingMode::Any, Some(vec![name.clone()])),
647 };
648 Some(ToolConfig {
649 function_calling_config: Some(FunctionCallingConfig {
650 mode,
651 allowed_function_names: allowed,
652 }),
653 })
654}
655
656fn build_thinking_config(parameters: &Parameters) -> Result<Option<ThinkingConfig>, GeminiError> {
665 let thinking_level = parameters
666 .reasoning_effort
667 .map(ThinkingLevel::try_from)
668 .transpose()?;
669 if !parameters.include_reasoning && thinking_level.is_none() {
670 return Ok(None);
671 }
672 Ok(Some(ThinkingConfig {
673 include_thoughts: parameters.include_reasoning.then_some(true),
674 thinking_level,
675 }))
676}
677
678fn convert_tool_definitions(defs: Vec<ToolDefinition>) -> Vec<FunctionDeclaration> {
679 defs.into_iter()
680 .map(|tool| FunctionDeclaration {
681 name: tool.name().to_string(),
682 description: tool.description().to_string(),
683 parameters: Some(tool.arguments_openai_schema()),
684 })
685 .collect()
686}
687
688async fn url_to_part(attachment: &Attachment) -> Result<Part, GeminiError> {
690 let url = attachment.url();
691 let media_type = attachment.media_type().as_ref();
692 match url.scheme() {
693 "data" => parse_data_url(url.as_str(), media_type),
694 "file" => read_file_to_part(url, media_type).await,
695 "http" | "https" => Ok(Part::from_file(media_type, url.as_str())),
696 scheme => Err(GeminiError::Api(format!(
697 "Gemini does not support attachment URL scheme '{scheme}'"
698 ))),
699 }
700}
701
702fn parse_data_url(url: &str, media_type: &str) -> Result<Part, GeminiError> {
703 use base64::Engine;
704
705 let after_data = url
706 .strip_prefix("data:")
707 .ok_or_else(|| GeminiError::Api("Malformed attachment data URL".to_string()))?;
708 let (header, data) = after_data
709 .split_once(',')
710 .ok_or_else(|| GeminiError::Api("Attachment data URL is missing payload".to_string()))?;
711 let encoded_media_type = header.strip_suffix(";base64").ok_or_else(|| {
712 GeminiError::Api("Attachment data URL must use base64 encoding".to_string())
713 })?;
714 if encoded_media_type != media_type {
715 return Err(GeminiError::Api(format!(
716 "Attachment MIME type '{media_type}' does not match data URL MIME type '{encoded_media_type}'"
717 )));
718 }
719 let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
720 Ok(Part::inline_media(media_type, bytes))
721}
722
723#[cfg(not(target_arch = "wasm32"))]
724async fn read_file_to_part(url: &url::Url, media_type: &str) -> Result<Part, GeminiError> {
725 let path = url.to_file_path().map_err(|()| {
726 GeminiError::Api("Attachment file URL could not be converted to a path".to_string())
727 })?;
728 let data = async_fs::read(&path).await.map_err(|error| {
729 GeminiError::Api(format!(
730 "Failed to read attachment '{}': {error}",
731 path.display()
732 ))
733 })?;
734 Ok(Part::inline_media(media_type, data))
735}
736
737#[cfg(target_arch = "wasm32")]
738async fn read_file_to_part(_url: &url::Url, _media_type: &str) -> Result<Part, GeminiError> {
739 Err(GeminiError::Api(
740 "file:// attachments are not supported on wasm32".to_string(),
741 ))
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use aither_core::llm::model::ReasoningEffort;
748
749 #[tokio::test]
750 async fn data_attachments_preserve_declared_media_types() {
751 for media_type in ["image/png", "audio/wav", "video/mp4", "application/pdf"] {
752 let attachment = Attachment::new(
753 format!("data:{media_type};base64,AA==")
754 .parse()
755 .expect("data URL"),
756 media_type.parse().expect("MIME type"),
757 );
758 let part = url_to_part(&attachment).await.expect("encode attachment");
759 let value = serde_json::to_value(part).expect("serialize part");
760 assert_eq!(value["inlineData"]["mimeType"], media_type);
761 }
762 }
763
764 #[tokio::test]
765 async fn remote_attachment_uses_file_data_with_media_type() {
766 let attachment = Attachment::new(
767 "https://ai.google.dev/static/gemini-api/docs/files/sample.pdf"
768 .parse()
769 .expect("remote URL"),
770 "application/pdf".parse().expect("PDF MIME"),
771 );
772 let part = url_to_part(&attachment).await.expect("encode attachment");
773 let value = serde_json::to_value(part).expect("serialize part");
774 assert_eq!(value["fileData"]["mimeType"], "application/pdf");
775 assert_eq!(
776 value["fileData"]["fileUri"],
777 "https://ai.google.dev/static/gemini-api/docs/files/sample.pdf"
778 );
779 }
780
781 #[test]
782 fn data_url_mime_mismatch_fails() {
783 let error = parse_data_url("data:image/png;base64,AA==", "audio/wav")
784 .expect_err("MIME mismatch must fail");
785 assert!(error.to_string().contains("does not match"));
786 }
787
788 fn thinking_json(params: &Parameters) -> serde_json::Value {
789 let config = build_thinking_config(params)
790 .expect("effort supported by Gemini")
791 .expect("thinking config");
792 serde_json::to_value(config).expect("serialize thinking config")
793 }
794
795 #[test]
798 fn thinking_config_uses_gemini_field_names() {
799 let value = thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::Medium));
800 assert_eq!(value["thinkingLevel"], "medium");
801 assert_eq!(value.get("tokenBudget"), None);
802 assert_eq!(value.get("thinkingBudget"), None);
803 }
804
805 #[test]
808 fn thinking_config_never_pairs_a_budget_with_a_level() {
809 for effort in [
810 ReasoningEffort::Minimal,
811 ReasoningEffort::Low,
812 ReasoningEffort::Medium,
813 ReasoningEffort::High,
814 ] {
815 let value = thinking_json(&Parameters::default().reasoning_effort(effort));
816 let object = value.as_object().expect("thinking config object");
817 assert!(
818 object.keys().all(|key| key != "thinkingBudget"),
819 "effort {effort:?} paired a budget with a level"
820 );
821 assert!(object.contains_key("thinkingLevel"));
822 }
823 }
824
825 #[test]
826 fn minimal_effort_maps_to_the_minimal_level() {
827 let value =
828 thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::Minimal));
829 assert_eq!(value["thinkingLevel"], "minimal");
830 }
831
832 #[test]
835 fn efforts_above_gemini_ceiling_are_rejected() {
836 for effort in [ReasoningEffort::XHigh, ReasoningEffort::Max] {
837 let error = build_thinking_config(&Parameters::default().reasoning_effort(effort))
838 .expect_err("effort above the Gemini ceiling must fail");
839 assert!(
840 error.to_string().contains("tops out at High"),
841 "unexpected error for {effort:?}: {error}"
842 );
843 }
844 }
845
846 #[tokio::test]
850 async fn thought_signature_round_trips_without_mangling_the_id() {
851 let call = aither_core::llm::ToolCall::new("gemini_abc", "lookup", serde_json::json!({}))
852 .with_reasoning_state(ReasoningState::new(PROVIDER_NAME, "sig-xyz"));
853 let message = Message::assistant_with_tool_calls("", vec![call]);
854
855 let (_, contents) = messages_to_gemini(&[message]).await.expect("convert");
856 let value = serde_json::to_value(&contents).expect("serialize contents");
857 let part = &value[0]["parts"][0];
858
859 assert_eq!(part["thoughtSignature"], "sig-xyz");
860 assert_eq!(part["functionCall"]["name"], "lookup");
861 }
862
863 #[tokio::test]
864 async fn generated_tool_call_ids_carry_no_smuggled_signature() {
865 assert!(!tool_call_id().contains("|ts|"));
866 }
867
868 #[tokio::test]
870 async fn foreign_reasoning_state_is_not_sent_as_a_signature() {
871 let call = aither_core::llm::ToolCall::new("gemini_abc", "lookup", serde_json::json!({}))
872 .with_reasoning_state(ReasoningState::new("anthropic", "sig-from-claude"));
873 let message = Message::assistant_with_tool_calls("", vec![call]);
874
875 let (_, contents) = messages_to_gemini(&[message]).await.expect("convert");
876 let value = serde_json::to_value(&contents).expect("serialize contents");
877 assert_eq!(value[0]["parts"][0].get("thoughtSignature"), None);
878 }
879
880 fn call(id: &str, name: &str) -> aither_core::llm::ToolCall {
881 aither_core::llm::ToolCall::new(id, name, serde_json::json!({}))
882 }
883
884 fn responses(content: &serde_json::Value) -> Vec<(String, String)> {
885 content["parts"]
886 .as_array()
887 .expect("parts")
888 .iter()
889 .map(|part| {
890 (
891 part["functionResponse"]["name"]
892 .as_str()
893 .expect("function name")
894 .to_string(),
895 part["functionResponse"]["response"]["result"]
896 .as_str()
897 .expect("result")
898 .to_string(),
899 )
900 })
901 .collect()
902 }
903
904 fn pairs(items: [(&str, &str); 2]) -> Vec<(String, String)> {
905 items
906 .into_iter()
907 .map(|(a, b)| (a.to_string(), b.to_string()))
908 .collect()
909 }
910
911 #[tokio::test]
912 async fn parallel_tool_results_share_one_content() {
913 let messages = [
914 Message::assistant_with_tool_calls("", vec![call("a", "weather"), call("b", "time")]),
915 Message::tool("a", "sunny"),
916 Message::tool("b", "noon"),
917 ];
918 let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
919 let value = serde_json::to_value(&contents).expect("serialize");
920 assert_eq!(value.as_array().expect("contents").len(), 2);
921 assert_eq!(value[1]["role"], "user");
922 assert_eq!(
923 responses(&value[1]),
924 pairs([("weather", "sunny"), ("time", "noon")])
925 );
926 }
927
928 #[tokio::test]
929 async fn same_function_results_stay_distinct_and_ordered() {
930 let messages = [
931 Message::assistant_with_tool_calls("", vec![call("a", "lookup"), call("b", "lookup")]),
932 Message::tool("a", "first"),
933 Message::tool("b", "second"),
934 ];
935 let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
936 let value = serde_json::to_value(&contents).expect("serialize");
937 assert_eq!(value.as_array().expect("contents").len(), 2);
938 assert_eq!(
939 responses(&value[1]),
940 pairs([("lookup", "first"), ("lookup", "second")])
941 );
942 }
943
944 #[tokio::test]
945 async fn out_of_order_results_follow_call_order_and_keep_signatures() {
946 let signed =
947 call("b", "time").with_reasoning_state(ReasoningState::new(PROVIDER_NAME, "sig-b"));
948 let messages = [
949 Message::assistant_with_tool_calls("", vec![call("a", "weather"), signed]),
950 Message::tool("b", "noon"),
951 Message::tool("a", "sunny"),
952 Message::user("thanks"),
953 ];
954 let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
955 let value = serde_json::to_value(&contents).expect("serialize");
956 assert_eq!(value.as_array().expect("contents").len(), 3);
957 assert_eq!(
958 responses(&value[1]),
959 pairs([("weather", "sunny"), ("time", "noon")])
960 );
961 assert_eq!(value[1]["parts"][0].get("thoughtSignature"), None);
962 assert_eq!(value[1]["parts"][1]["thoughtSignature"], "sig-b");
963 assert_eq!(value[2]["parts"][0]["text"], "thanks");
964 }
965
966 #[test]
968 fn none_effort_is_rejected() {
969 let error =
970 build_thinking_config(&Parameters::default().reasoning_effort(ReasoningEffort::None))
971 .expect_err("None effort must fail");
972 assert!(error.to_string().contains("no 'none' thinking level"));
973 }
974
975 #[test]
978 fn effort_and_visibility_are_independent() {
979 let effort_only =
980 thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::High));
981 assert_eq!(effort_only["thinkingLevel"], "high");
982 assert_eq!(effort_only.get("includeThoughts"), None);
983
984 let thoughts_only = thinking_json(&Parameters::default().include_reasoning(true));
985 assert_eq!(thoughts_only["includeThoughts"], true);
986 assert_eq!(thoughts_only.get("thinkingLevel"), None);
987
988 assert!(
989 build_thinking_config(&Parameters::default())
990 .expect("no effort requested")
991 .is_none()
992 );
993 }
994
995 #[test]
998 fn generation_config_forwards_every_sampling_parameter() {
999 let params = Parameters::default()
1000 .seed(42)
1001 .presence_penalty(0.5)
1002 .frequency_penalty(0.25)
1003 .logprobs(true)
1004 .top_logprobs(3);
1005 let config = build_generation_config(¶ms, None)
1006 .expect("effort supported by Gemini")
1007 .expect("generation config");
1008 let value = serde_json::to_value(config).expect("serialize generation config");
1009 assert_eq!(value["seed"], 42);
1010 assert_eq!(value["presencePenalty"], 0.5);
1011 assert_eq!(value["frequencyPenalty"], 0.25);
1012 assert_eq!(value["responseLogprobs"], true);
1013 assert_eq!(value["logprobs"], 3);
1014 }
1015}