Skip to main content

aither_gemini/
llm.rs

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            // Fetch actual context window from API, fallback to models database
73            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                    // Fallback to models database
78                    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
114/// Assembles the Gemini request for a provider-agnostic one.
115///
116/// Async because file attachments are uploaded before the request is sent.
117///
118/// # Errors
119///
120/// Returns [`GeminiError`] if the request names an exact tool that was not
121/// supplied, or an attachment cannot be resolved. Cache settings of other
122/// providers are ignored.
123async 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    // wasm32 has no filesystem, so there is nothing to upload and no use for
132    // the config here.
133    #[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 &parameters.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) = &parameters.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    // Google's own tools cannot be combined with a narrowed tool choice: the
164    // model would be free to answer with a tool the caller did not ask about.
165    let builtins_allowed = !matches!(
166        parameters.tool_choice,
167        ToolChoice::None | ToolChoice::Exact(_)
168    );
169    let native = &parameters.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(&parameters, None)?,
190        tools,
191        tool_config: build_tool_config(&parameters, 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
201/// Turns one candidate's content into the events it represents, in wire order:
202/// reasoning, then text, then built-in tool output, then function calls.
203fn 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    // Tool calls are reported, never executed: the consumer decides.
233    events.extend(
234        content
235            .function_call_parts()
236            .into_iter()
237            .map(|(call, signature)| {
238                // Gemini signs each function call rather than the turn, so the
239                // signature rides on the call it belongs to.
240                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        // Use streaming endpoint for true streaming output
269        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                // Chunks without candidates carry metadata, not content — but a
297                // blocked prompt is reported this way and must not pass silently.
298                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
334/// Simple UUID v4 generator for tool call IDs.
335fn 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
393/// Indexes every tool call by id, yielding its function name and, where the
394/// model signed it, its thought signature.
395///
396/// A tool *result* has to look both up: Gemini scopes the signature to the call
397/// rather than the result, so the result cannot carry it.
398fn 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
422/// Parses a tool result as JSON, or wraps it as a string `result`.
423fn 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
431/// Lookups keyed by tool-call id.
432struct ToolCallIndex<'a> {
433    names: HashMap<&'a str, &'a str>,
434    signatures: HashMap<&'a str, &'a str>,
435    /// Position of the call in the conversation, which is the order Gemini
436    /// expects its responses in.
437    positions: HashMap<&'a str, usize>,
438}
439
440/// Emits the pending tool results as one `user` Content in call order.
441///
442/// Gemini requires every `functionResponse` answering a model turn to share a
443/// single Content; results arrive in completion order, so they are sorted by
444/// the position of the call they answer.
445fn 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::Configuration => {
469                return Err(GeminiError::Api(
470                    "Gemini takes no per-message effort update".to_string(),
471                ));
472            }
473            Role::System => system_parts.push(Part::text(message.content())),
474            Role::User => {
475                let attachments = message.attachments();
476                if attachments.is_empty() {
477                    contents.push(GeminiContent::text("user", message.content()));
478                } else {
479                    // Build parts with attachments
480                    let mut parts = Vec::new();
481
482                    // Add attachment parts first
483                    for attachment in attachments {
484                        parts.push(url_to_part(attachment).await?);
485                    }
486
487                    // Add text content
488                    if !message.content().is_empty() {
489                        parts.push(Part::text(message.content()));
490                    }
491
492                    contents.push(GeminiContent::with_parts("user", parts));
493                }
494            }
495            Role::Tool => {
496                // Get the function name from the tool_call_id
497                let tool_call_id = message.tool_call_id().unwrap_or("");
498                let base_id = tool_call_id.to_string();
499                let signature = index
500                    .signatures
501                    .get(tool_call_id)
502                    .map(|value| (*value).to_string());
503
504                if let Some(&function_name) = index.names.get(tool_call_id) {
505                    pending_results.push((
506                        index.positions[tool_call_id],
507                        Part::function_response_with_signature(
508                            function_name.to_string(),
509                            tool_response_value(message.content()),
510                            signature,
511                        ),
512                    ));
513                } else {
514                    // Fallback: try legacy format or send as user message
515                    if let Some((name, response, sig)) = parse_tool_response(message.content()) {
516                        pending_results.push((
517                            usize::MAX,
518                            Part::function_response_with_signature(name, response, sig),
519                        ));
520                    } else {
521                        // Last resort - wrap as text
522                        debug!("Tool message without matching tool_call_id: {}", base_id);
523                        flush_tool_results(&mut contents, &mut pending_results);
524                        contents.push(GeminiContent::text("user", message.content()));
525                    }
526                }
527            }
528            Role::Assistant => {
529                let tool_calls = message.tool_calls();
530                if tool_calls.is_empty() {
531                    // Regular text response
532                    contents.push(GeminiContent::text("model", message.content()));
533                } else {
534                    // Assistant message with function calls
535                    let mut parts = Vec::new();
536
537                    // Add text content if present
538                    if !message.content().is_empty() {
539                        parts.push(Part::text(message.content()));
540                    }
541
542                    // Add function call parts with thought signatures extracted from IDs
543                    for tc in tool_calls {
544                        let signature = tc
545                            .reasoning_state
546                            .as_ref()
547                            .and_then(|state| state.payload_for(PROVIDER_NAME))
548                            .map(String::from);
549                        parts.push(Part::function_call_with_signature(
550                            tc.name.clone(),
551                            tc.arguments.clone(),
552                            signature,
553                        ));
554                    }
555
556                    contents.push(GeminiContent::with_parts("model", parts));
557                }
558            }
559        }
560    }
561
562    flush_tool_results(&mut contents, &mut pending_results);
563
564    let system_instruction = if system_parts.is_empty() {
565        None
566    } else {
567        Some(GeminiContent::system(system_parts))
568    };
569
570    Ok((system_instruction, contents))
571}
572
573fn format_prompt_feedback(feedback: &PromptFeedback) -> String {
574    let reason = feedback
575        .block_reason
576        .as_deref()
577        .unwrap_or("unknown reason")
578        .to_string();
579    if feedback.safety_ratings.is_empty() {
580        return format!("Gemini response blocked: {reason}");
581    }
582
583    let ratings = feedback
584        .safety_ratings
585        .iter()
586        .map(format_safety_rating)
587        .collect::<Vec<_>>()
588        .join(", ");
589    format!("Gemini response blocked: {reason}; safety: {ratings}")
590}
591
592fn format_safety_rating(rating: &SafetyRating) -> String {
593    let status = rating
594        .blocked
595        .map_or("unspecified", |b| if b { "blocked" } else { "allowed" });
596    let probability = rating.probability.as_deref().unwrap_or("unknown");
597    format!("{} ({status}, probability: {probability})", rating.category)
598}
599
600/// # Errors
601///
602/// Returns [`GeminiError`] when the requested reasoning effort has no Gemini
603/// equivalent.
604fn build_generation_config(
605    parameters: &Parameters,
606    modalities: Option<Vec<String>>,
607) -> Result<Option<GenerationConfig>, GeminiError> {
608    let thinking_config = build_thinking_config(parameters)?;
609    let mut config = GenerationConfig {
610        temperature: parameters.temperature,
611        top_p: parameters.top_p,
612        top_k: parameters.top_k,
613        max_output_tokens: parameters.max_tokens.map(
614            #[allow(clippy::cast_possible_wrap)]
615            |value| value as i32,
616        ),
617        stop_sequences: parameters.stop.clone(),
618        response_modalities: modalities,
619        thinking_config,
620        seed: parameters.seed.map(
621            #[allow(clippy::cast_possible_wrap)]
622            |value| value as i32,
623        ),
624        presence_penalty: parameters.presence_penalty,
625        frequency_penalty: parameters.frequency_penalty,
626        response_logprobs: parameters.logprobs,
627        logprobs: parameters.top_logprobs.map(i32::from),
628        ..Default::default()
629    };
630    if let Some(schema) = &parameters.response_format {
631        config.response_mime_type = Some("application/json".into());
632        config.response_json_schema = Some(schema.clone().to_value());
633    } else if parameters.structured_outputs {
634        config.response_mime_type = Some("application/json".into());
635    }
636    if config.is_meaningful() {
637        Ok(Some(config))
638    } else {
639        Ok(None)
640    }
641}
642
643fn build_tool_config(parameters: &Parameters, has_tools: bool) -> Option<ToolConfig> {
644    if !has_tools {
645        return None;
646    }
647    let (mode, allowed) = match &parameters.tool_choice {
648        ToolChoice::None => return None,
649        ToolChoice::Auto => (FunctionCallingMode::Auto, None),
650        ToolChoice::Required => (FunctionCallingMode::Any, None),
651        ToolChoice::Exact(name) => (FunctionCallingMode::Any, Some(vec![name.clone()])),
652    };
653    Some(ToolConfig {
654        function_calling_config: Some(FunctionCallingConfig {
655            mode,
656            allowed_function_names: allowed,
657        }),
658    })
659}
660
661/// Builds `thinkingConfig` from the portable reasoning parameters.
662///
663/// Depth (`reasoning_effort`) and visibility (`include_reasoning`) are separate
664/// controls: asking for a deeper think does not require the thoughts to be
665/// returned, and vice versa. Either one alone is enough to emit the config.
666/// # Errors
667///
668/// Returns [`GeminiError`] when the requested effort has no Gemini equivalent.
669fn build_thinking_config(parameters: &Parameters) -> Result<Option<ThinkingConfig>, GeminiError> {
670    let thinking_level = parameters
671        .reasoning_effort
672        .map(ThinkingLevel::try_from)
673        .transpose()?;
674    if !parameters.include_reasoning && thinking_level.is_none() {
675        return Ok(None);
676    }
677    Ok(Some(ThinkingConfig {
678        include_thoughts: parameters.include_reasoning.then_some(true),
679        thinking_level,
680    }))
681}
682
683fn convert_tool_definitions(defs: Vec<ToolDefinition>) -> Vec<FunctionDeclaration> {
684    defs.into_iter()
685        .map(|tool| FunctionDeclaration {
686            name: tool.name().to_string(),
687            description: tool.description().to_string(),
688            parameters: Some(tool.arguments_openai_schema()),
689        })
690        .collect()
691}
692
693/// Convert a typed attachment to a Gemini content part.
694async fn url_to_part(attachment: &Attachment) -> Result<Part, GeminiError> {
695    let url = attachment.url();
696    let media_type = attachment.media_type().as_ref();
697    match url.scheme() {
698        "data" => parse_data_url(url.as_str(), media_type),
699        "file" => read_file_to_part(url, media_type).await,
700        "http" | "https" => Ok(Part::from_file(media_type, url.as_str())),
701        scheme => Err(GeminiError::Api(format!(
702            "Gemini does not support attachment URL scheme '{scheme}'"
703        ))),
704    }
705}
706
707fn parse_data_url(url: &str, media_type: &str) -> Result<Part, GeminiError> {
708    use base64::Engine;
709
710    let after_data = url
711        .strip_prefix("data:")
712        .ok_or_else(|| GeminiError::Api("Malformed attachment data URL".to_string()))?;
713    let (header, data) = after_data
714        .split_once(',')
715        .ok_or_else(|| GeminiError::Api("Attachment data URL is missing payload".to_string()))?;
716    let encoded_media_type = header.strip_suffix(";base64").ok_or_else(|| {
717        GeminiError::Api("Attachment data URL must use base64 encoding".to_string())
718    })?;
719    if encoded_media_type != media_type {
720        return Err(GeminiError::Api(format!(
721            "Attachment MIME type '{media_type}' does not match data URL MIME type '{encoded_media_type}'"
722        )));
723    }
724    let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
725    Ok(Part::inline_media(media_type, bytes))
726}
727
728#[cfg(not(target_arch = "wasm32"))]
729async fn read_file_to_part(url: &url::Url, media_type: &str) -> Result<Part, GeminiError> {
730    let path = url.to_file_path().map_err(|()| {
731        GeminiError::Api("Attachment file URL could not be converted to a path".to_string())
732    })?;
733    let data = async_fs::read(&path).await.map_err(|error| {
734        GeminiError::Api(format!(
735            "Failed to read attachment '{}': {error}",
736            path.display()
737        ))
738    })?;
739    Ok(Part::inline_media(media_type, data))
740}
741
742#[cfg(target_arch = "wasm32")]
743async fn read_file_to_part(_url: &url::Url, _media_type: &str) -> Result<Part, GeminiError> {
744    Err(GeminiError::Api(
745        "file:// attachments are not supported on wasm32".to_string(),
746    ))
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use aither_core::llm::model::ReasoningEffort;
753
754    #[tokio::test]
755    async fn data_attachments_preserve_declared_media_types() {
756        for media_type in ["image/png", "audio/wav", "video/mp4", "application/pdf"] {
757            let attachment = Attachment::new(
758                format!("data:{media_type};base64,AA==")
759                    .parse()
760                    .expect("data URL"),
761                media_type.parse().expect("MIME type"),
762            );
763            let part = url_to_part(&attachment).await.expect("encode attachment");
764            let value = serde_json::to_value(part).expect("serialize part");
765            assert_eq!(value["inlineData"]["mimeType"], media_type);
766        }
767    }
768
769    #[tokio::test]
770    async fn remote_attachment_uses_file_data_with_media_type() {
771        let attachment = Attachment::new(
772            "https://ai.google.dev/static/gemini-api/docs/files/sample.pdf"
773                .parse()
774                .expect("remote URL"),
775            "application/pdf".parse().expect("PDF MIME"),
776        );
777        let part = url_to_part(&attachment).await.expect("encode attachment");
778        let value = serde_json::to_value(part).expect("serialize part");
779        assert_eq!(value["fileData"]["mimeType"], "application/pdf");
780        assert_eq!(
781            value["fileData"]["fileUri"],
782            "https://ai.google.dev/static/gemini-api/docs/files/sample.pdf"
783        );
784    }
785
786    #[test]
787    fn data_url_mime_mismatch_fails() {
788        let error = parse_data_url("data:image/png;base64,AA==", "audio/wav")
789            .expect_err("MIME mismatch must fail");
790        assert!(error.to_string().contains("does not match"));
791    }
792
793    fn thinking_json(params: &Parameters) -> serde_json::Value {
794        let config = build_thinking_config(params)
795            .expect("effort supported by Gemini")
796            .expect("thinking config");
797        serde_json::to_value(config).expect("serialize thinking config")
798    }
799
800    /// Gemini names the field `thinkingLevel`; there is no `tokenBudget`, so a
801    /// misnamed field is accepted by serde and then ignored on the wire.
802    #[test]
803    fn thinking_config_uses_gemini_field_names() {
804        let value = thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::Medium));
805        assert_eq!(value["thinkingLevel"], "medium");
806        assert_eq!(value.get("tokenBudget"), None);
807        assert_eq!(value.get("thinkingBudget"), None);
808    }
809
810    /// `thinkingLevel` and the legacy `thinkingBudget` are mutually exclusive:
811    /// sending both is a 400, so only the level is ever modelled.
812    #[test]
813    fn thinking_config_never_pairs_a_budget_with_a_level() {
814        for effort in [
815            ReasoningEffort::Minimal,
816            ReasoningEffort::Low,
817            ReasoningEffort::Medium,
818            ReasoningEffort::High,
819        ] {
820            let value = thinking_json(&Parameters::default().reasoning_effort(effort));
821            let object = value.as_object().expect("thinking config object");
822            assert!(
823                object.keys().all(|key| key != "thinkingBudget"),
824                "effort {effort:?} paired a budget with a level"
825            );
826            assert!(object.contains_key("thinkingLevel"));
827        }
828    }
829
830    #[test]
831    fn minimal_effort_maps_to_the_minimal_level() {
832        let value =
833            thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::Minimal));
834        assert_eq!(value["thinkingLevel"], "minimal");
835    }
836
837    /// Gemini's ladder stops at High. Clamping `XHigh` or `Max` down to High would
838    /// hand back less reasoning than the caller asked for, silently.
839    #[test]
840    fn efforts_above_gemini_ceiling_are_rejected() {
841        for effort in [ReasoningEffort::XHigh, ReasoningEffort::Max] {
842            let error = build_thinking_config(&Parameters::default().reasoning_effort(effort))
843                .expect_err("effort above the Gemini ceiling must fail");
844            assert!(
845                error.to_string().contains("tops out at High"),
846                "unexpected error for {effort:?}: {error}"
847            );
848        }
849    }
850
851    /// The signature used to be base64'd into the tool-call id behind a `|ts|`
852    /// separator, because core had nowhere to put it. It now rides on the call,
853    /// and the id is a plain identifier again.
854    #[tokio::test]
855    async fn thought_signature_round_trips_without_mangling_the_id() {
856        let call = aither_core::llm::ToolCall::new("gemini_abc", "lookup", serde_json::json!({}))
857            .with_reasoning_state(ReasoningState::new(PROVIDER_NAME, "sig-xyz"));
858        let message = Message::assistant_with_tool_calls("", vec![call]);
859
860        let (_, contents) = messages_to_gemini(&[message]).await.expect("convert");
861        let value = serde_json::to_value(&contents).expect("serialize contents");
862        let part = &value[0]["parts"][0];
863
864        assert_eq!(part["thoughtSignature"], "sig-xyz");
865        assert_eq!(part["functionCall"]["name"], "lookup");
866    }
867
868    #[tokio::test]
869    async fn generated_tool_call_ids_carry_no_smuggled_signature() {
870        assert!(!tool_call_id().contains("|ts|"));
871    }
872
873    /// Signatures are Gemini's; another provider's state must not be replayed.
874    #[tokio::test]
875    async fn foreign_reasoning_state_is_not_sent_as_a_signature() {
876        let call = aither_core::llm::ToolCall::new("gemini_abc", "lookup", serde_json::json!({}))
877            .with_reasoning_state(ReasoningState::new("anthropic", "sig-from-claude"));
878        let message = Message::assistant_with_tool_calls("", vec![call]);
879
880        let (_, contents) = messages_to_gemini(&[message]).await.expect("convert");
881        let value = serde_json::to_value(&contents).expect("serialize contents");
882        assert_eq!(value[0]["parts"][0].get("thoughtSignature"), None);
883    }
884
885    fn call(id: &str, name: &str) -> aither_core::llm::ToolCall {
886        aither_core::llm::ToolCall::new(id, name, serde_json::json!({}))
887    }
888
889    fn responses(content: &serde_json::Value) -> Vec<(String, String)> {
890        content["parts"]
891            .as_array()
892            .expect("parts")
893            .iter()
894            .map(|part| {
895                (
896                    part["functionResponse"]["name"]
897                        .as_str()
898                        .expect("function name")
899                        .to_string(),
900                    part["functionResponse"]["response"]["result"]
901                        .as_str()
902                        .expect("result")
903                        .to_string(),
904                )
905            })
906            .collect()
907    }
908
909    fn pairs(items: [(&str, &str); 2]) -> Vec<(String, String)> {
910        items
911            .into_iter()
912            .map(|(a, b)| (a.to_string(), b.to_string()))
913            .collect()
914    }
915
916    #[tokio::test]
917    async fn parallel_tool_results_share_one_content() {
918        let messages = [
919            Message::assistant_with_tool_calls("", vec![call("a", "weather"), call("b", "time")]),
920            Message::tool("a", "sunny"),
921            Message::tool("b", "noon"),
922        ];
923        let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
924        let value = serde_json::to_value(&contents).expect("serialize");
925        assert_eq!(value.as_array().expect("contents").len(), 2);
926        assert_eq!(value[1]["role"], "user");
927        assert_eq!(
928            responses(&value[1]),
929            pairs([("weather", "sunny"), ("time", "noon")])
930        );
931    }
932
933    #[tokio::test]
934    async fn same_function_results_stay_distinct_and_ordered() {
935        let messages = [
936            Message::assistant_with_tool_calls("", vec![call("a", "lookup"), call("b", "lookup")]),
937            Message::tool("a", "first"),
938            Message::tool("b", "second"),
939        ];
940        let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
941        let value = serde_json::to_value(&contents).expect("serialize");
942        assert_eq!(value.as_array().expect("contents").len(), 2);
943        assert_eq!(
944            responses(&value[1]),
945            pairs([("lookup", "first"), ("lookup", "second")])
946        );
947    }
948
949    #[tokio::test]
950    async fn out_of_order_results_follow_call_order_and_keep_signatures() {
951        let signed =
952            call("b", "time").with_reasoning_state(ReasoningState::new(PROVIDER_NAME, "sig-b"));
953        let messages = [
954            Message::assistant_with_tool_calls("", vec![call("a", "weather"), signed]),
955            Message::tool("b", "noon"),
956            Message::tool("a", "sunny"),
957            Message::user("thanks"),
958        ];
959        let (_, contents) = messages_to_gemini(&messages).await.expect("convert");
960        let value = serde_json::to_value(&contents).expect("serialize");
961        assert_eq!(value.as_array().expect("contents").len(), 3);
962        assert_eq!(
963            responses(&value[1]),
964            pairs([("weather", "sunny"), ("time", "noon")])
965        );
966        assert_eq!(value[1]["parts"][0].get("thoughtSignature"), None);
967        assert_eq!(value[1]["parts"][1]["thoughtSignature"], "sig-b");
968        assert_eq!(value[2]["parts"][0]["text"], "thanks");
969    }
970
971    /// Gemini has no "no thinking" level; the caller should omit effort instead.
972    #[test]
973    fn none_effort_is_rejected() {
974        let error =
975            build_thinking_config(&Parameters::default().reasoning_effort(ReasoningEffort::None))
976                .expect_err("None effort must fail");
977        assert!(error.to_string().contains("no 'none' thinking level"));
978    }
979
980    /// Depth and visibility are independent: asking for effort alone must still
981    /// produce a config, and asking for thoughts alone must still produce one.
982    #[test]
983    fn effort_and_visibility_are_independent() {
984        let effort_only =
985            thinking_json(&Parameters::default().reasoning_effort(ReasoningEffort::High));
986        assert_eq!(effort_only["thinkingLevel"], "high");
987        assert_eq!(effort_only.get("includeThoughts"), None);
988
989        let thoughts_only = thinking_json(&Parameters::default().include_reasoning(true));
990        assert_eq!(thoughts_only["includeThoughts"], true);
991        assert_eq!(thoughts_only.get("thinkingLevel"), None);
992
993        assert!(
994            build_thinking_config(&Parameters::default())
995                .expect("no effort requested")
996                .is_none()
997        );
998    }
999
1000    /// These reached the wire only if `build_generation_config` populates them;
1001    /// they were declared on the struct but never filled.
1002    #[test]
1003    fn generation_config_forwards_every_sampling_parameter() {
1004        let params = Parameters::default()
1005            .seed(42)
1006            .presence_penalty(0.5)
1007            .frequency_penalty(0.25)
1008            .logprobs(true)
1009            .top_logprobs(3);
1010        let config = build_generation_config(&params, None)
1011            .expect("effort supported by Gemini")
1012            .expect("generation config");
1013        let value = serde_json::to_value(config).expect("serialize generation config");
1014        assert_eq!(value["seed"], 42);
1015        assert_eq!(value["presencePenalty"], 0.5);
1016        assert_eq!(value["frequencyPenalty"], 0.25);
1017        assert_eq!(value["responseLogprobs"], true);
1018        assert_eq!(value["logprobs"], 3);
1019    }
1020}