1pub const GEMINI_3_8_FLASH: &str = "gemini-3.8-flash";
17pub const GEMINI_3_1_FLASH_LITE_PREVIEW: &str = "gemini-3.1-flash-lite-preview";
19pub const GEMINI_3_FLASH_PREVIEW: &str = "gemini-3-flash-preview";
21pub const GEMINI_2_5_PRO_PREVIEW_06_05: &str = "gemini-2.5-pro-preview-06-05";
23pub const GEMINI_2_5_PRO_EXP_03_25: &str = "gemini-2.5-pro-exp-03-25";
25pub const GEMINI_2_5_FLASH: &str = "gemini-2.5-flash";
27pub const GEMINI_2_0_FLASH: &str = "gemini-2.0-flash";
29
30use serde_json::{Map, Value, json};
31
32pub use super::cached_content::with_cached_content;
33#[doc(hidden)]
34pub use super::options::{Route, generate_content_options};
35use crate::completion::options::{BaseInput, FinalBody, RawAt, Rewrite, request_params};
36use crate::completion::{Accepts, CompletionRequest, Media, Place, Replay, ReplayTarget};
37use crate::error::{EncodeError, ProviderError};
38use crate::json_utils::Lenient;
39use crate::json_utils::remove_preserving_order;
40use crate::message::{
41 AssistantContent, DocumentData, DocumentMediaType, DocumentSourceKind as Source, Message,
42 MimeType, ToolChoice, ToolResultContent, UserContent,
43};
44use crate::operation::Completion;
45use crate::providers::internal::set_fields;
46use crate::providers::internal::wire_ids::WireIds;
47use crate::telemetry::GenAiOperation;
48use crate::wire::{Descriptor, Encoded, Framing, Mode, Wire};
49
50pub const PROVIDER_NAME: &str = "gcp.gemini";
52
53#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
56pub struct GenerateContent {
57 pub provider: super::GeminiConfig,
59 pub model: String,
61 pub cached_content: Option<String>,
64 #[serde(default)]
66 pub thought_replay: ThoughtReplay,
67 #[serde(skip)]
69 pub facts: crate::catalog::ModelFacts,
70}
71
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub enum ThoughtReplay {
88 #[default]
90 All,
91 CurrentTurn,
95}
96
97impl GenerateContent {
98 pub fn new(provider: super::GeminiConfig, model: impl Into<String>) -> Self {
100 Self {
101 provider,
102 model: model.into(),
103 cached_content: None,
104 thought_replay: ThoughtReplay::All,
105 facts: crate::catalog::ModelFacts::default(),
106 }
107 }
108
109 pub fn with_facts(mut self, facts: crate::catalog::ModelFacts) -> Self {
112 self.facts = facts;
113 self
114 }
115
116 pub fn thought_replay(mut self, replay: ThoughtReplay) -> Self {
118 self.thought_replay = replay;
119 self
120 }
121
122 pub fn with_cached_content(mut self, name: impl Into<String>) -> Self {
126 self.cached_content = Some(name.into());
127 self
128 }
129}
130
131impl<T> crate::driver::Model<GenerateContent, T> {
132 pub fn thought_replay(mut self, replay: ThoughtReplay) -> Self {
136 self.wire.thought_replay = replay;
137 self
138 }
139}
140
141fn drop_finished_signatures(contents: &mut [Value]) {
145 let current = contents.iter().rposition(|content| {
146 let parts = content.arr("parts");
147 content.str("role") == Some("user")
148 && parts.iter().any(|part| part.get("text").is_some())
149 && !parts
150 .iter()
151 .any(|part| part.get("functionResponse").is_some())
152 });
153 for content in contents.iter_mut().take(current.unwrap_or(0)) {
154 let parts = content.get_mut("parts").and_then(Value::as_array_mut);
155 for part in parts.into_iter().flatten().filter_map(Value::as_object_mut) {
156 remove_preserving_order(part, "thoughtSignature");
157 }
158 }
159}
160
161impl Wire for GenerateContent {
162 type Op = Completion;
163 type Payload = Encoded;
164 type Frame = crate::wire::WireFrame;
165 type Event = super::streaming::GenerateContentChunk;
166 type Decoder<'id> = super::streaming::GenerateContentDecoder;
167 type Reassembler = super::streaming::document::GenerateContentResponse;
168
169 fn describe(&self) -> Descriptor<'_> {
170 Descriptor::new(PROVIDER_NAME)
171 .model(self.model.as_str())
172 .facts(&self.facts)
173 .telemetry(|_| GenAiOperation::GenerateContent)
174 .replay(self)
175 }
176
177 fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
178 let model = request.model.clone().unwrap_or_else(|| self.model.clone());
180 let replay = self.thought_replay;
181 let body = request_body(
182 &request,
183 self,
184 &model,
185 self.cached_content.as_deref(),
186 |body| {
187 if let (ThoughtReplay::CurrentTurn, Some(Value::Array(contents))) =
188 (replay, body.get_mut("contents"))
189 {
190 drop_finished_signatures(contents);
191 }
192 },
193 )?;
194 use crate::providers::internal::LogTarget;
195 let (verb, framing, target) = match mode {
198 Mode::Unary => ("generateContent", Framing::Whole, LogTarget::Completions),
199 Mode::Streaming => (
200 "streamGenerateContent?alt=sse",
201 Framing::Sse,
202 LogTarget::Streaming,
203 ),
204 };
205 crate::providers::internal::trace_json(target, "Gemini completion request", &body);
206 let uri = self.provider.uri(&format!("/v1beta/models/{model}:{verb}"));
207 let request = http::Request::post(uri)
208 .header("Content-Type", "application/json")
209 .body(body.into_body())?;
210 Ok(Encoded::new(request, framing)
212 .with_projection(super::streaming::GenerateContentDecoder::project)
213 .with_analysis_only(super::streaming::GenerateContentDecoder::is_analysis_only))
214 }
215
216 fn decoder<'id>(&self) -> Self::Decoder<'id> {
217 super::streaming::GenerateContentDecoder::default()
218 }
219}
220
221impl ReplayTarget for GenerateContent {
222 fn facts(&self) -> Option<&crate::catalog::ModelFacts> {
223 Some(&self.facts)
224 }
225
226 fn api(&self) -> crate::message::Api {
227 crate::message::Api::from_static("gemini.generate_content")
228 }
229
230 fn provider(&self) -> &str {
231 PROVIDER_NAME
232 }
233
234 fn model(&self) -> &str {
235 &self.model
236 }
237
238 fn accepts(&self, model: &str) -> Accepts {
239 accepts(model)
240 }
241
242 fn encodes(&self, _model: &str, media: Media<'_>) -> bool {
243 encodes(media, false)
244 }
245
246 fn normalize_tool_call_id(
247 &self,
248 id: &str,
249 model: &str,
250 _source: Option<&crate::message::Origin>,
251 ) -> String {
252 normalize_tool_call_id(model, id)
253 }
254
255 fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
258 crate::completion::LaterSystem::Leading
259 }
260
261 fn call_id_slot(&self) -> Option<&'static str> {
262 CALL_ID_SLOT
263 }
264
265 fn blob_slots(&self) -> &'static [&'static str] {
266 BLOB_SLOTS
267 }
268
269 fn declares_tools(&self, request: &crate::completion::CompletionRequest) -> bool {
270 self.cached_content.is_some() || declares_tools(self, request)
271 }
272
273 fn map_options(
275 &self,
276 request: &CompletionRequest,
277 fields: crate::completion::options::OptionFields<'_>,
278 ) -> crate::completion::options::OptionMap {
279 let model = request.model.as_deref().unwrap_or(&self.model);
280 generate_content_options(&self.facts, model, Route::Rest, fields)
281 }
282}
283
284pub fn declares_tools(
289 target: &dyn ReplayTarget,
290 request: &crate::completion::CompletionRequest,
291) -> bool {
292 let param = |key: &str| crate::completion::options::param(target, request, key);
293 !request.tools.is_empty()
294 || param("tools").is_some_and(|tools| !tools.as_array().is_none_or(Vec::is_empty))
295 || CACHED_CONTENT
296 .iter()
297 .any(|spelling| param(spelling).is_some_and(|value| !value.is_null()))
298}
299
300pub const CALL_ID_SLOT: Option<&str> = Some("/functionCall/id");
303
304pub const BLOB_SLOTS: &[&str] = &["/thoughtSignature"];
307
308pub(super) fn gemini_major(model: &str) -> Option<u32> {
314 let model = model.to_ascii_lowercase();
315 let model = model.strip_prefix("models/").unwrap_or(&model);
316 let rest = model.strip_prefix("gemini-")?;
317 let rest = rest.strip_prefix("live-").unwrap_or(rest);
318 let end = rest
319 .find(|c: char| !c.is_ascii_digit())
320 .unwrap_or(rest.len());
321 Some(
322 rest.get(..end)
323 .and_then(|major| major.parse().ok())
324 .unwrap_or(u32::MAX),
325 )
326}
327
328pub(super) fn gemini_3_or_later(model: &str) -> bool {
331 gemini_major(model).is_some_and(|major| major >= 3)
332}
333
334pub fn requires_tool_call_id(model: &str) -> bool {
337 let model = model.to_ascii_lowercase();
338 model.starts_with("claude-") || model.starts_with("gpt-oss-") || gemini_3_or_later(&model)
339}
340
341pub fn accepts(model: &str) -> Accepts {
347 Accepts {
348 tool_result_images: gemini_major(model).is_none_or(|major| major >= 3),
349 ..Accepts::ALL
350 }
351}
352
353pub fn encodes(media: Media<'_>, response_files: bool) -> bool {
359 match media {
360 Media::Image(image, place) => {
361 reads_image(image.media_type.as_ref(), place)
362 && match image.data {
363 Source::Base64(_) => true,
364 Source::Url(_) => place != Place::ToolResult || response_files,
365 _ => false,
366 }
367 }
368 Media::Audio(audio) => {
369 audio.media_type.is_some() && matches!(audio.data, Source::Url(_) | Source::Base64(_))
370 }
371 Media::Video(video) => match &video.data {
372 Source::Url(url) if url.starts_with("https://www.youtube.com") => true,
373 data => {
374 video.media_type.is_some() && matches!(data, Source::Url(_) | Source::Base64(_))
375 }
376 },
377 Media::Document(document) => match (&document.media_type, &document.data) {
378 (_, DocumentData::Text(_)) => true,
379 (Some(_), DocumentData::File(Source::Url(_))) => true,
380 (Some(media_type), DocumentData::File(Source::Base64(_))) => {
381 *media_type == DocumentMediaType::PDF
382 }
383 _ => false,
384 },
385 }
386}
387
388pub(crate) fn reads_image(
391 media_type: Option<&crate::message::ImageMediaType>,
392 place: Place,
393) -> bool {
394 use crate::message::ImageMediaType::{HEIC, HEIF, JPEG, PNG, WEBP};
395 match place {
396 Place::ToolResult => matches!(media_type, Some(JPEG | PNG | WEBP)),
397 _ => matches!(media_type, Some(JPEG | PNG | WEBP | HEIC | HEIF)),
398 }
399}
400
401pub fn normalize_tool_call_id(model: &str, id: &str) -> String {
405 if !requires_tool_call_id(model) {
406 return id.to_owned();
407 }
408 crate::providers::internal::wire_ids::legal_call_id(id, 64)
409}
410
411pub fn request_body(
430 request: &CompletionRequest,
431 target: &dyn ReplayTarget,
432 model: &str,
433 cached_content: Option<&str>,
434 adjust: impl FnOnce(&mut Map<String, Value>),
435) -> Result<FinalBody, EncodeError> {
436 request_params(
437 target,
438 request,
439 |input| {
440 let mut body = base(request, target, model, input)?;
441 adjust(&mut body);
442 Ok(body)
443 },
444 RawAt::Top,
445 &[Rewrite::GeminiCachedContent(
446 cached_content.map(str::to_owned),
447 )],
448 )
449}
450
451fn base(
454 request: &CompletionRequest,
455 target: &dyn ReplayTarget,
456 model: &str,
457 input: &mut BaseInput<'_>,
458) -> Result<Map<String, Value>, EncodeError> {
459 let extra_tools = input.raw_tools()?;
460 let schema = request
461 .output_schema
462 .clone()
463 .map(|schema| schema.to_value());
464 let mime = schema.as_ref().map(|_| json!("application/json"));
465 let (temperature, max_tokens) = (
466 request.temperature.map(Value::from),
467 request.max_tokens.map(Value::from),
468 );
469 let config = set_fields([
470 ("responseMimeType", mime),
471 ("responseJsonSchema", schema),
472 ("temperature", temperature),
473 ("maxOutputTokens", max_tokens),
474 ]);
475 let (mut system, mut history) = (Vec::new(), Vec::new());
476 for message in &request.chat_history {
477 match &**message {
478 Message::System { content } if content.is_empty() => {}
479 Message::System { content } => system.push(text_part(content)),
480 message => history.push(message),
481 }
482 }
483 let preamble = "system instruction twice: as a preamble or system message";
486 let choice = "tool choice twice: as `tool_choice`, which Gemini merges,";
487 let twice = [
488 (!system.is_empty(), &SYSTEM_INSTRUCTION, preamble),
489 (request.tool_choice.is_some(), &TOOL_CONFIG, choice),
490 ];
491 for (set, spellings, what) in twice {
492 let raw = spellings
493 .iter()
494 .find(|spelling| input.param(spelling).is_some_and(|value| !value.is_null()));
495 if let (true, Some(spelling)) = (set, raw) {
496 return Err(EncodeError::request(format!(
497 "a Gemini request set the {what} and as `additional_params.{spelling}`. Set it \
498 one way or the other"
499 )));
500 }
501 }
502 let declared: Vec<Value> = request.tools.iter().map(|tool| declaration(tool)).collect();
503 let declared = (!declared.is_empty()).then(|| {
504 Value::Object(set_fields([
505 ("functionDeclarations", Some(Value::Array(declared))),
506 ("codeExecution", Some(Value::Null)),
507 ]))
508 });
509 let tools: Vec<Value> = declared.into_iter().chain(extra_tools).collect();
510 let contents = Value::Array(contents(&history, target, model)?);
511 let system = (!system.is_empty()).then(|| content(system, "model"));
512 let tool_config = request
513 .tool_choice
514 .clone()
515 .map_or(Value::Null, calling_config);
516 Ok(set_fields([
517 ("contents", Some(contents)),
518 (
519 "generationConfig",
520 Some(if config.is_empty() {
521 Value::Null
522 } else {
523 Value::Object(config)
524 }),
525 ),
526 ("safetySettings", Some(Value::Null)),
527 ("toolConfig", Some(tool_config)),
528 ("systemInstruction", Some(system.unwrap_or(Value::Null))),
529 ("tools", (!tools.is_empty()).then_some(Value::Array(tools))),
530 ]))
531}
532
533fn declaration(tool: &crate::completion::ToolDefinition) -> Value {
537 let parameters = (!tool.parameters.is_null()).then(|| tool.parameters.clone());
538 Value::Object(set_fields([
539 ("name", Some(Value::from(tool.name.as_str()))),
540 ("description", Some(Value::from(tool.description.as_str()))),
541 ("parametersJsonSchema", parameters),
542 ]))
543}
544
545pub(super) const SYSTEM_INSTRUCTION: [&str; 2] = ["systemInstruction", "system_instruction"];
547pub(super) const TOOL_CONFIG: [&str; 2] = ["toolConfig", "tool_config"];
548const CACHED_CONTENT: [&str; 2] = ["cachedContent", "cached_content"];
549
550pub(super) fn present<'a>(body: &Map<String, Value>, spellings: &[&'a str]) -> Option<&'a str> {
552 let set = |spelling: &&&str| body.get(**spelling).is_some_and(|value| !value.is_null());
553 spellings.iter().find(set).copied()
554}
555
556fn calling_config(choice: ToolChoice) -> Value {
558 let (mode, names) = match choice {
559 ToolChoice::Auto => ("AUTO", None),
560 ToolChoice::None => ("NONE", None),
561 ToolChoice::Required => ("ANY", None),
562 ToolChoice::Specific { function_names } => ("ANY", Some(json!(function_names))),
563 };
564 let config = set_fields([
565 ("mode", Some(json!(mode))),
566 ("allowed_function_names", names),
567 ]);
568 json!({ "functionCallingConfig": config })
569}
570
571fn contents(
578 history: &[&Message],
579 target: &dyn ReplayTarget,
580 model: &str,
581) -> Result<Vec<Value>, EncodeError> {
582 let ids = WireIds::for_target(history, target, model);
583 let (with_ids, gemini3) = (requires_tool_call_id(model), gemini_3_or_later(model));
584 let mut contents = Vec::with_capacity(history.len());
585 let mut push = |role: &str, parts: Vec<Value>| {
586 if !parts.is_empty() {
588 contents.push(content(parts, role));
589 }
590 };
591 for message in history {
592 match message {
593 Message::System { content } => push("user", vec![text_part(content)]),
594 Message::User { content } => {
595 let mut run = Vec::new();
596 let mut responses = false;
597 for part in content {
598 let id = match part {
599 UserContent::ToolResult(result) => {
600 Some(ids.of(&result.call).filter(|_| with_ids))
601 }
602 _ => None,
603 };
604 if id.is_some() != responses {
605 push("user", std::mem::take(&mut run));
606 }
607 responses = id.is_some();
608 run.push(user_part(part, id.flatten())?);
609 }
610 push("user", run);
611 }
612 Message::Assistant(turn) => {
613 let parts = turn
614 .content
615 .iter()
616 .map(|block| assistant_part(block, target, &ids, with_ids, gemini3));
617 push(
618 "model",
619 parts
620 .collect::<Result<Vec<_>, _>>()?
621 .into_iter()
622 .flatten()
623 .collect(),
624 );
625 }
626 }
627 }
628 Ok(contents)
629}
630
631fn content(parts: Vec<Value>, role: &str) -> Value {
633 Value::Object(set_fields([
634 ("parts", Some(Value::Array(parts))),
635 ("role", Some(Value::from(role))),
636 ]))
637}
638
639fn text_part(text: &str) -> Value {
640 json!({ "text": text, "thought": false })
641}
642
643pub(super) fn mime<M: MimeType>(media_type: Option<M>) -> Option<String> {
644 media_type.map(|media_type| media_type.to_mime_type().to_owned())
645}
646
647pub(super) fn carried(source: &Source) -> Result<(bool, &str), EncodeError> {
651 match source {
652 Source::Url(uri) => Ok((true, uri)),
653 Source::Base64(data) => Ok((false, data)),
654 _ => Err(EncodeError::request(
655 "Gemini cannot receive this media in its form",
656 )),
657 }
658}
659
660pub(super) fn media(
664 mime_type: Option<String>,
665 source: &Source,
666 part: bool,
667) -> Result<Value, EncodeError> {
668 let (uri, data) = carried(source)?;
669 let data = match (uri, mime_type) {
670 (true, mime) => ("fileData", json!({ "mimeType": mime, "fileUri": data })),
671 (false, Some(mime)) => ("inlineData", json!({ "mimeType": mime, "data": data })),
672 (false, None) => {
673 return Err(EncodeError::request(
674 "Gemini cannot receive media without its type",
675 ));
676 }
677 };
678 Ok(Value::Object(set_fields([
679 (data.0, Some(data.1)),
680 ("thought", part.then_some(Value::Bool(false))),
681 ])))
682}
683
684fn user_part(part: &UserContent, id: Option<&str>) -> Result<Value, EncodeError> {
690 Ok(match part {
691 UserContent::Text(text) => text_part(&text.text),
692 UserContent::ToolResult(result) => {
693 let (mut values, mut parts) = (Vec::new(), Vec::new());
694 for item in &result.content {
695 match item {
696 ToolResultContent::Text(text) => values.push(Value::from(text.text.as_str())),
697 ToolResultContent::Json { value } => values.push(value.clone()),
698 ToolResultContent::Image(image) => {
699 parts.push(media(mime(image.media_type.clone()), &image.data, false)?);
700 }
701 }
702 }
703 let key = if result.is_error { "error" } else { "result" };
704 let value = match values.len() {
705 0 => None,
706 1 => values.pop(),
707 _ => Some(Value::Array(values)),
708 };
709 let response = set_fields([
710 ("name", Some(json!(result.name))),
711 ("id", id.map(Value::from)),
712 (
713 "response",
714 value.map(|value| Value::Object(Map::from_iter([(key.into(), value)]))),
715 ),
716 ("parts", (!parts.is_empty()).then_some(Value::Array(parts))),
717 ]);
718 Value::Object(set_fields([
719 ("functionResponse", Some(Value::Object(response))),
720 ("thought", Some(Value::Bool(false))),
721 ]))
722 }
723 UserContent::Image(image) => media(mime(image.media_type.clone()), &image.data, true)?,
724 UserContent::Document(document) => match &document.data {
726 DocumentData::Text(text) => text_part(text),
727 DocumentData::File(data) => media(mime(document.media_type.clone()), data, true)?,
728 },
729 UserContent::Audio(audio) => media(mime(audio.media_type.clone()), &audio.data, true)?,
730 UserContent::Video(video) => {
731 let mut part = media(mime(video.media_type.clone()), &video.data, true)?;
732 if let (Some(Value::Object(extra)), Some(part)) =
733 (&video.additional_params, part.as_object_mut())
734 {
735 part.extend(extra.clone());
736 }
737 part
738 }
739 })
740}
741
742fn assistant_part(
749 block: &AssistantContent,
750 target: &dyn ReplayTarget,
751 ids: &WireIds,
752 with_ids: bool,
753 gemini3: bool,
754) -> Result<Option<Value>, EncodeError> {
755 if let Replay::Item(item) = block.replay(target, ids) {
756 let mut item = item.into_owned();
757 if let Some(part) = item.as_object_mut() {
758 if let (false, Some(Value::Object(call))) = (with_ids, part.get_mut("functionCall")) {
759 remove_preserving_order(call, "id");
760 }
761 if part
764 .get("thoughtSignature")
765 .and_then(Value::as_str)
766 .is_some_and(|signature| !is_base64(signature))
767 {
768 remove_preserving_order(part, "thoughtSignature");
769 }
770 }
771 return Ok(Some(item));
772 }
773 Ok(Some(match block {
774 AssistantContent::Text(text) => json!({ "text": text.text }),
775 AssistantContent::Reasoning(reasoning)
776 if reasoning.redacted || reasoning.text.trim().is_empty() =>
777 {
778 return Ok(None);
779 }
780 AssistantContent::Reasoning(reasoning) => {
781 json!({ "thought": true, "text": reasoning.text })
782 }
783 AssistantContent::ToolCall(call) => {
784 let id = ids.of(&call.id).filter(|_| with_ids).map(Value::from);
785 let name = Some(json!(call.function.name));
786 let args = Some(json!(call.function.arguments));
787 let signature = gemini3.then(|| json!("skip_thought_signature_validator"));
788 let call = Value::Object(set_fields([("name", name), ("args", args), ("id", id)]));
789 Value::Object(set_fields([
790 ("functionCall", Some(call)),
791 ("thoughtSignature", signature),
792 ]))
793 }
794 AssistantContent::Image(image) => media(mime(image.media_type.clone()), &image.data, true)?,
795 AssistantContent::Opaque(opaque) => opaque.item.clone(),
796 }))
797}
798
799fn is_base64(text: &str) -> bool {
801 let body = text.trim_end_matches('=');
802 let alphabet = |byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/');
803 !body.is_empty()
804 && text.len().is_multiple_of(4)
805 && text.len() - body.len() <= 2
806 && body.bytes().all(alphabet)
807}
808
809pub(crate) fn blocked_prompt_error(feedback: &Value) -> Option<ProviderError> {
815 let reason = match feedback.get("blockReason")? {
816 Value::String(reason) => reason.clone(),
817 Value::Number(number) => format!("BLOCK_REASON_{number}"),
818 _ => return None,
819 };
820 if matches!(
821 reason.as_str(),
822 "BLOCK_REASON_UNSPECIFIED" | "BLOCKED_REASON_UNSPECIFIED"
823 ) {
824 return None;
825 }
826 let spelled = |value: Option<&Value>| match value {
827 Some(Value::String(name)) => name.clone(),
828 Some(other) => other.to_string(),
829 None => "<unset>".to_owned(),
830 };
831 let ratings = feedback.arr("safetyRatings").iter();
832 let ratings = ratings.map(|rating| {
833 format!(
834 "{}={}",
835 spelled(rating.get("category")),
836 spelled(rating.get("probability"))
837 )
838 });
839 let ratings: Vec<String> = ratings.collect();
840 let ratings = match ratings.is_empty() {
841 true => String::new(),
842 false => format!(", safety_ratings=[{}]", ratings.join(", ")),
843 };
844 let message = format!("Gemini blocked the prompt: block_reason={reason}{ratings}");
845 let error = crate::provider_response::ProviderResponseError::without_status(message)
846 .with_code(Some(reason.clone()));
847 Some(ProviderError::ProviderResponse(match reason.as_str() {
848 "SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "IMAGE_SAFETY" | "MODEL_ARMOR"
849 | "JAILBREAK" => error.with_refusal(true),
850 _ => error.with_transient(Some(true)),
851 }))
852}
853
854pub fn map_google_finish_reason(wire_name: &str) -> crate::completion::FinishReason {
862 use crate::completion::FinishReason;
863 match wire_name {
864 "STOP" => FinishReason::Stop,
865 "MAX_TOKENS" => FinishReason::Length,
866 "SAFETY"
867 | "BLOCKLIST"
868 | "PROHIBITED_CONTENT"
869 | "SPII"
870 | "IMAGE_SAFETY"
871 | "IMAGE_PROHIBITED_CONTENT"
872 | "MODEL_ARMOR" => FinishReason::ContentFilter,
873 other => FinishReason::Other(other.to_owned()),
874 }
875}
876
877pub(crate) fn classify_reply(body: &str) -> crate::wire::WireEvent<Value> {
880 crate::providers::internal::wire::classify_marker_keyed_frame(
881 body,
882 &["candidates", "promptFeedback", "usageMetadata"],
883 )
884}
885
886pub fn usage_of(usage: &Value) -> crate::completion::Usage {
893 let count = |key: &str| usage.get(key).and_then(Value::as_u64);
894 let tool_use = count("toolUsePromptTokenCount");
895 let thoughts = count("thoughtsTokenCount");
896 let input = count("promptTokenCount")
897 .unwrap_or(0)
898 .saturating_add(tool_use.unwrap_or(0));
899 let output = count("candidatesTokenCount")
900 .unwrap_or(0)
901 .saturating_add(thoughts.unwrap_or(0));
902 crate::completion::Usage {
903 input_tokens: Some(input),
904 output_tokens: Some(output),
905 cached_input_tokens: count("cachedContentTokenCount"),
906 reasoning_tokens: thoughts,
907 tool_use_prompt_tokens: tool_use,
908 total_tokens: Some(input.saturating_add(output)),
909 cache_creation_input_tokens: None,
910 cost: None,
911 }
912}
913
914#[cfg(test)]
915mod tests;
916
917#[cfg(test)]
918mod cached_content_conflict_matrix;
919#[cfg(test)]
920mod cached_content_request_tests;