1use std::collections::HashMap;
4
5use ferrin_provider_util::reasoning::BudgetPercentages;
6use ferrin_provider_util::reasoning::is_custom_reasoning;
7use ferrin_provider_util::reasoning::map_reasoning_to_budget;
8use ferrin_provider_util::reasoning::map_reasoning_to_effort;
9use ferrin_provider_util::tool_name_mapping::ToolNameMapping;
10use ferrin_spec::JsonObject;
11use ferrin_spec::JsonValue;
12use ferrin_spec::ReasoningEffort;
13use ferrin_spec::ResponseFormat;
14use ferrin_spec::ToolDefinition;
15use ferrin_spec::error::ProviderError;
16use ferrin_spec::language_model::CallOptions;
17use ferrin_spec::shared::Warning;
18use serde_json::json;
19
20use crate::capabilities::GEMINI_2_5_MAX_OUTPUT_TOKENS;
21use crate::capabilities::ModelCapabilities;
22use crate::capabilities::capabilities;
23use crate::capabilities::max_thinking_tokens_gemini_2_5;
24use crate::capabilities::minimum_thinking_level_gemini3;
25use crate::config::GoogleConfig;
26use crate::convert_prompt::convert_prompt;
27use crate::json_schema::convert_json_schema_to_openapi_schema;
28use crate::options::GoogleLanguageModelOptions;
29use crate::options::ImageConfig;
30use crate::options::ThinkingConfig;
31use crate::options::parse_options;
32use crate::prepare_tools::CODE_EXECUTION_TOOL_NAME;
33use crate::prepare_tools::ids;
34use crate::prepare_tools::prepare_tools;
35
36pub const CONFIGURABLE_HARM_CATEGORIES: [&str; 4] = [
38 "HARM_CATEGORY_HATE_SPEECH",
39 "HARM_CATEGORY_DANGEROUS_CONTENT",
40 "HARM_CATEGORY_HARASSMENT",
41 "HARM_CATEGORY_SEXUALLY_EXPLICIT",
42];
43
44#[derive(Debug, Clone)]
46pub struct PreparedRequest {
47 pub body: JsonObject,
49 pub warnings: Vec<Warning>,
51 pub tool_name_mapping: ToolNameMapping,
53 pub capabilities: ModelCapabilities,
55}
56
57#[must_use]
59pub fn tool_name_mapping(tools: &[ToolDefinition]) -> ToolNameMapping {
60 ToolNameMapping::new(
61 tools,
62 &HashMap::from([(ids::CODE_EXECUTION, CODE_EXECUTION_TOOL_NAME)]),
63 )
64}
65
66fn insert_some<T: Into<JsonValue>>(object: &mut JsonObject, key: &str, value: Option<T>) {
67 if let Some(value) = value {
68 object.insert(key.to_owned(), value.into());
69 }
70}
71
72fn resolve_thinking(
74 reasoning: ReasoningEffort,
75 model_id: &str,
76 capabilities: ModelCapabilities,
77 warnings: &mut Vec<Warning>,
78) -> Option<ThinkingConfig> {
79 if !is_custom_reasoning(reasoning) {
80 return None;
81 }
82 if capabilities.uses_gemini3_features && !model_id.contains("gemini-3-pro-image") {
83 let minimum = minimum_thinking_level_gemini3(model_id);
84 let level = if reasoning == ReasoningEffort::None {
85 Some(minimum)
86 } else {
87 map_reasoning_to_effort(
88 reasoning,
89 &[
90 (ReasoningEffort::Minimal, minimum),
91 (ReasoningEffort::Low, "low"),
92 (ReasoningEffort::Medium, "medium"),
93 (ReasoningEffort::High, "high"),
94 (ReasoningEffort::XHigh, "high"),
95 ],
96 warnings,
97 )
98 };
99 return level.map(|level| ThinkingConfig {
100 thinking_level: Some(level.to_owned()),
101 ..ThinkingConfig::default()
102 });
103 }
104 let budget = if reasoning == ReasoningEffort::None {
105 Some(0)
106 } else {
107 map_reasoning_to_budget(
108 reasoning,
109 GEMINI_2_5_MAX_OUTPUT_TOKENS,
110 max_thinking_tokens_gemini_2_5(model_id),
111 0,
112 &BudgetPercentages::default(),
113 warnings,
114 )
115 };
116 budget.map(|budget| ThinkingConfig {
117 thinking_budget: Some(i64::from(budget)),
118 ..ThinkingConfig::default()
119 })
120}
121
122fn gemini_image_config(config: ImageConfig, warnings: &mut Vec<Warning>) -> ImageConfig {
124 let mut dropped = Vec::new();
125 if config.person_generation.is_some() {
126 dropped.push("'imageConfig.personGeneration'");
127 }
128 if config.prominent_people.is_some() {
129 dropped.push("'imageConfig.prominentPeople'");
130 }
131 if config.image_output_options.is_some() {
132 dropped.push("'imageConfig.imageOutputOptions'");
133 }
134 if dropped.is_empty() {
135 return config;
136 }
137 let verb = if dropped.len() == 1 {
138 "is a Vertex AI option and is"
139 } else {
140 "are Vertex AI options and are"
141 };
142 warnings.push(Warning::other(format!(
143 "{} {verb} ignored with the Gemini API",
144 dropped.join(", ")
145 )));
146 ImageConfig {
147 aspect_ratio: config.aspect_ratio,
148 image_size: config.image_size,
149 person_generation: None,
150 prominent_people: None,
151 image_output_options: None,
152 }
153}
154
155fn option_warnings(
156 google: &GoogleLanguageModelOptions,
157 tools: &[ToolDefinition],
158 warnings: &mut Vec<Warning>,
159) {
160 if tools.iter().any(
161 |tool| matches!(tool, ToolDefinition::Provider { id, .. } if id == ids::VERTEX_RAG_STORE),
162 ) {
163 warnings.push(Warning::other(
164 "the 'vertex_rag_store' tool is only supported with Vertex AI and may not work with the Gemini API",
165 ));
166 }
167 if google.stream_function_call_arguments == Some(true) {
168 warnings.push(Warning::other(
169 "'streamFunctionCallArguments' is only supported on Vertex AI and was ignored",
170 ));
171 }
172 if google.shared_request_type.is_some() || google.request_type.is_some() {
173 warnings.push(Warning::other(
174 "'sharedRequestType' and 'requestType' are Vertex AI options and were ignored",
175 ));
176 }
177}
178
179fn generation_config(
180 options: &CallOptions,
181 google: &GoogleLanguageModelOptions,
182 model_id: &str,
183 capabilities: ModelCapabilities,
184 warnings: &mut Vec<Warning>,
185) -> Result<JsonObject, ProviderError> {
186 let mut config = JsonObject::new();
187 insert_some(&mut config, "maxOutputTokens", options.max_output_tokens);
188 insert_some(&mut config, "temperature", options.temperature);
189 insert_some(&mut config, "topK", options.top_k);
190 insert_some(&mut config, "topP", options.top_p);
191 if options.frequency_penalty.is_some() {
192 if capabilities.is_gemini_2_5 {
193 warnings.push(Warning::unsupported("frequencyPenalty"));
194 } else {
195 insert_some(&mut config, "frequencyPenalty", options.frequency_penalty);
196 }
197 }
198 if options.presence_penalty.is_some() {
199 if capabilities.is_gemini_2_5 {
200 warnings.push(Warning::unsupported("presencePenalty"));
201 } else {
202 insert_some(&mut config, "presencePenalty", options.presence_penalty);
203 }
204 }
205 if let Some(stop) = &options.stop_sequences {
206 config.insert("stopSequences".to_owned(), json!(stop));
207 }
208 insert_some(&mut config, "seed", options.seed);
209 if let Some(ResponseFormat::Json { schema, .. }) = &options.response_format {
210 config.insert(
211 "responseMimeType".to_owned(),
212 JsonValue::from("application/json"),
213 );
214 if let Some(schema) = schema
215 && google.structured_outputs != Some(false)
216 && let Some(converted) = convert_json_schema_to_openapi_schema(schema)?
217 {
218 config.insert("responseSchema".to_owned(), converted);
219 }
220 }
221 insert_some(&mut config, "audioTimestamp", google.audio_timestamp);
222 if let Some(modalities) = &google.response_modalities {
223 config.insert("responseModalities".to_owned(), json!(modalities));
224 }
225 let mut thinking = google.thinking_config.clone().unwrap_or_default();
226 if let Some(resolved) = resolve_thinking(options.reasoning, model_id, capabilities, warnings) {
227 if thinking.thinking_level.is_none() {
228 thinking.thinking_level = resolved.thinking_level;
229 }
230 if thinking.thinking_budget.is_none() {
231 thinking.thinking_budget = resolved.thinking_budget;
232 }
233 }
234 if thinking != ThinkingConfig::default() {
235 config.insert("thinkingConfig".to_owned(), json!(thinking));
236 }
237 insert_some(
238 &mut config,
239 "mediaResolution",
240 google.media_resolution.as_deref(),
241 );
242 if let Some(image_config) = google.image_config.clone() {
243 let image_config = gemini_image_config(image_config, warnings);
244 if image_config != ImageConfig::default() {
245 config.insert("imageConfig".to_owned(), json!(image_config));
246 }
247 }
248 Ok(config)
249}
250
251fn safety_settings(google: &GoogleLanguageModelOptions) -> Option<JsonValue> {
252 if let Some(settings) = &google.safety_settings {
253 return Some(json!(settings));
254 }
255 let threshold = google.threshold.as_deref()?;
256 Some(JsonValue::Array(
257 CONFIGURABLE_HARM_CATEGORIES
258 .iter()
259 .map(|category| json!({"category": category, "threshold": threshold}))
260 .collect(),
261 ))
262}
263
264pub fn prepare_request(
273 config: &GoogleConfig,
274 model_id: &str,
275 options: &CallOptions,
276) -> Result<PreparedRequest, ProviderError> {
277 let google = parse_options(config, &options.provider_options)?;
278 let capabilities = capabilities(model_id);
279 let mapping = tool_name_mapping(&options.tools);
280 let mut warnings = Vec::new();
281 option_warnings(&google, &options.tools, &mut warnings);
282 let generation = generation_config(options, &google, model_id, capabilities, &mut warnings)?;
283 let prompt = convert_prompt(config, &options.prompt, capabilities, &mapping)?;
284 warnings.extend(prompt.warnings);
285 let tools = prepare_tools(
286 &options.tools,
287 options.tool_choice.as_ref(),
288 capabilities,
289 &mapping,
290 google.retrieval_config.as_ref(),
291 )?;
292 warnings.extend(tools.warnings);
293
294 let mut body = JsonObject::new();
295 body.insert("generationConfig".to_owned(), JsonValue::Object(generation));
296 body.insert("contents".to_owned(), JsonValue::Array(prompt.contents));
297 if let Some(system) = prompt.system_instruction {
298 body.insert("systemInstruction".to_owned(), JsonValue::Object(system));
299 }
300 if let Some(settings) = safety_settings(&google) {
301 body.insert("safetySettings".to_owned(), settings);
302 }
303 if let Some(wire) = tools.tools {
304 body.insert("tools".to_owned(), JsonValue::Array(wire));
305 }
306 if let Some(tool_config) = tools.tool_config {
307 body.insert("toolConfig".to_owned(), JsonValue::Object(tool_config));
308 }
309 insert_some(&mut body, "cachedContent", google.cached_content.as_deref());
310 if let Some(labels) = &google.labels {
311 body.insert("labels".to_owned(), json!(labels));
312 }
313 insert_some(&mut body, "serviceTier", google.service_tier.as_deref());
314 Ok(PreparedRequest {
315 body,
316 warnings,
317 tool_name_mapping: mapping,
318 capabilities,
319 })
320}