Skip to main content

lc_providers/providers/gemini/
mod.rs

1// lc-providers/src/providers/gemini/mod.rs
2//! Google Gemini API implementation (native API format).
3//!
4//! Implements calling Google's native Gemini API, supporting:
5//! - text chat (generateContent)
6//! - streaming (streamGenerateContent)
7//! - function calling
8//! - token usage statistics
9
10mod error;
11#[cfg(test)]
12mod tests;
13mod types;
14
15pub use error::GeminiError;
16
17use async_trait::async_trait;
18use futures_util::{Stream, StreamExt};
19use schemars::JsonSchema;
20use serde::de::DeserializeOwned;
21use serde_json::json;
22use std::env;
23use std::marker::PhantomData;
24use std::pin::Pin;
25use std::sync::{Arc, Mutex};
26
27use self::types::*;
28use crate::openai::sse::SseByteFramer;
29use crate::ProviderError;
30use lc_callbacks::RunType;
31use lc_core::language_models::{
32    BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk, TokenUsage,
33};
34use lc_core::runnables::{run_tree_from_config, Runnable};
35use lc_core::tools::{StructuredOutput, ToolDefinition};
36use lc_core::RunnableConfig;
37use lc_schema::{Message, MessageType};
38
39/// Gemini API base endpoint
40pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
41
42/// Gemini model list (B5, v0.22.4 — Gemini 3 / 2.5 generation; non-exhaustive).
43pub const GEMINI_MODELS: [&str; 6] = [
44    "gemini-3-pro",          // Gemini 3 Pro (2026 flagship)
45    "gemini-3-flash",        // Gemini 3 Flash
46    "gemini-2.5-pro",        // Gemini 2.5 Pro (strong reasoning)
47    "gemini-2.5-flash",      // Gemini 2.5 Flash (fast and balanced)
48    "gemini-2.5-flash-lite", // Gemini 2.5 Flash Lite (lightweight)
49    "gemini-2.0-flash",      // Gemini 2.0 Flash (stable previous gen)
50];
51
52/// Gemini config
53#[derive(Clone)]
54pub struct GeminiConfig {
55    /// Gemini API key.
56    pub api_key: String,
57    /// Base URL of the Gemini API endpoint.
58    pub base_url: String,
59    /// Model name to use.
60    pub model: String,
61    /// Sampling temperature.
62    pub temperature: Option<f32>,
63    /// Maximum number of output tokens.
64    pub max_output_tokens: Option<usize>,
65    /// Nucleus sampling probability mass.
66    pub top_p: Option<f32>,
67    /// Number of top tokens to consider for sampling.
68    pub top_k: Option<i32>,
69    /// Tool definitions for function calling (Gemini functionDeclarations).
70    pub tools: Option<Vec<ToolDefinition>>,
71    /// Tool choice mode: "auto" (AUTO), "none" (NONE), or "any" (ANY).
72    pub tool_choice: Option<String>,
73}
74
75impl std::fmt::Debug for GeminiConfig {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        // A13: redact the API key so `{:?}` never leaks the secret.
78        f.debug_struct("GeminiConfig")
79            .field("api_key", &"***")
80            .field("base_url", &self.base_url)
81            .field("model", &self.model)
82            .field("temperature", &self.temperature)
83            .field("max_output_tokens", &self.max_output_tokens)
84            .field("top_p", &self.top_p)
85            .field("top_k", &self.top_k)
86            .field("tools", &self.tools)
87            .field("tool_choice", &self.tool_choice)
88            .finish()
89    }
90}
91
92impl Default for GeminiConfig {
93    fn default() -> Self {
94        Self {
95            api_key: String::new(),
96            base_url: GEMINI_BASE_URL.to_string(),
97            model: "gemini-1.5-flash".to_string(),
98            temperature: None,
99            max_output_tokens: None,
100            top_p: None,
101            top_k: None,
102            tools: None,
103            tool_choice: None,
104        }
105    }
106}
107
108impl GeminiConfig {
109    /// Creates a new GeminiConfig with the given API key.
110    pub fn new(api_key: impl Into<String>) -> Self {
111        Self {
112            api_key: api_key.into(),
113            ..Default::default()
114        }
115    }
116
117    /// Creates a GeminiConfig from environment variables, returning a Result.
118    ///
119    /// Environment variables:
120    /// - `GEMINI_API_KEY` or `GOOGLE_API_KEY`: API key (required)
121    /// - `GEMINI_BASE_URL`: API endpoint (optional)
122    /// - `GEMINI_MODEL`: Model name (optional)
123    pub fn from_env_result() -> Result<Self, ProviderError> {
124        let api_key = env::var("GEMINI_API_KEY")
125            .or_else(|_| env::var("GOOGLE_API_KEY"))
126            .map_err(|_| {
127                ProviderError::Config(
128                    "GEMINI_API_KEY or GOOGLE_API_KEY environment variable not set".to_string(),
129                )
130            })?;
131
132        let base_url = env::var("GEMINI_BASE_URL").unwrap_or_else(|_| GEMINI_BASE_URL.to_string());
133
134        let model = env::var("GEMINI_MODEL").unwrap_or_else(|_| "gemini-1.5-flash".to_string());
135
136        Ok(Self {
137            api_key,
138            base_url,
139            model,
140            ..Default::default()
141        })
142    }
143
144    /// Sets the model name.
145    pub fn with_model(mut self, model: impl Into<String>) -> Self {
146        self.model = model.into();
147        self
148    }
149
150    /// Sets a custom API base URL.
151    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
152        self.base_url = url.into();
153        self
154    }
155
156    /// Sets the sampling temperature.
157    pub fn with_temperature(mut self, temp: f32) -> Self {
158        self.temperature = Some(temp);
159        self
160    }
161
162    /// Sets the maximum number of output tokens.
163    pub fn with_max_output_tokens(mut self, max: usize) -> Self {
164        self.max_output_tokens = Some(max);
165        self
166    }
167
168    /// L5 fix: alias for with_max_output_tokens for cross-provider consistency.
169    pub fn with_max_tokens(self, max: usize) -> Self {
170        self.with_max_output_tokens(max)
171    }
172}
173
174/// Gemini chat client
175#[derive(Clone, Debug)]
176pub struct GeminiChat {
177    config: GeminiConfig,
178    client: reqwest::Client,
179}
180
181impl GeminiChat {
182    /// Creates a new Gemini chat client with the given configuration.
183    pub fn new(config: GeminiConfig) -> Self {
184        Self {
185            config,
186            // 0.22.0 audit fix (H-P1): shared client with a connect timeout.
187            client: crate::retry::default_client(),
188        }
189    }
190
191    /// Creates a Gemini chat client from environment variables.
192    pub fn from_env() -> Result<Self, ProviderError> {
193        Self::from_env_result()
194    }
195
196    /// Creates a GeminiChat from environment variables, returning a Result.
197    #[allow(deprecated)]
198    pub fn from_env_result() -> Result<Self, ProviderError> {
199        Ok(Self::new(GeminiConfig::from_env_result()?))
200    }
201
202    /// Binds tool definitions for Gemini function calling.
203    ///
204    /// Gemini uses `functionDeclarations` inside a `tools` array in the
205    /// request body. The conversion from `ToolDefinition` is handled
206    /// automatically.
207    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
208        let config = GeminiConfig {
209            tools: Some(tools),
210            ..self.config.clone()
211        };
212        Self {
213            config,
214            client: self.client.clone(),
215        }
216    }
217
218    /// Sets the tool choice strategy.
219    ///
220    /// Accepts "auto" (AUTO), "none" (NONE), or "any" (ANY).
221    pub fn with_tool_choice(mut self, choice: impl Into<String>) -> Self {
222        self.config.tool_choice = Some(choice.into());
223        self
224    }
225
226    /// Enables structured JSON output with schema validation.
227    ///
228    /// Uses Gemini's function calling under the hood: a single tool named
229    /// "structured_output" is bound, and the model is forced to call it.
230    pub fn with_structured_output<T: DeserializeOwned + JsonSchema>(
231        &self,
232    ) -> GeminiStructuredOutputMethod<T> {
233        use schemars::schema_for;
234        let schema = serde_json::to_value(schema_for!(T))
235            .unwrap_or_else(|_| serde_json::json!({"type": "object", "properties": {}}));
236
237        let tool = ToolDefinition::new("structured_output", "Return structured JSON output")
238            .with_parameters(schema);
239
240        let config = GeminiConfig {
241            tools: Some(vec![tool]),
242            tool_choice: Some("auto".to_string()),
243            ..self.config.clone()
244        };
245
246        GeminiStructuredOutputMethod {
247            config,
248            client: self.client.clone(),
249            _phantom: PhantomData,
250        }
251    }
252
253    /// Builds the contents array for the Gemini API
254    fn build_contents(&self, messages: Vec<Message>) -> (Vec<GeminiContent>, Option<String>) {
255        let mut contents = Vec::new();
256        let mut system_prompt: Option<String> = None;
257
258        for msg in messages {
259            match msg.message_type {
260                MessageType::System => {
261                    // M9 fix: concatenate system messages instead of overwriting
262                    system_prompt = Some(match system_prompt {
263                        Some(prev) => format!("{}\n{}", prev, msg.content),
264                        None => msg.content,
265                    });
266                }
267                MessageType::Human => {
268                    // B7: text part first, then one media part per attached medium.
269                    // The async entries resolve every reference (fetch-to-inline
270                    // for http(s), pass-through for gs://) before this runs.
271                    let mut parts: Vec<GeminiPart> = Vec::new();
272                    if !msg.content.is_empty() {
273                        parts.push(GeminiPart {
274                            text: Some(msg.content.clone()),
275                            function_call: None,
276                            function_response: None,
277                            inline_data: None,
278                            file_data: None,
279                        });
280                    }
281                    for media in msg.media_parts() {
282                        if let Some(media_part) = Self::media_to_part(&media) {
283                            parts.push(media_part);
284                        }
285                    }
286                    if parts.is_empty() {
287                        // Preserve prior behaviour: a bare user turn carries its
288                        // text (even if empty) as a single text part.
289                        parts.push(GeminiPart {
290                            text: Some(msg.content),
291                            function_call: None,
292                            function_response: None,
293                            inline_data: None,
294                            file_data: None,
295                        });
296                    }
297                    contents.push(GeminiContent {
298                        role: Some("user".to_string()),
299                        parts,
300                    });
301                }
302                MessageType::AI => {
303                    contents.push(GeminiContent {
304                        role: Some("model".to_string()),
305                        parts: vec![GeminiPart {
306                            text: Some(msg.content),
307                            function_call: None,
308                            function_response: None,
309                            inline_data: None,
310                            file_data: None,
311                        }],
312                    });
313                }
314                MessageType::Tool { ref tool_call_id } => {
315                    // Gemini uses functionResponse format for tool results. The
316                    // outgoing ToolCall id is built as `call_{name}` (see
317                    // parse_response); recover the bare function name here.
318                    // Previously `split('_').next()` always returned the literal
319                    // "call", so the functionResponse.name never matched a real
320                    // declaration and any multi-round tool dialog broke from the
321                    // second round (0.22.0 audit H-P7).
322                    let function_name = tool_call_id.strip_prefix("call_").unwrap_or(tool_call_id);
323                    contents.push(GeminiContent {
324                        role: Some("function".to_string()),
325                        parts: vec![GeminiPart {
326                            text: None,
327                            function_call: None,
328                            function_response: Some(GeminiFunctionResponse {
329                                name: function_name.to_string(),
330                                response: json!({"result": msg.content}),
331                            }),
332                            inline_data: None,
333                            file_data: None,
334                        }],
335                    });
336                }
337            }
338        }
339
340        (contents, system_prompt)
341    }
342
343    /// B7: maps one unified [`lc_schema::MediaPart`] to a Gemini media part.
344    ///
345    /// Data URIs become `inline_data` (raw base64 + MIME); `gs://` references
346    /// become `file_data`. The async entries run the Gemini media policy via
347    /// `media::resolve_message_media` first — http(s) media is fetched
348    /// SSRF-safely into data URIs and MIME types are validated — so anything
349    /// that is neither a data URI nor a `gs://` reference is skipped
350    /// defensively rather than sent to the API.
351    fn media_to_part(part: &lc_schema::MediaPart<'_>) -> Option<GeminiPart> {
352        let url = part.url();
353
354        let (inline_data, file_data) = if let Some(gs_path) = url.strip_prefix("gs://") {
355            let mime = part
356                .mime_type()
357                .or_else(|| crate::media::mime_from_extension(url))?
358                .to_string();
359            (
360                None,
361                Some(GeminiFileData {
362                    file_uri: format!("gs://{gs_path}"),
363                    mime_type: mime,
364                }),
365            )
366        } else if let Some((uri_mime, data)) = crate::media::data_uri_parts(url) {
367            // An explicitly declared file MIME wins over the data-URI header.
368            let mime = part.mime_type().unwrap_or(uri_mime).to_string();
369            (
370                Some(GeminiInlineData {
371                    mime_type: mime,
372                    data: data.to_string(),
373                }),
374                None,
375            )
376        } else {
377            return None;
378        };
379
380        Some(GeminiPart {
381            text: None,
382            function_call: None,
383            function_response: None,
384            inline_data,
385            file_data,
386        })
387    }
388
389    /// Builds the API request body
390    fn build_request(&self, messages: Vec<Message>) -> GeminiRequest {
391        let (contents, system_text) = self.build_contents(messages);
392
393        let system_instruction = system_text.map(|text| GeminiSystemInstruction {
394            parts: vec![GeminiPart {
395                text: Some(text),
396                function_call: None,
397                function_response: None,
398                inline_data: None,
399                file_data: None,
400            }],
401        });
402
403        let generation_config = {
404            let has_config = self.config.temperature.is_some()
405                || self.config.max_output_tokens.is_some()
406                || self.config.top_p.is_some()
407                || self.config.top_k.is_some();
408
409            if has_config {
410                Some(GeminiGenerationConfig {
411                    temperature: self.config.temperature,
412                    max_output_tokens: self.config.max_output_tokens,
413                    top_p: self.config.top_p,
414                    top_k: self.config.top_k,
415                })
416            } else {
417                None
418            }
419        };
420
421        GeminiRequest {
422            contents,
423            system_instruction,
424            generation_config,
425            // H7: Convert ToolDefinition to Gemini functionDeclarations
426            tools: self.config.tools.as_ref().map(|tools| {
427                vec![GeminiToolDeclaration {
428                    function_declarations: tools
429                        .iter()
430                        .map(|td| GeminiFunctionDeclaration {
431                            name: td.function.name.clone(),
432                            description: td.function.description.clone(),
433                            parameters: td.function.parameters.clone(),
434                        })
435                        .collect(),
436                }]
437            }),
438            // H7: Convert tool_choice to Gemini function_calling_config
439            tool_config: self.config.tool_choice.as_ref().map(|choice| {
440                let mode = match choice.as_str() {
441                    "none" => "NONE",
442                    "any" => "ANY",
443                    _ => "AUTO",
444                };
445                GeminiToolConfig {
446                    function_calling_config: GeminiFunctionCallingConfig {
447                        mode: mode.to_string(),
448                    },
449                }
450            }),
451        }
452    }
453
454    /// Parses a Gemini API response into an LLMResult
455    fn parse_response(
456        &self,
457        response: GeminiResponse,
458        model: &str,
459    ) -> Result<LLMResult, GeminiError> {
460        // Check the safety feedback
461        if let Some(feedback) = &response.prompt_feedback {
462            if let Some(block_reason) = feedback.get("blockReason").and_then(|v| v.as_str()) {
463                return Err(GeminiError::SafetyBlock(block_reason.to_string()));
464            }
465        }
466
467        let candidates = response.candidates.ok_or(GeminiError::NoResponse)?;
468        let candidate = candidates
469            .into_iter()
470            .next()
471            .ok_or(GeminiError::NoResponse)?;
472
473        let content = candidate.content.ok_or(GeminiError::NoResponse)?;
474
475        let mut text_parts = String::new();
476        let mut tool_calls: Vec<lc_core::tools::ToolCall> = Vec::new();
477
478        for part in content.parts {
479            if let Some(text) = part.text {
480                text_parts.push_str(&text);
481            }
482            // H7: Parse functionCall parts into ToolCall
483            if let Some(fc) = part.function_call {
484                let args_str = fc.args.unwrap_or(serde_json::json!({})).to_string();
485                tool_calls.push(
486                    lc_core::tools::ToolCall::builder(format!("call_{}", fc.name))
487                        .name(fc.name)
488                        .arguments(args_str)
489                        .build(),
490                );
491            }
492        }
493
494        let token_usage = response.usage_metadata.map(|u| TokenUsage {
495            prompt_tokens: u.prompt_token_count.unwrap_or(0) as usize,
496            completion_tokens: u.candidates_token_count.unwrap_or(0) as usize,
497            total_tokens: u.total_token_count.unwrap_or(0) as usize,
498        });
499
500        Ok(LLMResult {
501            content: text_parts,
502            model: model.to_string(),
503            token_usage,
504            tool_calls: if tool_calls.is_empty() {
505                None
506            } else {
507                Some(tool_calls)
508            },
509            thinking_content: None,
510        })
511    }
512
513    /// Internal call: sends the request to the Gemini API
514    async fn chat_internal(&self, messages: Vec<Message>) -> Result<LLMResult, GeminiError> {
515        let url = format!(
516            "{}/models/{}:generateContent",
517            self.config.base_url, self.config.model
518        );
519
520        // B7: inline http(s) media through the SSRF guard, allow gs:// through,
521        // and reject unsupported schemes/types before building the request.
522        let mut messages = messages;
523        crate::media::resolve_message_media(&mut messages, crate::media::MediaPolicy::Gemini)
524            .await
525            .map_err(|e| GeminiError::ApiError(e.to_string()))?;
526        let request_body = self.build_request(messages);
527
528        // 0.22.0 audit fix (H-P2): retry transient failures (429/5xx/network).
529        // A14: non-idempotent POST — see retry::TransportRetryMode; use
530        // retry::SAFE_RETRY to forbid replaying a possibly-dispatched request.
531        let response = crate::retry::send_with_retry(
532            || {
533                self.client
534                    .post(&url)
535                    .header("x-goog-api-key", &self.config.api_key)
536                    .header("Content-Type", "application/json")
537                    .json(&request_body)
538            },
539            &crate::retry::DEFAULT_RETRY,
540        )
541        .await
542        .map_err(|e| GeminiError::HttpError(e.to_string()))?;
543
544        let status = response.status();
545        let body = response
546            .text()
547            .await
548            .map_err(|e| GeminiError::HttpError(e.to_string()))?;
549
550        if !status.is_success() {
551            // 0.22.0 C6 fix: char-boundary truncation (byte slicing panicked on
552            // non-ASCII error bodies).
553            let preview: String = body.chars().take(500).collect();
554            return Err(GeminiError::ApiError(format!(
555                "HTTP {}: {}",
556                status.as_u16(),
557                preview
558            )));
559        }
560
561        let gemini_response: GeminiResponse = serde_json::from_str(&body).map_err(|e| {
562            // 0.22.0 C6 fix: char-boundary truncation.
563            let preview: String = body.chars().take(200).collect();
564            GeminiError::ParseError(format!("{} - body: {}", e, preview))
565        })?;
566
567        self.parse_response(gemini_response, &self.config.model)
568    }
569
570    /// Streaming call
571    async fn stream_chat_internal(
572        &self,
573        messages: Vec<Message>,
574    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, GeminiError>> + Send>>, GeminiError>
575    {
576        let url = format!(
577            "{}/models/{}:streamGenerateContent?alt=event-stream",
578            self.config.base_url, self.config.model
579        );
580
581        // B7: same media resolution as the non-streaming path.
582        let mut messages = messages;
583        crate::media::resolve_message_media(&mut messages, crate::media::MediaPolicy::Gemini)
584            .await
585            .map_err(|e| GeminiError::ApiError(e.to_string()))?;
586        let request_body = self.build_request(messages);
587
588        let response = self
589            .client
590            .post(&url)
591            .header("x-goog-api-key", &self.config.api_key)
592            .header("Content-Type", "application/json")
593            .json(&request_body)
594            .send()
595            .await
596            .map_err(|e| GeminiError::HttpError(e.to_string()))?;
597
598        let status = response.status();
599        if !status.is_success() {
600            let body = response.text().await.unwrap_or_default();
601            return Err(GeminiError::ApiError(format!(
602                "HTTP {}: {}",
603                status.as_u16(),
604                body
605            )));
606        }
607
608        let byte_stream = response.bytes_stream();
609        // 0.22.0 C1: byte-level framer — complete events are decoded to UTF-8,
610        // so CJK characters split across TCP chunks are never lossy-torn.
611        let sse_buffer = Arc::new(Mutex::new(SseByteFramer::new()));
612        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamChunk, GeminiError>>(64);
613
614        let buffer_clone = sse_buffer.clone();
615        tokio::spawn(async move {
616            use futures_util::StreamExt;
617
618            let mut byte_stream = byte_stream;
619            while let Some(chunk_result) = byte_stream.next().await {
620                if let Ok(bytes) = chunk_result {
621                    // Extract complete events from the byte-level framer
622                    let events = {
623                        let mut buffer_guard =
624                            buffer_clone.lock().unwrap_or_else(|e| e.into_inner());
625                        buffer_guard.push(&bytes)
626                    };
627                    // buffer_guard is dropped here, before any await
628
629                    for event_text in events {
630                        for line in event_text.lines() {
631                            let line = line.trim();
632                            if !line.starts_with("data:") {
633                                continue;
634                            }
635
636                            // Tolerate both "data: {...}" and "data:{...}"
637                            let data = line.trim_start_matches("data:").trim();
638                            if data == "[DONE]" {
639                                continue;
640                            }
641
642                            match serde_json::from_str::<GeminiResponse>(data) {
643                                Ok(resp) => {
644                                    if let Some(candidates) = resp.candidates {
645                                        for candidate in candidates {
646                                            if let Some(content) = candidate.content {
647                                                for part in content.parts {
648                                                    if let Some(text) = part.text {
649                                                        if tx
650                                                            .send(Ok(StreamChunk::new(text)))
651                                                            .await
652                                                            .is_err()
653                                                        {
654                                                            return;
655                                                        }
656                                                    }
657                                                }
658                                            }
659                                        }
660                                    }
661                                    // Gemini carries usageMetadata on the last chunk; if present,
662                                    // emit a usage chunk so the streaming path gets the whole call's usage.
663                                    if let Some(usage) = resp.usage_metadata {
664                                        let token_usage = TokenUsage {
665                                            prompt_tokens: usage.prompt_token_count.unwrap_or(0)
666                                                as usize,
667                                            completion_tokens: usage
668                                                .candidates_token_count
669                                                .unwrap_or(0)
670                                                as usize,
671                                            total_tokens: usage.total_token_count.unwrap_or(0)
672                                                as usize,
673                                        };
674                                        let usage_chunk = StreamChunk {
675                                            text: String::new(),
676                                            token_usage: Some(token_usage),
677                                            tool_calls: None,
678                                        };
679                                        if tx.send(Ok(usage_chunk)).await.is_err() {
680                                            return;
681                                        }
682                                    }
683                                }
684                                Err(e) => {
685                                    // 0.22.0 (audit Medium): a bad datum no longer ends the
686                                    // stream silently — log and skip (transport errors are
687                                    // still surfaced below).
688                                    log::error!(
689                                        "Failed to parse Gemini streaming SSE event (skipping this token): {e}; data: {}",
690                                        &data[..data.len().min(200)]
691                                    );
692                                }
693                            }
694                        }
695                    }
696                } else if let Err(e) = chunk_result {
697                    // 0.22.0 (audit Medium): transport errors mid-stream no longer
698                    // end the stream silently.
699                    let _ = tx.send(Err(GeminiError::HttpError(e.to_string()))).await;
700                    return;
701                }
702            }
703        });
704
705        let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
706        Ok(Box::pin(stream))
707    }
708}
709
710#[async_trait]
711impl Runnable<Vec<Message>, LLMResult> for GeminiChat {
712    type Error = GeminiError;
713
714    async fn invoke(
715        &self,
716        input: Vec<Message>,
717        config: Option<RunnableConfig>,
718    ) -> Result<LLMResult, Self::Error> {
719        self.chat(input, config).await
720    }
721
722    async fn stream(
723        &self,
724        input: Vec<Message>,
725        config: Option<RunnableConfig>,
726    ) -> Result<Pin<Box<dyn Stream<Item = Result<LLMResult, Self::Error>> + Send>>, Self::Error>
727    {
728        let model = self.config.model.clone();
729        let (temp, max) = crate::sampling::sampling_overrides(&config);
730        let mut effective = self.clone();
731        if let Some(t) = temp {
732            effective.config.temperature = Some(t);
733        }
734        if let Some(m) = max {
735            effective.config.max_output_tokens = Some(m);
736        }
737        let token_stream = effective.stream_chat_internal(input).await?;
738
739        // C1 fix: true streaming — emit one LLMResult per token,
740        // matching OpenAI/Ollama/Anthropic behavior.
741        let stream = token_stream.map(move |token_result| match token_result {
742            Ok(chunk) => Ok(LLMResult {
743                content: chunk.text,
744                model: model.clone(),
745                token_usage: chunk.token_usage,
746                tool_calls: None,
747                thinking_content: None,
748            }),
749            Err(e) => Err(e),
750        });
751
752        Ok(Box::pin(stream))
753    }
754}
755
756#[async_trait]
757impl BaseLanguageModel<Vec<Message>, LLMResult> for GeminiChat {
758    fn model_name(&self) -> &str {
759        &self.config.model
760    }
761
762    fn get_num_tokens(&self, text: &str) -> usize {
763        lc_core::token_counter::count_tokens(text).unwrap_or_else(|e| {
764            // If the encoder fails to load, overestimate by byte length (better slightly high than silently counting 0, which would mislead routing/truncation)
765            log::warn!("Token counting failed, falling back to byte-length estimation: {e}");
766            text.len()
767        })
768    }
769
770    fn temperature(&self) -> Option<f32> {
771        self.config.temperature
772    }
773
774    fn max_tokens(&self) -> Option<usize> {
775        self.config.max_output_tokens
776    }
777
778    fn with_temperature(mut self, temp: f32) -> Self {
779        self.config.temperature = Some(temp);
780        self
781    }
782
783    fn with_max_tokens(mut self, max: usize) -> Self {
784        self.config.max_output_tokens = Some(max);
785        self
786    }
787}
788
789#[async_trait]
790impl BaseChatModel for GeminiChat {
791    async fn chat(
792        &self,
793        messages: Vec<Message>,
794        config: Option<RunnableConfig>,
795    ) -> Result<LLMResult, Self::Error> {
796        let run_name = config
797            .as_ref()
798            .and_then(|c| c.run_name.clone())
799            .unwrap_or_else(|| format!("{}:chat", self.config.model));
800
801        let mut run = run_tree_from_config(
802            run_name,
803            RunType::Llm,
804            json!({
805                "messages": messages.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
806                "model": self.config.model,
807            }),
808            config.as_ref(),
809        );
810
811        if let Some(ref cfg) = config {
812            if let Some(ref callbacks) = cfg.callbacks {
813                for handler in callbacks.handlers() {
814                    handler.on_llm_start(&run, &messages).await;
815                }
816            }
817        }
818
819        let (temp, max) = crate::sampling::sampling_overrides(&config);
820        let mut effective = self.clone();
821        if let Some(t) = temp {
822            effective.config.temperature = Some(t);
823        }
824        if let Some(m) = max {
825            effective.config.max_output_tokens = Some(m);
826        }
827        let result = effective.chat_internal(messages.clone()).await;
828
829        match result {
830            Ok(response) => {
831                run.end(json!({
832                    "content": &response.content,
833                    "model": &response.model,
834                    "token_usage": &response.token_usage,
835                }));
836
837                if let Some(ref cfg) = config {
838                    if let Some(ref callbacks) = cfg.callbacks {
839                        for handler in callbacks.handlers() {
840                            handler.on_llm_end(&run, &response.content).await;
841                        }
842                    }
843                }
844
845                Ok(response)
846            }
847            Err(e) => {
848                run.end_with_error(e.to_string());
849
850                if let Some(ref cfg) = config {
851                    if let Some(ref callbacks) = cfg.callbacks {
852                        for handler in callbacks.handlers() {
853                            handler.on_llm_error(&run, &e.to_string()).await;
854                        }
855                    }
856                }
857
858                Err(e)
859            }
860        }
861    }
862
863    async fn stream_chat(
864        &self,
865        messages: Vec<Message>,
866        config: Option<RunnableConfig>,
867    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
868    {
869        let run_name = config
870            .as_ref()
871            .and_then(|c| c.run_name.clone())
872            .unwrap_or_else(|| format!("{}:stream", self.config.model));
873
874        let run = run_tree_from_config(
875            run_name,
876            RunType::Llm,
877            json!({
878                "messages": messages.len(),
879                "model": self.config.model,
880            }),
881            config.as_ref(),
882        );
883
884        if let Some(ref cfg) = config {
885            if let Some(ref callbacks) = cfg.callbacks {
886                for handler in callbacks.handlers() {
887                    handler.on_llm_start(&run, &messages).await;
888                }
889            }
890        }
891
892        let (temp, max) = crate::sampling::sampling_overrides(&config);
893        let mut effective = self.clone();
894        if let Some(t) = temp {
895            effective.config.temperature = Some(t);
896        }
897        if let Some(m) = max {
898            effective.config.max_output_tokens = Some(m);
899        }
900        let stream = effective.stream_chat_internal(messages).await?;
901
902        let callbacks = config.and_then(|c| c.callbacks);
903        let stream = stream.then(move |token_result| {
904            let cbs = callbacks.clone();
905            let run = run.clone();
906            async move {
907                if let Some(ref cbs) = cbs {
908                    if let Ok(ref token) = token_result {
909                        for handler in cbs.handlers() {
910                            handler.on_llm_new_token(&run, &token.text).await;
911                        }
912                    }
913                }
914                token_result
915            }
916        });
917
918        Ok(Box::pin(stream))
919    }
920
921    fn bind_tools(
922        &self,
923        tools: Vec<ToolDefinition>,
924    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
925        // Expose the inherent tool-binding capability at the trait level so it
926        // survives being wrapped by `ChatModelWrapper` / `LLMClient` (Q1).
927        Some(Box::new(self.bind_tools(tools)))
928    }
929}
930
931/// Method for structured output calls via Gemini function calling.
932pub struct GeminiStructuredOutputMethod<T: DeserializeOwned + JsonSchema> {
933    config: GeminiConfig,
934    client: reqwest::Client,
935    _phantom: PhantomData<T>,
936}
937
938impl<T: DeserializeOwned + JsonSchema> GeminiStructuredOutputMethod<T> {
939    /// Invokes the model and parses the result as the structured type.
940    pub async fn invoke(&self, messages: Vec<Message>) -> Result<T, GeminiError> {
941        let chat = GeminiChat {
942            config: self.config.clone(),
943            client: self.client.clone(),
944        };
945
946        let result = chat.chat_internal(messages).await?;
947        let structured = StructuredOutput::<T>::new(result);
948        structured
949            .parse()
950            .map_err(|e| GeminiError::ParseError(e.to_string()))
951    }
952}