ferrin-google 0.1.0

Ferrin provider for Google Generative AI (Gemini): language, embedding, image, speech, transcription and video models, files, batches and Live API sessions.
Documentation
//! Request body assembly of `generateContent`: snapshots and warnings.

use ferrin_google::request::PreparedRequest;
use ferrin_spec::CallOptions;
use ferrin_spec::PromptMessage;
use ferrin_spec::ReasoningEffort;
use ferrin_spec::ResponseFormat;
use ferrin_spec::ToolChoice;
use ferrin_spec::ToolDefinition;
use ferrin_spec::error::ProviderError;
use pretty_assertions::assert_eq;
use serde_json::json;

use super::common::TestProvider;
use super::common::features;
use super::common::google_options;
use super::common::options_under;

fn prepare(test: &TestProvider, model: &str, options: &CallOptions) -> PreparedRequest {
    test.provider
        .language_model(model)
        .prepare_request(options)
        .unwrap()
}

fn weather_tool() -> ToolDefinition {
    ToolDefinition::function(
        "get_weather",
        Some("Weather lookup".to_owned()),
        json!({
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
            "additionalProperties": false
        }),
    )
}

#[tokio::test]
async fn basic_settings_map_to_generation_config() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![
        PromptMessage::system("You are terse."),
        PromptMessage::user_text("Hello"),
        PromptMessage::assistant_text("Hi!"),
        PromptMessage::user_text("How are you?"),
    ]);
    options.max_output_tokens = Some(100);
    options.temperature = Some(0.3);
    options.top_k = Some(5);
    options.top_p = Some(0.9);
    options.seed = Some(7);
    options.stop_sequences = Some(vec!["END".to_owned()]);
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert!(prepared.warnings.is_empty(), "{:?}", prepared.warnings);
    insta::assert_json_snapshot!("request_basic", prepared.body);
}

#[tokio::test]
async fn penalties_warn_on_gemini_2_5_and_pass_through_elsewhere() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.presence_penalty = Some(0.1);
    options.frequency_penalty = Some(0.2);
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        features(&prepared.warnings),
        vec!["frequencyPenalty", "presencePenalty"]
    );
    assert!(
        prepared.body["generationConfig"]
            .get("presencePenalty")
            .is_none()
    );
    let prepared = prepare(&test, "gemini-2.0-flash", &options);
    assert!(prepared.warnings.is_empty(), "{:?}", prepared.warnings);
    assert_eq!(
        prepared.body["generationConfig"]["presencePenalty"],
        json!(0.1)
    );
    assert_eq!(
        prepared.body["generationConfig"]["frequencyPenalty"],
        json!(0.2)
    );
}

#[tokio::test]
async fn json_response_format_sets_mime_type_and_openapi_schema() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.response_format = Some(ResponseFormat::Json {
        schema: Some(json!({
            "type": "object",
            "properties": {"answer": {"type": ["string", "null"]}},
            "required": ["answer"]
        })),
        name: Some("answer".to_owned()),
        description: None,
    });
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["generationConfig"]["responseMimeType"],
        json!("application/json")
    );
    insta::assert_json_snapshot!(
        "request_response_schema",
        prepared.body["generationConfig"]["responseSchema"]
    );
    options.provider_options = google_options(json!({"structuredOutputs": false}));
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["generationConfig"]["responseMimeType"],
        json!("application/json")
    );
    assert!(
        prepared.body["generationConfig"]
            .get("responseSchema")
            .is_none()
    );
}

#[tokio::test]
async fn reasoning_effort_maps_to_thinking_budget_or_level() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.reasoning = ReasoningEffort::Low;
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    insta::assert_json_snapshot!(
        "request_thinking_gemini_2_5",
        prepared.body["generationConfig"]["thinkingConfig"]
    );
    let prepared = prepare(&test, "gemini-3-pro-preview", &options);
    assert_eq!(
        prepared.body["generationConfig"]["thinkingConfig"]["thinkingLevel"],
        json!("low")
    );
    options.reasoning = ReasoningEffort::None;
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["generationConfig"]["thinkingConfig"]["thinkingBudget"],
        json!(0)
    );
    options.reasoning = ReasoningEffort::ProviderDefault;
    options.provider_options = google_options(json!({
        "thinkingConfig": {"thinkingBudget": 1024, "includeThoughts": true}
    }));
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["generationConfig"]["thinkingConfig"],
        json!({"thinkingBudget": 1024, "includeThoughts": true})
    );
}

#[tokio::test]
async fn provider_options_map_to_the_wire_format() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.provider_options = google_options(json!({
        "cachedContent": "cachedContents/abc",
        "labels": {"team": "ferrin"},
        "serviceTier": "priority",
        "responseModalities": ["TEXT", "IMAGE"],
        "audioTimestamp": true,
        "mediaResolution": "MEDIA_RESOLUTION_LOW",
        "threshold": "BLOCK_ONLY_HIGH",
        "imageConfig": {
            "aspectRatio": "16:9",
            "imageSize": "2K",
            "personGeneration": "allow_adult",
            "imageOutputOptions": {"mimeType": "image/png"}
        },
        "streamFunctionCallArguments": true
    }));
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    insta::assert_json_snapshot!("request_provider_options", prepared.body);
    insta::assert_json_snapshot!("request_provider_options_warnings", prepared.warnings);
}

#[tokio::test]
async fn explicit_safety_settings_take_precedence_over_threshold() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.provider_options = google_options(json!({
        "threshold": "BLOCK_ONLY_HIGH",
        "safetySettings": [
            {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}
        ]
    }));
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["safetySettings"],
        json!([{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}])
    );
}

#[tokio::test]
async fn tools_and_tool_choice_map_to_function_declarations() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.tools = vec![
        weather_tool(),
        ToolDefinition::function(
            "tree",
            None,
            json!({
                "type": "object",
                "properties": {"child": {"$ref": "#/$defs/node"}},
                "$defs": {"node": {"type": "object", "properties": {"child": {"$ref": "#/$defs/node"}}}}
            }),
        ),
    ];
    options.tool_choice = Some(ToolChoice::Tool {
        tool_name: "get_weather".into(),
    });
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert!(prepared.warnings.is_empty(), "{:?}", prepared.warnings);
    insta::assert_json_snapshot!("request_tools", prepared.body["tools"]);
    assert_eq!(
        prepared.body["toolConfig"],
        json!({"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["get_weather"]}})
    );
    options.tool_choice = Some(ToolChoice::None);
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(
        prepared.body["toolConfig"],
        json!({"functionCallingConfig": {"mode": "NONE"}})
    );
}

#[tokio::test]
async fn custom_name_options_override_the_canonical_key() {
    let test = TestProvider::start_with(|mut settings| {
        settings.name = Some("mygemini".to_owned());
        settings
    })
    .await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.provider_options =
        google_options(json!({"serviceTier": "standard", "cachedContent": "cachedContents/a"}));
    options.provider_options.extend(options_under(
        "mygemini",
        json!({"serviceTier": "priority"}),
    ));
    let prepared = prepare(&test, "gemini-2.5-flash", &options);
    assert_eq!(prepared.body["serviceTier"], json!("priority"));
    assert_eq!(prepared.body["cachedContent"], json!("cachedContents/a"));
}

#[tokio::test]
async fn unknown_option_keys_are_rejected() {
    let test = TestProvider::start().await;
    let mut options = CallOptions::new(vec![PromptMessage::user_text("Hello")]);
    options.provider_options = google_options(json!({"unknownSetting": 1}));
    let error = test
        .provider
        .language_model("gemini-2.5-flash")
        .prepare_request(&options)
        .unwrap_err();
    assert!(
        matches!(error, ProviderError::InvalidArgument(_)),
        "{error:?}"
    );
}