mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
536 lines
18 KiB
Rust
536 lines
18 KiB
Rust
//! 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<serde_json::Value>) {
|
|
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<serde_json::Value>) {
|
|
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(),
|
|
}));
|
|
}
|