Caution
This project is the result of late night tinkering. I hope it fuels your own nocturnal coding, but consume with caution!
A type-safe Rust client library for the Google Gemini API.
- Type-safe DTOs: Fully typed request and response data structures
- Automatic JSON Deserialization: Type-safe parsing of structured responses
- Dual Schema Support: Both OpenAPI schema subset and full JSON Schema
- Flexible Configuration: Environment variables or explicit configuration
- Streaming Support: Both streaming and non-streaming content generation
- Safety Settings: Configurable content filtering and safety thresholds
- Builder Pattern: Ergonomic API for constructing requests
Add this to your Cargo.toml:
[dependencies]
gemini = { path = "../gemini" }use gemini::GeminiConfig;
// From environment variables (GEMINI_API_KEY, GEMINI_MODEL)
let config = GeminiConfig::from_env()?;
// Or with explicit values
let config = GeminiConfig::new(
"your-api-key".to_string(),
"gemini-2.5-flash".to_string()
);use gemini::{GenerateContentRequest, Content, Part, Role};
let request = GenerateContentRequest {
contents: vec![Content {
role: Role::User,
parts: vec![Part {
text: Some("Write a haiku about Rust programming".to_string()),
..Default::default()
}],
}],
generation_config: None,
system_instruction: None,
safety_settings: None,
};The Gemini API client provides type-safe schema configuration that enforces compile-time type safety between your schema definition and response parsing:
use gemini::{GenerateContentRequest, GenerateContentResponse, GenerationConfig, Content, Part, Role};
use serde::{Deserialize, Serialize};
// Define your response structure with JsonSchema derive
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
struct Character {
character_name: String,
character_class: String,
level: u32,
}
// JSON schema is automatically derived from the type!
// Use turbofish syntax to specify the response type
let config: GenerationConfig<Character> = GenerationConfig::builder()
.response_json_schema::<Character>()
.temperature(0.7)
.build()
.unwrap();
// Request automatically inherits the type from config
let request: GenerateContentRequest<Character> = GenerateContentRequest {
contents: vec![Content {
role: Some(Role::User),
parts: vec![Part {
text: Some(JsonString::new("Create a fantasy RPG character".to_string())),
inline_data: None,
function_call: None,
function_response: None,
file_data: None,
executable_code: None,
code_execution_result: None,
video_metadata: None,
}],
}],
generation_config: Some(config),
system_instruction: None,
safety_settings: None,
};
// Response type is automatically inferred from the request!
let response = api.generate_content(request).await?;
// response is GenerateContentResponse<Character>
// Access the typed data directly - no manual parsing needed
if let Some(character) = response.first_text() {
println!("Character: {} (Level {} {})",
character.character_name,
character.level,
character.character_class);
}Type Safety Benefits:
- Compile-time checking: Type mismatch between schema and response is caught at compile time
- Type inference: Response type automatically flows from config to request to response
- No manual parsing: JSON deserialization happens automatically
- Zero runtime cost: Uses
PhantomData- compiles to nothing
Key Points:
- Use turbofish syntax to specify response type:
.response_json_schema::<YourType>(schema) - The type flows:
GenerationConfig<T>→GenerateContentRequest<T>→GenerateContentResponse<T> - Use helper methods like
first_text()to access the parsed data - For plain text responses, use the default:
GenerationConfig(same asGenerationConfig<String>)
use gemini::{GenerationConfig, ResponseMimeType};
let config = GenerationConfig::builder()
.temperature(0.9)
.max_output_tokens(2048)
.top_p(0.95)
.top_k(40)
.add_stop_sequence("END")
.build()
.unwrap();The Gemini API supports two types of schemas for structured output:
- Automatically derived from Rust types that implement
ToSchema - Supports: objects, primitives, arrays
- Uses OpenAPI 3.0 schema format
- Full JSON Schema specification
- Automatically derived from Rust types that implement
JsonSchema - Supports advanced features:
$ref,$defs,anyOf,oneOf, etc. - More powerful and flexible
Important: These are mutually exclusive. Only one can be set per request.
// Using OpenAPI schema (auto-derived from type)
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
struct Person {
name: String,
}
let config = GenerationConfig::<Person>::builder()
.response_schema::<Person>()
.build()
.unwrap();
// Using JSON Schema (auto-derived from type)
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct Character {
name: String,
level: u32,
}
let config = GenerationConfig::<Character>::builder()
.response_json_schema::<Character>()
.build()
.unwrap();The build() method validates that typed responses have appropriate schemas:
- String responses: No schema required (plain text output)
- Typed responses: Must provide either
response_schemaorresponse_json_schema
// String type - no schema required ✓
let config = GenerationConfig::<String>::builder()
.temperature(0.7)
.build()
.unwrap();
// Typed response without schema - will fail ✗
#[derive(Deserialize, Serialize)]
struct MyType { value: String }
let result = GenerationConfig::<MyType>::builder()
.temperature(0.7)
.build();
assert!(result.is_err()); // BuildError::SchemaRequiredForTypedResponse
// Typed response with schema - succeeds ✓
// Option 1: Auto-derived OpenAPI schema
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
struct MyType { value: String }
let config = GenerationConfig::<MyType>::builder()
.response_schema::<MyType>()
.build()
.unwrap();
// Option 2: Auto-derived JSON schema
#[derive(Deserialize, Serialize, schemars::JsonSchema)]
struct MyType2 { value: String }
let config = GenerationConfig::<MyType2>::builder()
.response_json_schema::<MyType2>()
.build()
.unwrap();The client supports streaming responses for real-time content generation:
use gemini::{GeminiStreamingApi, GenerateContentRequest, Content, Part, JsonString};
use futures::StreamExt;
let client = GeminiV1Beta::from_env()?;
let request: GenerateContentRequest<String> = GenerateContentRequest::builder()
.add_content(Content {
parts: vec![
Part::builder()
.text(JsonString::new("Write a short story".to_string()))
.build(),
],
role: None,
})
.build();
// Get a streaming async stream
let mut stream = client.stream_generate_content(request).await?;
// Process chunks as they arrive in real-time
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
if let Some(text) = response.first_text() {
print!("{}", text);
}
}
Err(e) => eprintln!("Error: {}", e),
}
}Streaming also works with typed responses, though intermediate chunks may contain incomplete JSON:
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use futures::StreamExt;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct Story {
title: String,
genre: String,
summary: String,
}
let config = GenerationConfigBuilder::<String>::new()
.response_json_schema::<Story>()
.build()
.unwrap();
let request: GenerateContentRequest<Story> = GenerateContentRequest::builder()
.add_content(Content {
parts: vec![
Part::builder()
.text(JsonString::new("Create a story idea".to_string()))
.build(),
],
role: None,
})
.generation_config(config)
.build();
let mut stream = client.stream_generate_content(request).await?;
let mut last_complete_story: Option<Story> = None;
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
if let Some(story) = response.first_text() {
last_complete_story = Some(story.clone());
println!("Received: {:?}", story);
}
}
Err(e) => {
// Intermediate chunks may have incomplete JSON
println!("Partial chunk (expected): {}", e);
}
}
}
// The final chunk should have the complete typed object
if let Some(story) = last_complete_story {
println!("Final story: {}", story.title);
}The streaming API includes a parse_incomplete method that uses the deser-incomplete crate to handle partial JSON:
use gemini::GeminiStreamingApi;
// This is handled automatically in the stream, but you can use it directly:
let raw_json = r#"{"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]}"#;
let response = GeminiV1Beta::parse_incomplete::<String>(raw_json.to_string())?;The crate provides two traits for implementing custom clients:
For non-streaming content generation:
use gemini::{GeminiApi, GenerateContentRequest, GenerateContentResponse};
use std::error::Error;
#[async_trait::async_trait]
pub trait GeminiApi {
async fn generate_content<T>(
&self,
request: GenerateContentRequest<T>,
) -> Result<GenerateContentResponse<T>, Box<dyn Error>>
where
T: serde::de::DeserializeOwned + serde::Serialize + Send + 'static;
}For streaming content generation (returns async Stream):
use gemini::{GeminiStreamingApi, GenerateContentRequest, StreamingResponseStream};
use std::error::Error;
#[async_trait::async_trait]
pub trait GeminiStreamingApi {
async fn stream_generate_content<T>(
&self,
request: GenerateContentRequest<T>,
) -> Result<StreamingResponseStream<T>, Box<dyn Error>>
where
T: serde::de::DeserializeOwned + serde::Serialize + Send + 'static;
fn parse_incomplete<T>(
raw: String,
) -> Result<GenerateContentResponse<T>, deser_incomplete::Error<serde_json::Error>>
where
T: serde::de::DeserializeOwned + 'static;
}Note: StreamingResponseStream<T> is a pinned, boxed async Stream that yields results as they arrive from the API in real-time. Use with futures::StreamExt to consume chunks with .next().await.
GEMINI_API_KEY(required): Your Gemini API keyGEMINI_MODEL(optional): Model to use (defaults to "gemini-2.5-flash")
- Debug builds: API key is baked in at compile time from
COMPILE_TIME_GEMINI_API_KEY - Release builds: API key is loaded from environment at runtime
Configure safety settings to control content filtering:
use gemini::{SafetySetting, GenerateContentRequest};
let request = GenerateContentRequest {
contents: vec![/* ... */],
generation_config: None,
system_instruction: None,
safety_settings: Some(vec![
SafetySetting {
category: "HARM_CATEGORY_HATE_SPEECH".to_string(),
threshold: "BLOCK_MEDIUM_AND_ABOVE".to_string(),
}
]),
};Run the standard test suite:
cargo test --package geminiRun tests with all features enabled:
cargo test --package gemini --all-featuresThe crate includes integration tests that make real API calls to the Gemini API. These tests are ignored by default and require a valid GEMINI_API_KEY environment variable.
# Set your API key
export GEMINI_API_KEY="your-api-key-here"
# Run all tests including ignored integration tests
cargo test --package gemini --all-features -- --ignored
# Run only integration tests
cargo test --package gemini --all-features integration_tests -- --ignored
# Run a specific integration test
cargo test --package gemini --all-features test_real_api_string_response -- --ignoredNon-Streaming Tests:
test_real_api_string_response: Tests basic string response from the APItest_real_api_typed_json_response(requiresjsonfeature): Tests typed response with JSON schematest_real_api_typed_openapi_response(requiresopenapifeature): Tests typed response with OpenAPI schematest_real_api_complex_typed_response(requiresjsonfeature): Tests complex nested typed response
Streaming Tests:
test_real_api_string_streaming: Tests streaming string responses from the APItest_real_api_typed_streaming(requiresjsonfeature): Tests streaming with typed JSON responses
To run tests for specific features:
# Test JSON schema support
cargo test --package gemini --features json typed_json_tests
# Test OpenAPI schema support
cargo test --package gemini --features openapi typed_openapi_tests
# Test both
cargo test --package gemini --all-featuresThe test suite includes:
- Unit tests: Test individual components without API calls (run by default)
- Typed schema tests: Test schema generation and serialization for both JSON and OpenAPI schemas
- Integration tests: Make real API calls to verify end-to-end functionality (ignored by default, require
GEMINI_API_KEY)
MIT