Skip to main content

ferrin_google/
request.rs

1//! Request body construction (`generateContent`).
2
3use 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
36/// Harm categories that `threshold` applies to.
37pub 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/// A request body plus everything the response mapping needs.
45#[derive(Debug, Clone)]
46pub struct PreparedRequest {
47    /// Request body.
48    pub body: JsonObject,
49    /// Warnings collected while preparing.
50    pub warnings: Vec<Warning>,
51    /// Custom ↔ provider tool name mapping.
52    pub tool_name_mapping: ToolNameMapping,
53    /// Model capabilities.
54    pub capabilities: ModelCapabilities,
55}
56
57/// Tool name mapping of a call (`google.code_execution` → `code_execution`).
58#[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
72/// Resolves the `thinkingConfig` implied by `reasoning`.
73fn 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
122/// Drops the Vertex AI-only image configuration fields with a warning.
123fn 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 resolved.thinking_level.is_some() {
228            thinking.thinking_level = resolved.thinking_level;
229        }
230        if resolved.thinking_budget.is_some() {
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
264/// Builds the `generateContent` request body for `options`.
265///
266/// # Errors
267///
268/// Returns [`ProviderError::InvalidArgument`] for invalid provider options,
269/// [`ProviderError::UnsupportedFunctionality`] for unconvertible schemas or
270/// prompts and [`ProviderError::NoSuchProviderReference`] for unresolvable
271/// file references.
272pub 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}