feat: Implement AnthropicClient

This commit is contained in:
Keisuke Hirata 2026-01-06 00:25:08 +09:00
parent 6d6ae24ffe
commit 9a7acb74c8
23 changed files with 3783 additions and 22 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
ANTHROPIC_API_KEY=your_api_key

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target /target
.direnv .direnv
.env

1846
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
/// llm_client層が出力するフラットなイベント列挙 /// llm_client層が出力するフラットなイベント列挙
/// ///
/// Timeline層がこのイベントストリームを受け取り、ブロック構造化を行う /// Timeline層がこのイベントストリームを受け取り、ブロック構造化を行う
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event { pub enum Event {
// Meta events (not tied to a block) // Meta events (not tied to a block)
Ping(PingEvent), Ping(PingEvent),
@ -31,7 +31,7 @@ pub enum Event {
// ============================================================================= // =============================================================================
/// Pingイベントハートビート /// Pingイベントハートビート
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent { pub struct PingEvent {
pub timestamp: Option<u64>, pub timestamp: Option<u64>,
} }
@ -52,13 +52,13 @@ pub struct UsageEvent {
} }
/// ステータスイベント /// ステータスイベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent { pub struct StatusEvent {
pub status: ResponseStatus, pub status: ResponseStatus,
} }
/// レスポンスステータス /// レスポンスステータス
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus { pub enum ResponseStatus {
/// ストリーム開始 /// ストリーム開始
Started, Started,
@ -71,7 +71,7 @@ pub enum ResponseStatus {
} }
/// エラーイベント /// エラーイベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent { pub struct ErrorEvent {
pub code: Option<String>, pub code: Option<String>,
pub message: String, pub message: String,
@ -82,7 +82,7 @@ pub struct ErrorEvent {
// ============================================================================= // =============================================================================
/// ブロックの種別 /// ブロックの種別
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType { pub enum BlockType {
/// テキスト生成 /// テキスト生成
Text, Text,
@ -95,7 +95,7 @@ pub enum BlockType {
} }
/// ブロック開始イベント /// ブロック開始イベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart { pub struct BlockStart {
/// ブロックのインデックス /// ブロックのインデックス
pub index: usize, pub index: usize,
@ -112,7 +112,7 @@ impl BlockStart {
} }
/// ブロックのメタデータ /// ブロックのメタデータ
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata { pub enum BlockMetadata {
Text, Text,
Thinking, Thinking,
@ -121,7 +121,7 @@ pub enum BlockMetadata {
} }
/// ブロックデルタイベント /// ブロックデルタイベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta { pub struct BlockDelta {
/// ブロックのインデックス /// ブロックのインデックス
pub index: usize, pub index: usize,
@ -130,7 +130,7 @@ pub struct BlockDelta {
} }
/// デルタの内容 /// デルタの内容
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent { pub enum DeltaContent {
/// テキストデルタ /// テキストデルタ
Text(String), Text(String),
@ -152,7 +152,7 @@ impl DeltaContent {
} }
/// ブロック停止イベント /// ブロック停止イベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop { pub struct BlockStop {
/// ブロックのインデックス /// ブロックのインデックス
pub index: usize, pub index: usize,
@ -169,7 +169,7 @@ impl BlockStop {
} }
/// ブロック中断イベント /// ブロック中断イベント
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort { pub struct BlockAbort {
/// ブロックのインデックス /// ブロックのインデックス
pub index: usize, pub index: usize,
@ -186,7 +186,7 @@ impl BlockAbort {
} }
/// 停止理由 /// 停止理由
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason { pub enum StopReason {
/// 自然終了 /// 自然終了
EndTurn, EndTurn,

View File

@ -4,7 +4,16 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = "0.1.89"
eventsource-stream = "0.2.3"
futures = "0.3.31"
reqwest = { version = "0.13.1", features = ["stream", "json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
worker-macros = { path = "../worker-macros" } worker-macros = { path = "../worker-macros" }
worker-types = { path = "../worker-types" } worker-types = { path = "../worker-types" }
[dev-dependencies]
tempfile = "3.24.0"

View File

@ -0,0 +1,176 @@
//! LLMクライアント + Timeline統合サンプル
//!
//! Anthropic Claude APIにリクエストを送信し、Timelineでイベントを処理するサンプル
//!
//! ## 使用方法
//!
//! ```bash
//! # .envファイルにAPIキーを設定
//! echo "ANTHROPIC_API_KEY=your-api-key" > .env
//!
//! # 実行
//! cargo run --example llm_client_anthropic
//! ```
use std::sync::{Arc, Mutex};
use futures::StreamExt;
use worker::{
Handler, TextBlockEvent, TextBlockKind, Timeline, ToolUseBlockEvent, ToolUseBlockKind,
UsageEvent, UsageKind,
llm_client::{LlmClient, Request, providers::anthropic::AnthropicClient},
};
/// テキスト出力をリアルタイムで表示するハンドラー
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("ANTHROPIC_API_KEY")
.expect("ANTHROPIC_API_KEY environment variable must be set");
println!("=== LLM Client + Timeline Integration Example ===\n");
// クライアントを作成
let client = AnthropicClient::new(api_key, "claude-sonnet-4-20250514");
// 共有状態
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

@ -0,0 +1,118 @@
//! APIレスポンス記録ツール
//!
//! 実際のAnthropicAPIからのレスポンスをファイルに記録する。
//! 後でテストフィクスチャとして使用可能。
//!
//! ## 使用方法
//!
//! ```bash
//! # 記録モード (APIを呼び出して記録)
//! ANTHROPIC_API_KEY=your-key cargo run --example record_anthropic
//!
//! # 記録されたファイルは worker/tests/fixtures/ に保存される
//! ```
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use worker::llm_client::{LlmClient, Request, providers::anthropic::AnthropicClient};
/// 記録されたSSEイベント
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct RecordedEvent {
elapsed_ms: u64,
event_type: String,
data: String,
}
/// セッションメタデータ
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct SessionMetadata {
timestamp: u64,
model: String,
description: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.expect("ANTHROPIC_API_KEY environment variable must be set");
let model = "claude-sonnet-4-20250514";
let description = "Simple greeting test";
println!("=== Anthropic API Response Recorder ===\n");
println!("Model: {}", model);
println!("Description: {}\n", description);
// クライアントを作成
let client = AnthropicClient::new(&api_key, model);
// シンプルなリクエスト
let request = Request::new()
.system("You are a helpful assistant. Be very concise.")
.user("Say hello in one word.")
.max_tokens(50);
println!("📤 Sending request...\n");
// レスポンスを記録
let start_time = Instant::now();
let mut events: Vec<RecordedEvent> = Vec::new();
let mut stream = client.stream(request).await?;
while let Some(result) = stream.next().await {
let elapsed = start_time.elapsed().as_millis() as u64;
match result {
Ok(event) => {
// Eventをシリアライズして記録
let event_json = serde_json::to_string(&event)?;
println!("[{:>6}ms] {:?}", elapsed, event);
events.push(RecordedEvent {
elapsed_ms: elapsed,
event_type: format!("{:?}", std::mem::discriminant(&event)),
data: event_json,
});
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
}
}
println!("\n📊 Recorded {} events", events.len());
// ファイルに保存
let fixtures_dir = Path::new("worker/tests/fixtures");
fs::create_dir_all(fixtures_dir)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let filename = format!("anthropic_{}.jsonl", timestamp);
let filepath = fixtures_dir.join(&filename);
let file = File::create(&filepath)?;
let mut writer = BufWriter::new(file);
// メタデータを書き込み
let metadata = SessionMetadata {
timestamp,
model: model.to_string(),
description: description.to_string(),
};
writeln!(writer, "{}", serde_json::to_string(&metadata)?)?;
// イベントを書き込み
for event in &events {
writeln!(writer, "{}", serde_json::to_string(event)?)?;
}
writer.flush()?;
println!("💾 Saved to: {}", filepath.display());
Ok(())
}

View File

@ -3,8 +3,8 @@
//! 設計ドキュメントに基づいたTimelineの使用パターンを示すサンプル //! 設計ドキュメントに基づいたTimelineの使用パターンを示すサンプル
use worker::{ use worker::{
Event, Handler, TextBlockEvent, TextBlockKind, Timeline, Event, Handler, TextBlockEvent, TextBlockKind, Timeline, ToolUseBlockEvent, ToolUseBlockKind,
ToolUseBlockEvent, ToolUseBlockKind, UsageEvent, UsageKind, UsageEvent, UsageKind,
}; };
fn main() { fn main() {
@ -81,7 +81,9 @@ struct TextCollector {
impl TextCollector { impl TextCollector {
fn new() -> Self { fn new() -> Self {
Self { results: Vec::new() } Self {
results: Vec::new(),
}
} }
} }

View File

@ -2,8 +2,10 @@
//! //!
//! このクレートは以下を提供します: //! このクレートは以下を提供します:
//! - Timeline: イベントストリームの状態管理とハンドラーへのディスパッチ //! - Timeline: イベントストリームの状態管理とハンドラーへのディスパッチ
//! - LlmClient: LLMプロバイダとの通信
//! - 型消去されたHandler実装 //! - 型消去されたHandler実装
pub mod llm_client;
mod timeline; mod timeline;
pub use timeline::*; pub use timeline::*;

View File

@ -0,0 +1,28 @@
//! LLMクライアント共通trait定義
use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use worker_types::Event;
use crate::llm_client::{ClientError, Request};
/// LLMクライアントのtrait
///
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
#[async_trait]
pub trait LlmClient: Send + Sync {
/// ストリーミングリクエストを送信し、Eventストリームを返す
///
/// # Arguments
/// * `request` - リクエスト情報
///
/// # Returns
/// * `Ok(Stream)` - イベントストリーム
/// * `Err(ClientError)` - エラー
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>;
}

View File

@ -0,0 +1,69 @@
//! LLMクライアントエラー型
use std::fmt;
/// LLMクライアントのエラー
#[derive(Debug)]
pub enum ClientError {
/// HTTPリクエストエラー
Http(reqwest::Error),
/// JSONパースエラー
Json(serde_json::Error),
/// SSEパースエラー
Sse(String),
/// APIエラー (プロバイダからのエラーレスポンス)
Api {
status: Option<u16>,
code: Option<String>,
message: String,
},
/// 設定エラー
Config(String),
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Http(e) => write!(f, "HTTP error: {}", e),
ClientError::Json(e) => write!(f, "JSON parse error: {}", e),
ClientError::Sse(msg) => write!(f, "SSE parse error: {}", msg),
ClientError::Api {
status,
code,
message,
} => {
write!(f, "API error")?;
if let Some(s) = status {
write!(f, " (status: {})", s)?;
}
if let Some(c) = code {
write!(f, " [{}]", c)?;
}
write!(f, ": {}", message)
}
ClientError::Config(msg) => write!(f, "Config error: {}", msg),
}
}
}
impl std::error::Error for ClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ClientError::Http(e) => Some(e),
ClientError::Json(e) => Some(e),
_ => None,
}
}
}
impl From<reqwest::Error> for ClientError {
fn from(err: reqwest::Error) -> Self {
ClientError::Http(err)
}
}
impl From<serde_json::Error> for ClientError {
fn from(err: serde_json::Error) -> Self {
ClientError::Json(err)
}
}

View File

@ -0,0 +1,24 @@
//! LLMクライアント層
//!
//! LLMプロバイダと通信し、統一された`Event`ストリームを出力する。
//!
//! # アーキテクチャ
//!
//! - **client**: `LlmClient` trait定義
//! - **scheme**: APIスキーマリクエスト/レスポンス変換)
//! - **providers**: プロバイダ固有のHTTPクライアント実装
//! - **testing**: テスト用のAPIレスポンス記録・再生機能
pub mod client;
pub mod error;
pub mod types;
pub mod providers;
pub(crate) mod scheme;
#[cfg(test)]
pub mod testing;
pub use client::*;
pub use error::*;
pub use types::*;

View File

@ -0,0 +1,193 @@
//! Anthropic プロバイダ実装
//!
//! Anthropic Messages 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::anthropic::AnthropicScheme};
/// Anthropic クライアント
pub struct AnthropicClient {
/// HTTPクライアント
http_client: reqwest::Client,
/// APIキー
api_key: String,
/// モデル名
model: String,
/// スキーマ
scheme: AnthropicScheme,
/// ベースURL
base_url: String,
}
impl AnthropicClient {
/// 新しいAnthropicクライアントを作成
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: AnthropicScheme::default(),
base_url: "https://api.anthropic.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: AnthropicScheme) -> 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"));
headers.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key)
.map_err(|e| ClientError::Config(format!("Invalid API key: {}", e)))?,
);
headers.insert(
"anthropic-version",
HeaderValue::from_str(&self.scheme.api_version)
.map_err(|e| ClientError::Config(format!("Invalid API version: {}", e)))?,
);
// 細粒度ツールストリーミングを有効にする場合
if self.scheme.fine_grained_tool_streaming {
headers.insert(
"anthropic-beta",
HeaderValue::from_static("fine-grained-tool-streaming-2025-05-14"),
);
}
Ok(headers)
}
}
#[async_trait]
impl LlmClient for AnthropicClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let url = format!("{}/v1/messages", self.base_url);
let headers = self.build_headers()?;
let body = self.scheme.build_request(&self.model, &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) {
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").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();
// 現在のブロックタイプを追跡するための状態
// Note: Streamではmutableな状態を直接保持できないため、
// BlockStopイベントでblock_typeを正しく設定するには追加の処理が必要
let stream = event_stream.map(move |result| {
match result {
Ok(event) => {
// SSEイベントをパース
match scheme.parse_event(&event.event, &event.data) {
Ok(Some(evt)) => Ok(evt),
Ok(None) => {
// イベントを無視空のStatusで代用し、後でフィルタ
// 実際にはOption<Event>を返すべきだが、Stream型の都合上こうする
Ok(Event::Ping(worker_types::PingEvent { timestamp: None }))
}
Err(e) => Err(e),
}
}
Err(e) => Err(ClientError::Sse(e.to_string())),
}
});
Ok(Box::pin(stream))
}
}
impl Clone for AnthropicScheme {
fn clone(&self) -> Self {
Self {
api_version: self.api_version.clone(),
fine_grained_tool_streaming: self.fine_grained_tool_streaming,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
assert_eq!(client.model, "claude-sonnet-4-20250514");
}
#[test]
fn test_build_headers() {
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
let headers = client.build_headers().unwrap();
assert!(headers.contains_key("x-api-key"));
assert!(headers.contains_key("anthropic-version"));
assert!(headers.contains_key("anthropic-beta"));
}
}

View File

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

View File

@ -0,0 +1,372 @@
//! Anthropic SSEイベントパース
//!
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
use serde::Deserialize;
use worker_types::{
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent, Event,
PingEvent, ResponseStatus, StatusEvent, UsageEvent,
};
use crate::llm_client::ClientError;
use super::AnthropicScheme;
/// Anthropic SSEイベントタイプ
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnthropicEventType {
MessageStart,
ContentBlockStart,
ContentBlockDelta,
ContentBlockStop,
MessageDelta,
MessageStop,
Ping,
Error,
}
impl AnthropicEventType {
/// イベントタイプ文字列からパース
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"message_start" => Some(Self::MessageStart),
"content_block_start" => Some(Self::ContentBlockStart),
"content_block_delta" => Some(Self::ContentBlockDelta),
"content_block_stop" => Some(Self::ContentBlockStop),
"message_delta" => Some(Self::MessageDelta),
"message_stop" => Some(Self::MessageStop),
"ping" => Some(Self::Ping),
"error" => Some(Self::Error),
_ => None,
}
}
}
// ============================================================================
// SSEイベントのJSON構造
// ============================================================================
/// message_start イベント
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartEvent {
pub message: MessageStartMessage,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartMessage {
pub id: String,
pub model: String,
pub usage: Option<UsageData>,
}
/// content_block_start イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStartEvent {
pub index: usize,
pub content_block: ContentBlock,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
/// content_block_delta イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockDeltaEvent {
pub index: usize,
pub delta: DeltaBlock,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum DeltaBlock {
#[serde(rename = "text_delta")]
TextDelta { text: String },
#[serde(rename = "thinking_delta")]
ThinkingDelta { thinking: String },
#[serde(rename = "input_json_delta")]
InputJsonDelta { partial_json: String },
#[serde(rename = "signature_delta")]
SignatureDelta { signature: String },
}
/// content_block_stop イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStopEvent {
pub index: usize,
}
/// message_delta イベント
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaEvent {
pub delta: MessageDeltaData,
pub usage: Option<UsageData>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaData {
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
}
/// 使用量データ
#[derive(Debug, Deserialize)]
pub(crate) struct UsageData {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
/// エラーイベント
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEventData {
pub error: ErrorDetail,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorDetail {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
}
// ============================================================================
// イベント変換
// ============================================================================
impl AnthropicScheme {
/// SSEイベントをEvent型に変換
///
/// # Arguments
/// * `event_type` - SSEイベントタイプ
/// * `data` - イベントデータJSON文字列
///
/// # Returns
/// * `Ok(Some(Event))` - 変換成功
/// * `Ok(None)` - イベントを無視unknown event等
/// * `Err(ClientError)` - パースエラー
pub(crate) fn parse_event(
&self,
event_type: &str,
data: &str,
) -> Result<Option<Event>, ClientError> {
let Some(event_type) = AnthropicEventType::parse(event_type) else {
// Unknown event type, ignore
return Ok(None);
};
match event_type {
AnthropicEventType::MessageStart => {
let event: MessageStartEvent = serde_json::from_str(data)?;
// message_start時にUsageイベントがあれば出力
if let Some(usage) = event.message.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
// Statusイベントとして開始を通知
Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Started,
})))
}
AnthropicEventType::ContentBlockStart => {
let event: ContentBlockStartEvent = serde_json::from_str(data)?;
Ok(Some(self.convert_block_start(&event)))
}
AnthropicEventType::ContentBlockDelta => {
let event: ContentBlockDeltaEvent = serde_json::from_str(data)?;
Ok(self.convert_block_delta(&event))
}
AnthropicEventType::ContentBlockStop => {
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
// Note: BlockStopにはblock_typeが必要だが、ここでは追跡していない
// プロバイダ層で状態を追跡する必要がある
Ok(Some(Event::BlockStop(BlockStop {
index: event.index,
block_type: BlockType::Text, // プロバイダ層で上書きされる
stop_reason: None,
})))
}
AnthropicEventType::MessageDelta => {
let event: MessageDeltaEvent = serde_json::from_str(data)?;
// Usage情報があれば出力
if let Some(usage) = event.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
Ok(None)
}
AnthropicEventType::MessageStop => Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}))),
AnthropicEventType::Ping => Ok(Some(Event::Ping(PingEvent { timestamp: None }))),
AnthropicEventType::Error => {
let event: ErrorEventData = serde_json::from_str(data)?;
Ok(Some(Event::Error(ErrorEvent {
code: Some(event.error.error_type),
message: event.error.message,
})))
}
}
}
fn convert_block_start(&self, event: &ContentBlockStartEvent) -> Event {
let (block_type, metadata) = match &event.content_block {
ContentBlock::Text { .. } => (BlockType::Text, BlockMetadata::Text),
ContentBlock::Thinking { .. } => (BlockType::Thinking, BlockMetadata::Thinking),
ContentBlock::ToolUse { id, name, .. } => (
BlockType::ToolUse,
BlockMetadata::ToolUse {
id: id.clone(),
name: name.clone(),
},
),
};
Event::BlockStart(BlockStart {
index: event.index,
block_type,
metadata,
})
}
fn convert_block_delta(&self, event: &ContentBlockDeltaEvent) -> Option<Event> {
let delta = match &event.delta {
DeltaBlock::TextDelta { text } => DeltaContent::Text(text.clone()),
DeltaBlock::ThinkingDelta { thinking } => DeltaContent::Thinking(thinking.clone()),
DeltaBlock::InputJsonDelta { partial_json } => {
DeltaContent::InputJson(partial_json.clone())
}
DeltaBlock::SignatureDelta { .. } => {
// signature_delta は無視
return None;
}
};
Some(Event::BlockDelta(BlockDelta {
index: event.index,
delta,
}))
}
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
let input = usage.input_tokens.unwrap_or(0);
let output = usage.output_tokens.unwrap_or(0);
UsageEvent {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: Some(input + output),
cache_read_input_tokens: usage.cache_read_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_message_start() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#;
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
match event {
Event::Usage(u) => {
assert_eq!(u.input_tokens, Some(10));
}
_ => panic!("Expected Usage event"),
}
}
#[test]
fn test_parse_content_block_start_text() {
let scheme = AnthropicScheme::new();
let data =
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
let event = scheme
.parse_event("content_block_start", data)
.unwrap()
.unwrap();
match event {
Event::BlockStart(s) => {
assert_eq!(s.index, 0);
assert_eq!(s.block_type, BlockType::Text);
}
_ => panic!("Expected BlockStart event"),
}
}
#[test]
fn test_parse_content_block_delta_text() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event = scheme
.parse_event("content_block_delta", data)
.unwrap()
.unwrap();
match event {
Event::BlockDelta(d) => {
assert_eq!(d.index, 0);
match d.delta {
DeltaContent::Text(t) => assert_eq!(t, "Hello"),
_ => panic!("Expected Text delta"),
}
}
_ => panic!("Expected BlockDelta event"),
}
}
#[test]
fn test_parse_tool_use_start() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_123","name":"get_weather","input":{}}}"#;
let event = scheme
.parse_event("content_block_start", data)
.unwrap()
.unwrap();
match event {
Event::BlockStart(s) => {
assert_eq!(s.block_type, BlockType::ToolUse);
match s.metadata {
BlockMetadata::ToolUse { id, name } => {
assert_eq!(id, "toolu_123");
assert_eq!(name, "get_weather");
}
_ => panic!("Expected ToolUse metadata"),
}
}
_ => panic!("Expected BlockStart event"),
}
}
#[test]
fn test_parse_ping() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"ping"}"#;
let event = scheme.parse_event("ping", data).unwrap().unwrap();
match event {
Event::Ping(_) => {}
_ => panic!("Expected Ping event"),
}
}
}

View File

@ -0,0 +1,39 @@
//! Anthropic Messages API スキーマ
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod events;
mod request;
/// Anthropicスキーマ
///
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
pub struct AnthropicScheme {
/// APIバージョン
pub api_version: String,
/// 細粒度ツールストリーミングを有効にするか
pub fine_grained_tool_streaming: bool,
}
impl Default for AnthropicScheme {
fn default() -> Self {
Self {
api_version: "2023-06-01".to_string(),
fine_grained_tool_streaming: true,
}
}
}
impl AnthropicScheme {
/// 新しいスキーマを作成
pub fn new() -> Self {
Self::default()
}
/// 細粒度ツールストリーミングを有効/無効にする
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
self.fine_grained_tool_streaming = enabled;
self
}
}

View File

@ -0,0 +1,195 @@
//! Anthropic リクエスト生成
use serde::Serialize;
use crate::llm_client::{
Request,
types::{ContentPart, Message, MessageContent, Role, ToolDefinition},
};
use super::AnthropicScheme;
/// Anthropic APIへのリクエストボディ
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicRequest {
pub model: String,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
pub messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<AnthropicTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
pub stream: bool,
}
/// Anthropic メッセージ
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicMessage {
pub role: String,
pub content: AnthropicContent,
}
/// Anthropic コンテンツ
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum AnthropicContent {
Text(String),
Parts(Vec<AnthropicContentPart>),
}
/// Anthropic コンテンツパーツ
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub(crate) enum AnthropicContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
},
}
/// Anthropic ツール定義
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
impl AnthropicScheme {
/// RequestからAnthropicのリクエストボディを構築
pub(crate) fn build_request(&self, model: &str, request: &Request) -> AnthropicRequest {
let messages = request
.messages
.iter()
.map(|m| self.convert_message(m))
.collect();
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
AnthropicRequest {
model: model.to_string(),
max_tokens: request.config.max_tokens.unwrap_or(4096),
system: request.system_prompt.clone(),
messages,
tools,
temperature: request.config.temperature,
top_p: request.config.top_p,
stop_sequences: request.config.stop_sequences.clone(),
stream: true,
}
}
fn convert_message(&self, message: &Message) -> AnthropicMessage {
let role = match message.role {
Role::User => "user",
Role::Assistant => "assistant",
};
let content = match &message.content {
MessageContent::Text(text) => AnthropicContent::Text(text.clone()),
MessageContent::ToolResult {
tool_use_id,
content,
} => AnthropicContent::Parts(vec![AnthropicContentPart::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
}]),
MessageContent::Parts(parts) => {
let converted: Vec<_> = parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => {
AnthropicContentPart::Text { text: text.clone() }
}
ContentPart::ToolUse { id, name, input } => AnthropicContentPart::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
},
ContentPart::ToolResult {
tool_use_id,
content,
} => AnthropicContentPart::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
},
})
.collect();
AnthropicContent::Parts(converted)
}
};
AnthropicMessage {
role: role.to_string(),
content,
}
}
fn convert_tool(&self, tool: &ToolDefinition) -> AnthropicTool {
AnthropicTool {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_simple_request() {
let scheme = AnthropicScheme::new();
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514");
assert_eq!(
anthropic_req.system,
Some("You are a helpful assistant.".to_string())
);
assert_eq!(anthropic_req.messages.len(), 1);
assert!(anthropic_req.stream);
}
#[test]
fn test_build_request_with_tool() {
let scheme = AnthropicScheme::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 anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
assert_eq!(anthropic_req.tools.len(), 1);
assert_eq!(anthropic_req.tools[0].name, "get_weather");
}
}

View File

@ -0,0 +1,7 @@
//! APIスキーマ定義
//!
//! 各APIスキーマごとの変換ロジック
//! - リクエスト変換: Request → プロバイダ固有JSON
//! - レスポンス変換: SSEイベント → Event
pub mod anthropic;

View File

@ -0,0 +1,238 @@
//! テスト用のAPIレスポンス記録・再生機能
//!
//! 実際のAPIレスポンスをタイムスタンプ付きで記録し、
//! テスト時に再生できるようにする。
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
/// 記録されたSSEイベント
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedEvent {
/// イベント受信からの経過時間 (ミリ秒)
pub elapsed_ms: u64,
/// SSEイベントタイプ
pub event_type: String,
/// SSEイベントデータ
pub data: String,
}
/// セッションメタデータ
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
/// 記録開始タイムスタンプ (Unix epoch秒)
pub timestamp: u64,
/// モデル名
pub model: String,
/// リクエストの説明
pub description: String,
}
/// SSEイベントレコーダー
///
/// 実際のAPIレスポンスを記録し、後でテストに使用できるようにする
pub struct EventRecorder {
start_time: Instant,
events: Vec<RecordedEvent>,
metadata: SessionMetadata,
}
impl EventRecorder {
/// 新しいレコーダーを作成
pub fn new(model: impl Into<String>, description: impl Into<String>) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
start_time: Instant::now(),
events: Vec::new(),
metadata: SessionMetadata {
timestamp,
model: model.into(),
description: description.into(),
},
}
}
/// イベントを記録
pub fn record(&mut self, event_type: &str, data: &str) {
let elapsed = self.start_time.elapsed();
self.events.push(RecordedEvent {
elapsed_ms: elapsed.as_millis() as u64,
event_type: event_type.to_string(),
data: data.to_string(),
});
}
/// 記録をファイルに保存
///
/// フォーマット: JSONL (1行目: metadata, 2行目以降: events)
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
// メタデータを書き込み
let metadata_json = serde_json::to_string(&self.metadata)?;
writeln!(writer, "{}", metadata_json)?;
// イベントを書き込み
for event in &self.events {
let event_json = serde_json::to_string(event)?;
writeln!(writer, "{}", event_json)?;
}
writer.flush()?;
Ok(())
}
/// 記録されたイベント数を取得
pub fn event_count(&self) -> usize {
self.events.len()
}
}
/// SSEイベントプレイヤー
///
/// 記録されたイベントを読み込み、テストで使用する
pub struct EventPlayer {
metadata: SessionMetadata,
events: Vec<RecordedEvent>,
current_index: usize,
}
impl EventPlayer {
/// ファイルから読み込み
pub fn load(path: impl AsRef<Path>) -> std::io::Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
// メタデータを読み込み
let metadata_line = lines
.next()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "Empty file"))??;
let metadata: SessionMetadata = serde_json::from_str(&metadata_line)?;
// イベントを読み込み
let mut events = Vec::new();
for line in lines {
let line = line?;
if !line.is_empty() {
let event: RecordedEvent = serde_json::from_str(&line)?;
events.push(event);
}
}
Ok(Self {
metadata,
events,
current_index: 0,
})
}
/// メタデータを取得
pub fn metadata(&self) -> &SessionMetadata {
&self.metadata
}
/// 全イベントを取得
pub fn events(&self) -> &[RecordedEvent] {
&self.events
}
/// イベント数を取得
pub fn event_count(&self) -> usize {
self.events.len()
}
/// 次のイベントを取得Iterator的に使用
pub fn next_event(&mut self) -> Option<&RecordedEvent> {
if self.current_index < self.events.len() {
let event = &self.events[self.current_index];
self.current_index += 1;
Some(event)
} else {
None
}
}
/// インデックスをリセット
pub fn reset(&mut self) {
self.current_index = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_record_and_playback() {
// レコーダーを作成して記録
let mut recorder = EventRecorder::new("claude-sonnet-4-20250514", "Test recording");
recorder.record("message_start", r#"{"type":"message_start"}"#);
recorder.record(
"content_block_start",
r#"{"type":"content_block_start","index":0}"#,
);
recorder.record(
"content_block_delta",
r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}"#,
);
// 一時ファイルに保存
let temp_file = NamedTempFile::new().unwrap();
recorder.save(temp_file.path()).unwrap();
// 読み込んで確認
let player = EventPlayer::load(temp_file.path()).unwrap();
assert_eq!(player.metadata().model, "claude-sonnet-4-20250514");
assert_eq!(player.event_count(), 3);
assert_eq!(player.events()[0].event_type, "message_start");
assert_eq!(player.events()[2].event_type, "content_block_delta");
}
#[test]
fn test_player_iteration() {
// テストデータを直接作成
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(
temp_file,
r#"{{"timestamp":1704067200,"model":"test","description":"test"}}"#
)
.unwrap();
writeln!(
temp_file,
r#"{{"elapsed_ms":0,"event_type":"ping","data":"{{}}"}}"#
)
.unwrap();
writeln!(
temp_file,
r#"{{"elapsed_ms":100,"event_type":"message_stop","data":"{{}}"}}"#
)
.unwrap();
temp_file.flush().unwrap();
let mut player = EventPlayer::load(temp_file.path()).unwrap();
let first = player.next_event().unwrap();
assert_eq!(first.event_type, "ping");
let second = player.next_event().unwrap();
assert_eq!(second.event_type, "message_stop");
assert!(player.next_event().is_none());
// リセット後は最初から
player.reset();
assert_eq!(player.next_event().unwrap().event_type, "ping");
}
}

View File

@ -0,0 +1,198 @@
//! LLMクライアント共通型定義
use serde::{Deserialize, Serialize};
/// リクエスト構造体
#[derive(Debug, Clone, Default)]
pub struct Request {
/// システムプロンプト
pub system_prompt: Option<String>,
/// メッセージ履歴
pub messages: Vec<Message>,
/// ツール定義
pub tools: Vec<ToolDefinition>,
/// リクエスト設定
pub config: RequestConfig,
}
impl Request {
/// 新しいリクエストを作成
pub fn new() -> Self {
Self::default()
}
/// システムプロンプトを設定
pub fn system(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
/// ユーザーメッセージを追加
pub fn user(mut self, content: impl Into<String>) -> Self {
self.messages.push(Message::user(content));
self
}
/// アシスタントメッセージを追加
pub fn assistant(mut self, content: impl Into<String>) -> Self {
self.messages.push(Message::assistant(content));
self
}
/// メッセージを追加
pub fn message(mut self, message: Message) -> Self {
self.messages.push(message);
self
}
/// ツールを追加
pub fn tool(mut self, tool: ToolDefinition) -> Self {
self.tools.push(tool);
self
}
/// 設定を適用
pub fn config(mut self, config: RequestConfig) -> Self {
self.config = config;
self
}
/// max_tokensを設定
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
self.config.max_tokens = Some(max_tokens);
self
}
}
/// メッセージ
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
/// ロール
pub role: Role,
/// コンテンツ
pub content: MessageContent,
}
impl Message {
/// ユーザーメッセージを作成
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: MessageContent::Text(content.into()),
}
}
/// アシスタントメッセージを作成
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: MessageContent::Text(content.into()),
}
}
/// ツール結果メッセージを作成
pub fn tool_result(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: MessageContent::ToolResult {
tool_use_id: tool_use_id.into(),
content: content.into(),
},
}
}
}
/// ロール
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
/// メッセージコンテンツ
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
/// テキストコンテンツ
Text(String),
/// ツール結果
ToolResult {
tool_use_id: String,
content: String,
},
/// 複合コンテンツ (テキスト + ツール使用等)
Parts(Vec<ContentPart>),
}
/// コンテンツパーツ
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentPart {
/// テキスト
#[serde(rename = "text")]
Text { text: String },
/// ツール使用
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
/// ツール結果
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
},
}
/// ツール定義
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
/// ツール名
pub name: String,
/// 説明
pub description: Option<String>,
/// 入力スキーマ (JSON Schema)
pub input_schema: serde_json::Value,
}
impl ToolDefinition {
/// 新しいツール定義を作成
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
}
}
/// 説明を設定
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
/// 入力スキーマを設定
pub fn input_schema(mut self, schema: serde_json::Value) -> Self {
self.input_schema = schema;
self
}
}
/// リクエスト設定
#[derive(Debug, Clone, Default)]
pub struct RequestConfig {
/// 最大トークン数
pub max_tokens: Option<u32>,
/// Temperature
pub temperature: Option<f32>,
/// Top P
pub top_p: Option<f32>,
/// ストップシーケンス
pub stop_sequences: Vec<String>,
}

View File

@ -121,7 +121,8 @@ where
fn dispatch_delta(&mut self, delta: &BlockDelta) { fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope { if let Some(scope) = &mut self.scope {
if let DeltaContent::Text(text) = &delta.delta { if let DeltaContent::Text(text) = &delta.delta {
self.handler.on_event(scope, &TextBlockEvent::Delta(text.clone())); self.handler
.on_event(scope, &TextBlockEvent::Delta(text.clone()));
} }
} }
} }
@ -189,7 +190,8 @@ where
fn dispatch_delta(&mut self, delta: &BlockDelta) { fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope { if let Some(scope) = &mut self.scope {
if let DeltaContent::Thinking(text) = &delta.delta { if let DeltaContent::Thinking(text) = &delta.delta {
self.handler.on_event(scope, &ThinkingBlockEvent::Delta(text.clone())); self.handler
.on_event(scope, &ThinkingBlockEvent::Delta(text.clone()));
} }
} }
} }
@ -510,7 +512,10 @@ impl Timeline {
self.current_block = None; self.current_block = None;
} }
fn get_block_handlers_mut(&mut self, block_type: BlockType) -> &mut Vec<Box<dyn ErasedBlockHandler>> { fn get_block_handlers_mut(
&mut self,
block_type: BlockType,
) -> &mut Vec<Box<dyn ErasedBlockHandler>> {
match block_type { match block_type {
BlockType::Text => &mut self.text_block_handlers, BlockType::Text => &mut self.text_block_handlers,
BlockType::Thinking => &mut self.thinking_block_handlers, BlockType::Thinking => &mut self.thinking_block_handlers,
@ -551,7 +556,9 @@ mod tests {
} }
let calls = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(Mutex::new(Vec::new()));
let handler = TestUsageHandler { calls: calls.clone() }; let handler = TestUsageHandler {
calls: calls.clone(),
};
let mut timeline = Timeline::new(); let mut timeline = Timeline::new();
timeline.on_usage(handler); timeline.on_usage(handler);

View File

@ -0,0 +1,228 @@
//! Anthropic フィクスチャベースの統合テスト
//!
//! 記録されたAPIレスポンスを使ってイベントパースをテストする
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use worker_types::{BlockType, DeltaContent, Event, ResponseStatus};
/// フィクスチャファイルからEventを読み込む
fn load_events_from_fixture(path: impl AsRef<Path>) -> Vec<Event> {
let file = File::open(path).expect("Failed to open fixture file");
let reader = BufReader::new(file);
let mut lines = reader.lines();
// 最初の行はメタデータ、スキップ
let _metadata = lines.next().expect("Empty fixture file").unwrap();
// 残りはイベント
let mut events = Vec::new();
for line in lines {
let line = line.unwrap();
if line.is_empty() {
continue;
}
// RecordedEvent構造体をパース
let recorded: serde_json::Value = serde_json::from_str(&line).unwrap();
let data = recorded["data"].as_str().unwrap();
// data フィールドからEventをデシリアライズ
let event: Event = serde_json::from_str(data).unwrap();
events.push(event);
}
events
}
/// フィクスチャディレクトリからanthropic_*ファイルを検索
fn find_anthropic_fixtures() -> Vec<std::path::PathBuf> {
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
if !fixtures_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&fixtures_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("anthropic_") && n.ends_with(".jsonl"))
})
.collect()
}
#[test]
fn test_fixture_events_deserialize() {
let fixtures = find_anthropic_fixtures();
assert!(!fixtures.is_empty(), "No anthropic fixtures found");
for fixture_path in fixtures {
println!("Testing fixture: {:?}", fixture_path);
let events = load_events_from_fixture(&fixture_path);
assert!(!events.is_empty(), "Fixture should contain events");
// 各イベントが正しくデシリアライズされているか確認
for event in &events {
// Debugトレイトで出力可能か確認
let _ = format!("{:?}", event);
}
println!(" Loaded {} events", events.len());
}
}
#[test]
fn test_fixture_event_sequence() {
let fixtures = find_anthropic_fixtures();
if fixtures.is_empty() {
println!("No fixtures found, skipping test");
return;
}
// 最初のフィクスチャをテスト
let events = load_events_from_fixture(&fixtures[0]);
// 期待されるイベントシーケンスを検証
// Usage -> BlockStart -> BlockDelta -> BlockStop -> Usage -> Status
// 最初のUsageイベント
assert!(
matches!(&events[0], Event::Usage(_)),
"First event should be Usage"
);
// BlockStartイベント
if let Event::BlockStart(start) = &events[1] {
assert_eq!(start.block_type, BlockType::Text);
assert_eq!(start.index, 0);
} else {
panic!("Second event should be BlockStart");
}
// BlockDeltaイベント
if let Event::BlockDelta(delta) = &events[2] {
assert_eq!(delta.index, 0);
if let DeltaContent::Text(text) = &delta.delta {
assert!(!text.is_empty(), "Delta text should not be empty");
println!(" Text content: {}", text);
} else {
panic!("Delta should be Text");
}
} else {
panic!("Third event should be BlockDelta");
}
// BlockStopイベント
if let Event::BlockStop(stop) = &events[3] {
assert_eq!(stop.block_type, BlockType::Text);
assert_eq!(stop.index, 0);
} else {
panic!("Fourth event should be BlockStop");
}
// 最後のStatusイベント
if let Event::Status(status) = events.last().unwrap() {
assert_eq!(status.status, ResponseStatus::Completed);
} else {
panic!("Last event should be Status(Completed)");
}
}
#[test]
fn test_fixture_usage_tokens() {
let fixtures = find_anthropic_fixtures();
if fixtures.is_empty() {
println!("No fixtures found, skipping test");
return;
}
let events = load_events_from_fixture(&fixtures[0]);
// Usageイベントを収集
let usage_events: Vec<_> = events
.iter()
.filter_map(|e| {
if let Event::Usage(u) = e {
Some(u)
} else {
None
}
})
.collect();
assert!(
!usage_events.is_empty(),
"Should have at least one Usage event"
);
// 最後のUsageイベントはトークン数を持つはず
let last_usage = usage_events.last().unwrap();
assert!(last_usage.input_tokens.is_some());
assert!(last_usage.output_tokens.is_some());
assert!(last_usage.total_tokens.is_some());
println!(
" Token usage: {} input, {} output, {} total",
last_usage.input_tokens.unwrap(),
last_usage.output_tokens.unwrap(),
last_usage.total_tokens.unwrap()
);
}
#[test]
fn test_fixture_with_timeline() {
use std::sync::{Arc, Mutex};
use worker::{Handler, TextBlockEvent, TextBlockKind, Timeline};
let fixtures = find_anthropic_fixtures();
if fixtures.is_empty() {
println!("No fixtures found, skipping test");
return;
}
let events = load_events_from_fixture(&fixtures[0]);
// テスト用ハンドラー
struct TestCollector {
texts: Arc<Mutex<Vec<String>>>,
}
impl Handler<TextBlockKind> for TestCollector {
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);
}
}
}
}
let collected = Arc::new(Mutex::new(Vec::new()));
let mut timeline = Timeline::new();
timeline.on_text_block(TestCollector {
texts: collected.clone(),
});
// フィクスチャからのイベントをTimelineにディスパッチ
for event in &events {
timeline.dispatch(event);
}
// テキストが収集されたことを確認
let texts = collected.lock().unwrap();
assert_eq!(texts.len(), 1, "Should have collected one text block");
assert!(!texts[0].is_empty(), "Collected text should not be empty");
println!(" Collected text: {}", texts[0]);
}

View File

@ -0,0 +1,7 @@
{"timestamp":1767624445,"model":"claude-sonnet-4-20250514","description":"Simple greeting test"}
{"elapsed_ms":1697,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":2,\"total_tokens\":26,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1697,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
{"elapsed_ms":1697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
{"elapsed_ms":1885,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":1929,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":5,\"total_tokens\":29,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1929,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}