//! Wire snapshots for the Gemini `generateContent` dialect. The model is //! part of the URL path, auth is the `x-goog-api-key` header, and the //! decoder mints synthetic UUID tool-call ids (normalized to `[UUID]` in //! these snapshots). use fabro_llm::provider::ProviderAdapter; use fabro_llm::providers::GeminiAdapter; use fabro_llm::types::{ Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, }; use httpmock::prelude::*; use crate::support::{ self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, }; const MODEL: &str = "gemini-test"; const COMPLETE_PATH: &str = "/models/gemini-test:generateContent"; const STREAM_PATH: &str = "/models/gemini-test:streamGenerateContent"; /// Minimal valid generateContent body for encode-side tests. fn minimal_body() -> serde_json::Value { serde_json::json!({ "candidates": [{ "content": {"role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP" }], "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1} }) } fn adapter() -> GeminiAdapter { GeminiAdapter::new("test-key") } /// Runs `complete()` against a capture mock and returns the captured wire /// request. async fn encode_capture(adapter: GeminiAdapter, request: &Request) -> WireCapture { let server = MockServer::start(); let (mock, slot) = mount_capture(&server, COMPLETE_PATH, minimal_body()); let adapter = adapter.with_base_url(server.base_url()); adapter .complete(request) .await .expect("complete should succeed"); mock.assert(); take_capture(&slot) } /// Runs `stream()` against an SSE transcript and returns the captured wire /// request plus every emitted stream item as JSON (UUIDs normalized). async fn stream_capture( adapter: GeminiAdapter, request: &Request, sse_body: &str, ) -> (WireCapture, Vec) { let server = MockServer::start(); let (mock, slot) = mount_capture_sse(&server, STREAM_PATH, sse_body); let adapter = adapter.with_base_url(server.base_url()); let mut events = support::collect_stream_events(&adapter, request).await; mock.assert(); events.iter_mut().for_each(support::normalize_uuids); (take_capture(&slot), events) } // --------------------------------------------------------------------------- // Round trip (encode + decode) // --------------------------------------------------------------------------- /// Shared setup for the system+tools round trip. The decoded response is /// returned as a UUID-normalized JSON value (gemini mints a synthetic UUID /// for the response id); the encode and decode halves are pinned separately. async fn system_and_tools_roundtrip() -> (WireCapture, serde_json::Value) { let server = MockServer::start(); let (mock, slot) = mount_capture( &server, COMPLETE_PATH, serde_json::json!({ "candidates": [{ "content": {"role": "model", "parts": [{"text": "Hello back"}]}, "finishReason": "STOP" }], "usageMetadata": { "promptTokenCount": 42, "candidatesTokenCount": 7, "cachedContentTokenCount": 10 } }), ); let adapter = adapter().with_base_url(server.base_url()); let request = Request { messages: vec![Message::system("Be concise"), Message::user("Hello")], tools: Some(vec![ToolDefinition::function( "search", "Search files", serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), )]), temperature: Some(0.5), ..base_request(MODEL) }; let response = adapter .complete(&request) .await .expect("complete should succeed"); mock.assert(); let mut response_value = serde_json::to_value(&response).expect("response should serialize"); support::normalize_uuids(&mut response_value); (take_capture(&slot), response_value) } #[tokio::test] async fn system_and_tools_encode() { let (capture, _) = system_and_tools_roundtrip().await; fabro_test::fabro_json_snapshot!(capture); } #[tokio::test] async fn system_and_tools_decode() { let (_, response) = system_and_tools_roundtrip().await; fabro_test::fabro_json_snapshot!(response); } // --------------------------------------------------------------------------- // Encode // --------------------------------------------------------------------------- #[tokio::test] async fn encode_multi_turn() { let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_tool_choice_auto() { let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_tool_choice_required() { let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_tool_choice_named() { let capture = encode_capture( adapter(), &corpus_tools(MODEL, Some(ToolChoice::named("search"))), ) .await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_tool_choice_none() { let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_tool_round_trip() { let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_thinking_round_trip() { let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_inline_attachments() { let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_url_attachments() { let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_bad_file_path_attachments_dropped() { let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } /// Gemini sends inline audio (the only dialect that does). #[tokio::test] async fn encode_audio_attachment() { let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_response_format_json_object() { let format = ResponseFormat { kind: ResponseFormatType::JsonObject, json_schema: None, strict: false, }; let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_response_format_json_schema() { let capture = encode_capture( adapter(), &corpus_response_format(MODEL, json_schema_format()), ) .await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_sampling_params() { let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; fabro_test::fabro_json_snapshot!(capture.body); } /// The "gemini"-namespaced provider_options merge — and the default /// safety_settings injection it can override. #[tokio::test] async fn encode_provider_options_gemini_namespace() { let capture = encode_capture( adapter(), &corpus_provider_options( MODEL, serde_json::json!({"gemini": {"cached_content": "cachedContents/abc"}}), ), ) .await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_provider_options_can_override_safety_settings() { let capture = encode_capture( adapter(), &corpus_provider_options( MODEL, serde_json::json!({"gemini": {"safety_settings": []}}), ), ) .await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn encode_reasoning_effort_with_levels_catalog() { let catalog = support::catalog_from_toml( r#" [providers.gemini] display_name = "Gemini" adapter = "gemini" agent_profile = "gemini" [models."gemini-test"] provider = "gemini" display_name = "Test Gemini" family = "gemini" default = true [models."gemini-test".limits] context_window = 200000 max_output = 4096 [models."gemini-test".features] tools = true vision = true reasoning = true reasoning_effort = "levels" "#, ); let request = Request { reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), ..base_request(MODEL) }; let capture = encode_capture(adapter().with_catalog(catalog), &request).await; fabro_test::fabro_json_snapshot!(capture.body); } #[tokio::test] async fn count_tokens_wire_shape() { let server = MockServer::start(); let (mock, slot) = mount_capture( &server, "/models/gemini-test:countTokens", serde_json::json!({"totalTokens": 123}), ); let adapter = adapter().with_base_url(server.base_url()); let request = Request { messages: vec![Message::system("Be concise"), Message::user("Hello")], ..corpus_tools(MODEL, None) }; let count = adapter .count_input_tokens(&request) .await .unwrap() .expect("gemini should count tokens"); mock.assert(); assert_eq!(count.input_tokens, 123); fabro_test::fabro_json_snapshot!(take_capture(&slot)); } // --------------------------------------------------------------------------- // Decode // --------------------------------------------------------------------------- /// Runs `complete()` against a canned body and returns the decoded response /// as JSON with synthetic UUIDs normalized. async fn decode_response(body: serde_json::Value) -> serde_json::Value { let server = MockServer::start(); let (mock, _slot) = mount_capture(&server, COMPLETE_PATH, body); let adapter = adapter().with_base_url(server.base_url()); let response = adapter .complete(&base_request(MODEL)) .await .expect("complete should succeed"); mock.assert(); let mut value = serde_json::to_value(&response).expect("response should serialize"); support::normalize_uuids(&mut value); value } /// functionCall parts get synthetic UUID ids, preserve `thoughtSignature`, /// and force the finish reason to ToolCalls regardless of `finishReason`. #[tokio::test] async fn decode_function_call_with_thought_signature() { let response = decode_response(serde_json::json!({ "candidates": [{ "content": { "role": "model", "parts": [ {"text": "Let me search."}, { "functionCall": {"name": "search", "args": {"query": "foo"}}, "thoughtSignature": "sig_gemini_xyz" } ] }, "finishReason": "STOP" }], "usageMetadata": {"promptTokenCount": 30, "candidatesTokenCount": 12} })) .await; fabro_test::fabro_json_snapshot!(response); } /// The Gemini usage arithmetic: input = (prompt - cached) + tool_use_prompt; /// thoughts become reasoning tokens. #[tokio::test] async fn decode_usage_arithmetic() { let response = decode_response(serde_json::json!({ "candidates": [{ "content": {"role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP" }], "usageMetadata": { "promptTokenCount": 100, "candidatesTokenCount": 50, "thoughtsTokenCount": 8, "cachedContentTokenCount": 30, "toolUsePromptTokenCount": 5 } })) .await; fabro_test::fabro_json_snapshot!(response); } /// `thought: true` text parts decode as Thinking content. #[tokio::test] async fn decode_thought_parts() { let response = decode_response(serde_json::json!({ "candidates": [{ "content": { "role": "model", "parts": [ {"text": "Adding the numbers.", "thought": true}, {"text": "4."} ] }, "finishReason": "STOP" }], "usageMetadata": {"promptTokenCount": 25, "candidatesTokenCount": 40} })) .await; fabro_test::fabro_json_snapshot!(response); } #[tokio::test] async fn decode_max_tokens_finish_reason() { let length = decode_response(serde_json::json!({ "candidates": [{ "content": {"role": "model", "parts": [{"text": "Trunc"}]}, "finishReason": "MAX_TOKENS" }], "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 128} })) .await; fabro_test::fabro_json_snapshot!(length["finish_reason"]); } #[tokio::test] async fn decode_safety_finish_reason() { let safety = decode_response(serde_json::json!({ "candidates": [{ "content": {"role": "model", "parts": [{"text": ""}]}, "finishReason": "SAFETY" }], "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 0} })) .await; fabro_test::fabro_json_snapshot!(safety["finish_reason"]); } // --------------------------------------------------------------------------- // Stream // --------------------------------------------------------------------------- /// Shared setup for the happy-path text stream; the request and event halves /// are pinned by separate tests. async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { let sse = support::sse_data_transcript(&[ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}"#, r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":5}}"#, ]); stream_capture(adapter(), &base_request(MODEL), &sse).await } /// The captured request pins model-in-URL and `?alt=sse` on the wire. #[tokio::test] async fn stream_text_happy_path_request() { let (capture, _) = stream_text_happy_path_capture().await; fabro_test::fabro_json_snapshot!(capture); } #[tokio::test] async fn stream_text_happy_path_events() { let (_, events) = stream_text_happy_path_capture().await; support::assert_stream_starts(&events); fabro_test::fabro_json_snapshot!(events); } #[tokio::test] async fn stream_function_call() { let sse = support::sse_data_transcript(&[ r#"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"search","args":{"query":"foo"}},"thoughtSignature":"sig_stream_g"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9}}"#, ]); let (_capture, events) = stream_capture( adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto)), &sse, ) .await; fabro_test::fabro_json_snapshot!(events); } #[tokio::test] async fn stream_thought_parts() { let sse = support::sse_data_transcript(&[ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Let me think","thought":true}]}}]}"#, r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"4."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":12,"thoughtsTokenCount":6}}"#, ]); let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; fabro_test::fabro_json_snapshot!(events); } /// The Gemini decoder synthesizes a `Finish` on byte-stream end /// unconditionally — even when no chunk carried a `finishReason`. #[tokio::test] async fn stream_end_synthesizes_finish_without_finish_reason() { let sse = support::sse_data_transcript(&[ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}"#, ]); let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; fabro_test::fabro_json_snapshot!(events); } // --------------------------------------------------------------------------- // Custom-named route identity // --------------------------------------------------------------------------- /// Streamed responses stamp the configured provider name (previously /// hardcoded "gemini"). #[tokio::test] async fn custom_named_stream_identity() { let sse = support::sse_data_transcript(&[ r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}"#, ]); let (_capture, events) = stream_capture( adapter().with_name("gemini-proxy"), &base_request(MODEL), &sse, ) .await; fabro_test::fabro_json_snapshot!(events); } /// HTTP-level errors carry the configured name in the error detail /// (normalize-both decision). #[tokio::test] async fn custom_named_http_error_identity() { let server = MockServer::start(); let mock = server.mock(|when, then| { when.method(POST).path(COMPLETE_PATH); then.status(500) .header("content-type", "application/json") .json_body(serde_json::json!({ "error": {"message": "backend exploded", "status": "INTERNAL", "code": 500} })); }); let adapter = adapter() .with_name("gemini-proxy") .with_base_url(server.base_url()); let error = adapter .complete(&base_request(MODEL)) .await .expect_err("complete should fail"); mock.assert(); fabro_test::fabro_json_snapshot!(serde_json::json!({ "error": error.to_string(), "retryable": error.retryable(), "failover_eligible": error.failover_eligible(), })); }