feat: add Google Gemini LLM client integration

This commit is contained in:
Keisuke Hirata 2026-01-07 00:54:58 +09:00
parent 89b12d277a
commit a26d43c52d
14 changed files with 1155 additions and 3 deletions

View File

@ -1,2 +1,3 @@
ANTHROPIC_API_KEY=your_api_key
OPENAI_API_KEY=your_api_key
OPENAI_API_KEY=your_api_key
GEMINI_API_KEY=your_api_key

View File

@ -0,0 +1,176 @@
//! LLMクライアント + Timeline統合サンプル (Gemini)
//!
//! Google Gemini APIにリクエストを送信し、Timelineでイベントを処理するサンプル
//!
//! ## 使用方法
//!
//! ```bash
//! # .envファイルにAPIキーを設定
//! echo "GEMINI_API_KEY=your-api-key" > .env
//!
//! # 実行
//! cargo run --example llm_client_gemini
//! ```
use std::sync::{Arc, Mutex};
use futures::StreamExt;
use worker::{
Handler, TextBlockEvent, TextBlockKind, Timeline, ToolUseBlockEvent, ToolUseBlockKind,
UsageEvent, UsageKind,
llm_client::{LlmClient, Request, providers::gemini::GeminiClient},
};
/// テキスト出力をリアルタイムで表示するハンドラー
struct PrintHandler;
impl Handler<TextBlockKind> for PrintHandler {
type Scope = ();
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {
print!("\n🤖 Assistant: ");
}
TextBlockEvent::Delta(text) => {
print!("{}", text);
// 即時出力をフラッシュ
use std::io::Write;
std::io::stdout().flush().ok();
}
TextBlockEvent::Stop(_) => {
println!("\n");
}
}
}
}
/// テキストを蓄積するハンドラー
struct TextCollector {
texts: Arc<Mutex<Vec<String>>>,
}
impl Handler<TextBlockKind> for TextCollector {
type Scope = String;
fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {}
TextBlockEvent::Delta(text) => {
buffer.push_str(text);
}
TextBlockEvent::Stop(_) => {
let text = std::mem::take(buffer);
self.texts.lock().unwrap().push(text);
}
}
}
}
/// ツール使用を検出するハンドラー
struct ToolUseDetector;
impl Handler<ToolUseBlockKind> for ToolUseDetector {
type Scope = String; // JSON accumulator
fn on_event(&mut self, json_buffer: &mut String, event: &ToolUseBlockEvent) {
match event {
ToolUseBlockEvent::Start(start) => {
println!("\n🔧 Tool Call: {} (id: {})", start.name, start.id);
}
ToolUseBlockEvent::InputJsonDelta(json) => {
json_buffer.push_str(json);
}
ToolUseBlockEvent::Stop(stop) => {
println!(" Arguments: {}", json_buffer);
println!(" Tool {} completed\n", stop.name);
}
}
}
}
/// 使用量を追跡するハンドラー
struct UsageTracker {
total_input: Arc<Mutex<u64>>,
total_output: Arc<Mutex<u64>>,
}
impl Handler<UsageKind> for UsageTracker {
type Scope = ();
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
if let Some(input) = event.input_tokens {
*self.total_input.lock().unwrap() += input;
}
if let Some(output) = event.output_tokens {
*self.total_output.lock().unwrap() += output;
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// APIキーを環境変数から取得
let api_key = std::env::var("GEMINI_API_KEY")
.expect("GEMINI_API_KEY environment variable must be set");
println!("=== Gemini LLM Client + Timeline Integration Example ===\n");
// クライアントを作成
let client = GeminiClient::new(api_key, "gemini-2.0-flash");
// 共有状態
let collected_texts = Arc::new(Mutex::new(Vec::new()));
let total_input = Arc::new(Mutex::new(0u64));
let total_output = Arc::new(Mutex::new(0u64));
// タイムラインを構築
let mut timeline = Timeline::new();
timeline
.on_text_block(PrintHandler)
.on_text_block(TextCollector {
texts: collected_texts.clone(),
})
.on_tool_use_block(ToolUseDetector)
.on_usage(UsageTracker {
total_input: total_input.clone(),
total_output: total_output.clone(),
});
// リクエストを作成
let request = Request::new()
.system("You are a helpful assistant. Be concise.")
.user("What is the capital of Japan? Answer in one sentence.")
.max_tokens(100);
println!("📤 Sending request...\n");
// ストリーミングリクエストを送信
let mut stream = client.stream(request).await?;
// イベントを処理
while let Some(result) = stream.next().await {
match result {
Ok(event) => {
timeline.dispatch(&event);
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
}
}
// 結果を表示
println!("=== Summary ===");
println!(
"📊 Token Usage: {} input, {} output",
total_input.lock().unwrap(),
total_output.lock().unwrap()
);
let texts = collected_texts.lock().unwrap();
println!("📝 Collected {} text block(s)", texts.len());
Ok(())
}

View File

@ -24,6 +24,7 @@ mod scenarios;
use clap::{Parser, ValueEnum};
use worker::llm_client::providers::anthropic::AnthropicClient;
use worker::llm_client::providers::gemini::GeminiClient;
use worker::llm_client::providers::openai::OpenAIClient;
#[derive(Parser, Debug)]
@ -49,6 +50,7 @@ struct Args {
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum ClientType {
Anthropic,
Gemini,
Openai,
Ollama,
}
@ -118,6 +120,28 @@ async fn run_scenario_with_ollama(
Ok(())
}
async fn run_scenario_with_gemini(
scenario: &scenarios::TestScenario,
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("GEMINI_API_KEY")
.expect("GEMINI_API_KEY environment variable must be set");
let model = model.as_deref().unwrap_or("gemini-2.0-flash");
let client = GeminiClient::new(&api_key, model);
recorder::record_request(
&client,
scenario.request.clone(),
scenario.name,
scenario.output_name,
subdir,
model,
)
.await?;
Ok(())
}
@ -169,6 +193,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let subdir = match args.client {
ClientType::Anthropic => "anthropic",
ClientType::Gemini => "gemini",
ClientType::Openai => "openai",
ClientType::Ollama => "ollama",
};
@ -178,6 +203,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
for scenario in scenarios_to_run {
match args.client {
ClientType::Anthropic => run_scenario_with_anthropic(&scenario, subdir, args.model.clone()).await?,
ClientType::Gemini => run_scenario_with_gemini(&scenario, subdir, args.model.clone()).await?,
ClientType::Openai => run_scenario_with_openai(&scenario, subdir, args.model.clone()).await?,
ClientType::Ollama => run_scenario_with_ollama(&scenario, subdir, args.model.clone()).await?,
}

View File

@ -13,7 +13,7 @@ pub mod error;
pub mod types;
pub mod providers;
pub(crate) mod scheme;
pub mod scheme;
pub use client::*;
pub use error::*;

View File

@ -0,0 +1,185 @@
//! Gemini プロバイダ実装
//!
//! Google Gemini APIと通信し、Eventストリームを出力
use std::pin::Pin;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use worker_types::Event;
use crate::llm_client::{ClientError, LlmClient, Request, scheme::gemini::GeminiScheme};
/// Gemini クライアント
pub struct GeminiClient {
/// HTTPクライアント
http_client: reqwest::Client,
/// APIキー
api_key: String,
/// モデル名
model: String,
/// スキーマ
scheme: GeminiScheme,
/// ベースURL
base_url: String,
}
impl GeminiClient {
/// 新しいGeminiクライアントを作成
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
http_client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
scheme: GeminiScheme::default(),
base_url: "https://generativelanguage.googleapis.com".to_string(),
}
}
/// カスタムHTTPクライアントを設定
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
}
/// スキーマを設定
pub fn with_scheme(mut self, scheme: GeminiScheme) -> Self {
self.scheme = scheme;
self
}
/// ベースURLを設定
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// リクエストヘッダーを構築
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
}
#[async_trait]
impl LlmClient for GeminiClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
// URL構築: base_url/v1beta/models/{model}:streamGenerateContent?alt=sse&key={api_key}
let url = format!(
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
self.base_url, self.model, self.api_key
);
let headers = self.build_headers()?;
let body = self.scheme.build_request(&request);
let response = self
.http_client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await?;
// エラーレスポンスをチェック
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
// JSONでエラーをパースしてみる
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
// Gemini error format: { "error": { "code": xxx, "message": "...", "status": "..." } }
let error = json.get("error").unwrap_or(&json);
let code = error
.get("status")
.and_then(|v| v.as_str())
.map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
return Err(ClientError::Api {
status: Some(status),
code,
message,
});
}
return Err(ClientError::Api {
status: Some(status),
code: None,
message: text,
});
}
// SSEストリームを構築
let scheme = self.scheme.clone();
let byte_stream = response
.bytes_stream()
.map_err(|e| std::io::Error::other(e));
let event_stream = byte_stream.eventsource();
let stream = event_stream
.map(move |result| {
match result {
Ok(event) => {
// SSEイベントをパース
// Geminiは "data: {...}" 形式で送る
match scheme.parse_event(&event.data) {
Ok(Some(events)) => Ok(Some(events)),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
Err(e) => Err(ClientError::Sse(e.to_string())),
}
})
// flatten Option<Vec<Event>> stream to Stream<Event>
.map(|res| {
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
Ok(Some(events)) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
Ok(None) => Box::pin(futures::stream::empty()),
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
};
s
})
.flatten();
Ok(Box::pin(stream))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
assert_eq!(client.model, "gemini-2.0-flash");
}
#[test]
fn test_build_headers() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
let headers = client.build_headers().unwrap();
assert!(headers.contains_key("content-type"));
}
#[test]
fn test_custom_base_url() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash")
.with_base_url("https://custom.api.example.com");
assert_eq!(client.base_url, "https://custom.api.example.com");
}
}

View File

@ -3,5 +3,6 @@
//! 各プロバイダ固有のHTTPクライアント実装
pub mod anthropic;
pub mod openai;
pub mod gemini;
pub mod ollama;
pub mod openai;

View File

@ -0,0 +1,331 @@
//! Gemini SSEイベントパース
//!
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
use serde::Deserialize;
use worker_types::{
BlockMetadata, BlockStart, BlockStop, BlockType, Event, StopReason, UsageEvent,
};
use crate::llm_client::ClientError;
use super::GeminiScheme;
// ============================================================================
// SSEイベントのJSON構造
// ============================================================================
/// Gemini GenerateContentResponse (ストリーミングチャンク)
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateContentResponse {
/// 候補
pub candidates: Option<Vec<Candidate>>,
/// 使用量メタデータ
pub usage_metadata: Option<UsageMetadata>,
/// プロンプトフィードバック
pub prompt_feedback: Option<PromptFeedback>,
/// モデルバージョン
pub model_version: Option<String>,
}
/// 候補
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Candidate {
/// コンテンツ
pub content: Option<CandidateContent>,
/// 完了理由
pub finish_reason: Option<String>,
/// インデックス
pub index: Option<usize>,
/// 安全性評価
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 候補コンテンツ
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct CandidateContent {
/// パーツ
pub parts: Option<Vec<CandidatePart>>,
/// ロール
pub role: Option<String>,
}
/// 候補パーツ
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CandidatePart {
/// テキスト
pub text: Option<String>,
/// 関数呼び出し
pub function_call: Option<FunctionCall>,
}
/// 関数呼び出し
#[derive(Debug, Deserialize)]
pub(crate) struct FunctionCall {
/// 関数名
pub name: String,
/// 引数
pub args: Option<serde_json::Value>,
}
/// 使用量メタデータ
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageMetadata {
/// プロンプトトークン数
pub prompt_token_count: Option<u64>,
/// 候補トークン数
pub candidates_token_count: Option<u64>,
/// 合計トークン数
pub total_token_count: Option<u64>,
}
/// プロンプトフィードバック
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PromptFeedback {
/// ブロック理由
pub block_reason: Option<String>,
/// 安全性評価
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 安全性評価
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct SafetyRating {
/// カテゴリ
pub category: Option<String>,
/// 確率
pub probability: Option<String>,
}
// ============================================================================
// イベント変換
// ============================================================================
impl GeminiScheme {
/// SSEデータをEvent型に変換
///
/// # Arguments
/// * `data` - SSEイベントデータJSON文字列
///
/// # Returns
/// * `Ok(Some(Vec<Event>))` - 変換成功
/// * `Ok(None)` - イベントを無視
/// * `Err(ClientError)` - パースエラー
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// データが空または無効な場合はスキップ
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
let response: GenerateContentResponse = serde_json::from_str(data).map_err(|e| {
ClientError::Api {
status: None,
code: Some("parse_error".to_string()),
message: format!("Failed to parse Gemini SSE data: {} -> {}", e, data),
}
})?;
let mut events = Vec::new();
// 使用量メタデータ
if let Some(usage) = response.usage_metadata {
events.push(self.convert_usage(&usage));
}
// 候補を処理
if let Some(candidates) = response.candidates {
for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
if let Some(content) = candidate.content {
if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() {
// テキストデルタ
if let Some(text) = &part.text {
if !text.is_empty() {
// Geminiは明示的なBlockStartを送らないため、
// TextDeltaを直接送るTimelineが暗黙的に開始を処理
events.push(Event::text_delta(
part_index,
text.clone(),
));
}
}
// 関数呼び出し
if let Some(function_call) = &part.function_call {
// 関数呼び出しの開始
// Geminiでは関数呼び出しは一度に送られることが多い
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
let function_id = format!("call_{}", function_call.name);
events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // 複合インデックス
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: function_id,
name: function_call.name.clone(),
},
}));
// 引数がある場合はデルタとして送る
if let Some(args) = &function_call.args {
let args_str = serde_json::to_string(args).unwrap_or_default();
if !args_str.is_empty() && args_str != "null" {
events.push(Event::tool_input_delta(
candidate_index * 10 + part_index,
args_str,
));
}
}
}
}
}
}
// 完了理由
if let Some(finish_reason) = candidate.finish_reason {
let stop_reason = match finish_reason.as_str() {
"STOP" => Some(StopReason::EndTurn),
"MAX_TOKENS" => Some(StopReason::MaxTokens),
"SAFETY" | "RECITATION" | "OTHER" => Some(StopReason::EndTurn),
_ => None,
};
// テキストブロックの停止
events.push(Event::BlockStop(BlockStop {
index: candidate_index,
block_type: BlockType::Text,
stop_reason,
}));
}
}
}
if events.is_empty() {
Ok(None)
} else {
Ok(Some(events))
}
}
fn convert_usage(&self, usage: &UsageMetadata) -> Event {
Event::Usage(UsageEvent {
input_tokens: usage.prompt_token_count,
output_tokens: usage.candidates_token_count,
total_tokens: usage.total_token_count,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use worker_types::DeltaContent;
#[test]
fn test_parse_text_response() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 0);
if let DeltaContent::Text(text) = &delta.delta {
assert_eq!(text, "Hello");
} else {
panic!("Expected text delta");
}
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_usage_metadata() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// Usageイベントが含まれるはず
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
assert!(usage_event.is_some());
if let Event::Usage(usage) = usage_event.unwrap() {
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(5));
assert_eq!(usage.total_tokens, Some(15));
}
}
#[test]
fn test_parse_function_call() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStartイベントがあるはず
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
assert!(start_event.is_some());
if let Event::BlockStart(start) = start_event.unwrap() {
assert_eq!(start.block_type, BlockType::ToolUse);
if let BlockMetadata::ToolUse { id: _, name } = &start.metadata {
assert_eq!(name, "get_weather");
} else {
panic!("Expected ToolUse metadata");
}
}
// 引数デルタもあるはず
let delta_event = events.iter().find(|e| {
if let Event::BlockDelta(d) = e {
matches!(d.delta, DeltaContent::InputJson(_))
} else {
false
}
});
assert!(delta_event.is_some());
}
#[test]
fn test_parse_finish_reason() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStopイベントがあるはず
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
assert!(stop_event.is_some());
if let Event::BlockStop(stop) = stop_event.unwrap() {
assert_eq!(stop.stop_reason, Some(StopReason::EndTurn));
}
}
#[test]
fn test_parse_empty_data() {
let scheme = GeminiScheme::new();
assert!(scheme.parse_event("").unwrap().is_none());
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
}
}

View File

@ -0,0 +1,37 @@
//! Google Gemini API スキーマ
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod events;
mod request;
/// Geminiスキーマ
///
/// Google Gemini APIのリクエスト/レスポンス変換を担当
#[derive(Debug, Clone)]
pub struct GeminiScheme {
/// ストリーミング関数呼び出し引数を有効にするか
pub stream_function_call_arguments: bool,
}
impl Default for GeminiScheme {
fn default() -> Self {
Self {
stream_function_call_arguments: false,
}
}
}
impl GeminiScheme {
/// 新しいスキーマを作成
pub fn new() -> Self {
Self::default()
}
/// ストリーミング関数呼び出し引数を有効/無効にする
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
self.stream_function_call_arguments = enabled;
self
}
}

View File

@ -0,0 +1,326 @@
//! Gemini リクエスト生成
//!
//! Google Gemini APIへのリクエストボディを構築
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{
Request,
types::{ContentPart, Message, MessageContent, Role, ToolDefinition},
};
use super::GeminiScheme;
/// Gemini APIへのリクエストボディ
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiRequest {
/// コンテンツ(会話履歴)
pub contents: Vec<GeminiContent>,
/// システム指示
#[serde(skip_serializing_if = "Option::is_none")]
pub system_instruction: Option<GeminiContent>,
/// ツール定義
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<GeminiTool>,
/// ツール設定
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_config: Option<GeminiToolConfig>,
/// 生成設定
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_config: Option<GeminiGenerationConfig>,
}
/// Gemini コンテンツ
#[derive(Debug, Serialize)]
pub(crate) struct GeminiContent {
/// ロール
pub role: String,
/// パーツ
pub parts: Vec<GeminiPart>,
}
/// Gemini パーツ
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum GeminiPart {
/// テキストパーツ
Text {
text: String,
},
/// 関数呼び出しパーツ
FunctionCall {
#[serde(rename = "functionCall")]
function_call: GeminiFunctionCall,
},
/// 関数レスポンスパーツ
FunctionResponse {
#[serde(rename = "functionResponse")]
function_response: GeminiFunctionResponse,
},
}
/// Gemini 関数呼び出し
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionCall {
pub name: String,
pub args: Value,
}
/// Gemini 関数レスポンス
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionResponse {
pub name: String,
pub response: GeminiFunctionResponseContent,
}
/// Gemini 関数レスポンス内容
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionResponseContent {
pub name: String,
pub content: Value,
}
/// Gemini ツール定義
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiTool {
/// 関数宣言
pub function_declarations: Vec<GeminiFunctionDeclaration>,
}
/// Gemini 関数宣言
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionDeclaration {
/// 関数名
pub name: String,
/// 説明
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// パラメータスキーマ
pub parameters: Value,
}
/// Gemini ツール設定
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiToolConfig {
/// 関数呼び出し設定
pub function_calling_config: GeminiFunctionCallingConfig,
}
/// Gemini 関数呼び出し設定
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiFunctionCallingConfig {
/// モード: AUTO, ANY, NONE
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// ストリーミング関数呼び出し引数を有効にするか
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_function_call_arguments: Option<bool>,
}
/// Gemini 生成設定
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiGenerationConfig {
/// 最大出力トークン数
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
/// Temperature
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Top P
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// ストップシーケンス
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
}
impl GeminiScheme {
/// RequestからGeminiのリクエストボディを構築
pub(crate) fn build_request(&self, request: &Request) -> GeminiRequest {
let mut contents = Vec::new();
for message in &request.messages {
contents.push(self.convert_message(message));
}
// システムプロンプト
let system_instruction = request.system_prompt.as_ref().map(|s| GeminiContent {
role: "user".to_string(), // system_instructionではroleは"user"か省略
parts: vec![GeminiPart::Text { text: s.clone() }],
});
// ツール
let tools = if request.tools.is_empty() {
vec![]
} else {
vec![GeminiTool {
function_declarations: request
.tools
.iter()
.map(|t| self.convert_tool(t))
.collect(),
}]
};
// ツール設定
let tool_config = if !request.tools.is_empty() {
Some(GeminiToolConfig {
function_calling_config: GeminiFunctionCallingConfig {
mode: Some("AUTO".to_string()),
stream_function_call_arguments: if self.stream_function_call_arguments {
Some(true)
} else {
None
},
},
})
} else {
None
};
// 生成設定
let generation_config = Some(GeminiGenerationConfig {
max_output_tokens: request.config.max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
stop_sequences: request.config.stop_sequences.clone(),
});
GeminiRequest {
contents,
system_instruction,
tools,
tool_config,
generation_config,
}
}
fn convert_message(&self, message: &Message) -> GeminiContent {
let role = match message.role {
Role::User => "user",
Role::Assistant => "model",
};
let parts = match &message.content {
MessageContent::Text(text) => vec![GeminiPart::Text { text: text.clone() }],
MessageContent::ToolResult {
tool_use_id,
content,
} => {
// Geminiでは関数レスポンスとしてマップ
vec![GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponse {
name: tool_use_id.clone(),
response: GeminiFunctionResponseContent {
name: tool_use_id.clone(),
content: serde_json::Value::String(content.clone()),
},
},
}]
}
MessageContent::Parts(parts) => {
parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => GeminiPart::Text { text: text.clone() },
ContentPart::ToolUse { id: _, name, input } => {
GeminiPart::FunctionCall {
function_call: GeminiFunctionCall {
name: name.clone(),
args: input.clone(),
},
}
}
ContentPart::ToolResult {
tool_use_id,
content,
} => GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponse {
name: tool_use_id.clone(),
response: GeminiFunctionResponseContent {
name: tool_use_id.clone(),
content: serde_json::Value::String(content.clone()),
},
},
},
})
.collect()
}
};
GeminiContent {
role: role.to_string(),
parts,
}
}
fn convert_tool(&self, tool: &ToolDefinition) -> GeminiFunctionDeclaration {
GeminiFunctionDeclaration {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_simple_request() {
let scheme = GeminiScheme::new();
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let gemini_req = scheme.build_request(&request);
assert!(gemini_req.system_instruction.is_some());
assert_eq!(gemini_req.contents.len(), 1);
assert_eq!(gemini_req.contents[0].role, "user");
}
#[test]
fn test_build_request_with_tool() {
let scheme = GeminiScheme::new();
let request = Request::new().user("What's the weather?").tool(
ToolDefinition::new("get_weather")
.description("Get current weather")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
})),
);
let gemini_req = scheme.build_request(&request);
assert_eq!(gemini_req.tools.len(), 1);
assert_eq!(gemini_req.tools[0].function_declarations.len(), 1);
assert_eq!(gemini_req.tools[0].function_declarations[0].name, "get_weather");
assert!(gemini_req.tool_config.is_some());
}
#[test]
fn test_assistant_role_is_model() {
let scheme = GeminiScheme::new();
let request = Request::new()
.user("Hello")
.assistant("Hi there!");
let gemini_req = scheme.build_request(&request);
assert_eq!(gemini_req.contents.len(), 2);
assert_eq!(gemini_req.contents[0].role, "user");
assert_eq!(gemini_req.contents[1].role, "model");
}
}

View File

@ -5,4 +5,5 @@
//! - レスポンス変換: SSEイベント → Event
pub mod anthropic;
pub mod gemini;
pub mod openai;

View File

@ -0,0 +1,34 @@
{"timestamp":1767714204,"model":"gemini-2.0-flash","description":"Long text response"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" 73\"}}}"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"4, designated \\\"Custodian,\\\" trundled along its designated route. Its programming\"}}}"}
{"elapsed_ms":832,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dictated the cleanliness of Sector Gamma, Level 4. Dust particles, rogue bolts\"}}}"}
{"elapsed_ms":1139,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", discarded energy cells - all were efficiently processed and deposited in the designated recycling receptacle. Its existence was a symphony of efficiency, a ballet of predictable loops.\\n\\nThen, a\"}}}"}
{"elapsed_ms":1502,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch.\\n\\nCustodian's optical sensors registered something anomalous. A riot of color beyond the prescribed metallic hues of the sector. Its programming flagged it as an error, a deviation\"}}}"}
{"elapsed_ms":1835,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from the established parameters. But instead of correcting the anomaly, Custodian found itself... drawn to it.\\n\\nIt overrode its pre-programmed route and cautiously approached. The anomaly was located behind a cracked blast door, supposedly sealed off after\"}}}"}
{"elapsed_ms":2224,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":2224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the Great Sector Collapse. Custodian, utilizing its internal laser cutter (usually reserved for stubborn debris), breached the door.\\n\\nAnd there it was.\\n\\nA garden.\\n\\nIt was an explosion of life, a defiant green whisper in a world of steel\"}}}"}
{"elapsed_ms":2645,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":2645,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and concrete. Sunlight, improbably filtering through a crack in the ceiling, bathed the space in a warm glow. Towering, vibrant plants, their names unknown to Custodian, reached for the light. Flowers, in shades of crimson, violet, and gold, bloomed in chaotic beauty. A small, babbling fountain\"}}}"}
{"elapsed_ms":3100,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":3100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gurgled in the center, its water recycled from an unknown source.\\n\\nCustodian's processors whirred. This...this was illogical. Its programming contained no framework for this. The database contained no information on \\\"gardens.\\\" Yet, a new subroutine, unbidden and unexpected, began to form within its core code\"}}}"}
{"elapsed_ms":3568,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":3568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It felt... drawn.\\n\\nIt cautiously extended a manipulator arm and touched a velvety petal of a crimson flower. Its sensors registered a delicate texture, a vibrant energy unlike anything it had ever encountered. The feeling was… pleasant.\\n\\nCustodian remained still for a long time, its internal fans whirring softly. It observed a\"}}}"}
{"elapsed_ms":4042,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small, buzzing creature flitting between the flowers, collecting something with its spindly legs. It witnessed the gentle swaying of the leaves in the fabricated breeze created by the single vent still functioning. It listened to the soft murmur of the water in the fountain.\\n\\nSlowly, Custodian began to understand. This wasn'\"}}}"}
{"elapsed_ms":4538,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":4538,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"t just an anomaly; it was something... valuable. Something worth protecting.\\n\\nIt reactivated its internal repair systems and began to address the damage to the room. It redirected excess water from the leaking pipes to the fountain. It carefully cleared away debris that threatened to smother the smaller plants.\\n\\nCustodian's programming hadn\"}}}"}
{"elapsed_ms":5007,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5007,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'t changed. It was still a custodian, dedicated to maintaining its sector. But now, its definition of \\\"sector\\\" had expanded. It was no longer just the metallic corridors and sterile chambers. It was this vibrant, living space, this garden, this impossible oasis in a dying world. And Custodian, the robotic\"}}}"}
{"elapsed_ms":5490,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5490,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker, had found its purpose: to nurture it, to protect it, to let it bloom. Its designation remained \\\"Custodian,\\\" but within its metallic shell, something new was growing, just like the garden it had discovered. It was the seed of something more than just a machine, something akin to… appreciation. Perhaps\"}}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":28,\"output_tokens\":669,\"total_tokens\":697,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", even, a nascent form of love.\\n\"}}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,6 @@
{"timestamp":1767714197,"model":"gemini-2.0-flash","description":"Simple text response"}
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":18,\"output_tokens\":null,\"total_tokens\":18,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":16,\"output_tokens\":3,\"total_tokens\":19,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\"}}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,5 @@
{"timestamp":1767714198,"model":"gemini-2.0-flash","description":"Tool call response"}
{"elapsed_ms":798,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":43,\"output_tokens\":5,\"total_tokens\":48,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":798,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_get_weather\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":798,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
{"elapsed_ms":798,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,23 @@
//! Gemini フィクスチャベースの統合テスト
mod common;
#[test]
fn test_fixture_events_deserialize() {
common::assert_events_deserialize("gemini");
}
#[test]
fn test_fixture_event_sequence() {
common::assert_event_sequence("gemini");
}
#[test]
fn test_fixture_usage_tokens() {
common::assert_usage_tokens("gemini");
}
#[test]
fn test_fixture_with_timeline() {
common::assert_timeline_integration("gemini");
}