feat: Implement AnthropicClient
This commit is contained in:
parent
6d6ae24ffe
commit
9a7acb74c8
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ANTHROPIC_API_KEY=your_api_key
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
/target
|
/target
|
||||||
.direnv
|
.direnv
|
||||||
|
.env
|
||||||
1846
Cargo.lock
generated
1846
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
176
worker/examples/llm_client_anthropic.rs
Normal file
176
worker/examples/llm_client_anthropic.rs
Normal 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(())
|
||||||
|
}
|
||||||
118
worker/examples/record_anthropic.rs
Normal file
118
worker/examples/record_anthropic.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
//!
|
//!
|
||||||
//! このクレートは以下を提供します:
|
//! このクレートは以下を提供します:
|
||||||
//! - Timeline: イベントストリームの状態管理とハンドラーへのディスパッチ
|
//! - Timeline: イベントストリームの状態管理とハンドラーへのディスパッチ
|
||||||
|
//! - LlmClient: LLMプロバイダとの通信
|
||||||
//! - 型消去されたHandler実装
|
//! - 型消去されたHandler実装
|
||||||
|
|
||||||
|
pub mod llm_client;
|
||||||
mod timeline;
|
mod timeline;
|
||||||
|
|
||||||
pub use timeline::*;
|
pub use timeline::*;
|
||||||
|
|
|
||||||
28
worker/src/llm_client/client.rs
Normal file
28
worker/src/llm_client/client.rs
Normal 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>;
|
||||||
|
}
|
||||||
69
worker/src/llm_client/error.rs
Normal file
69
worker/src/llm_client/error.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
worker/src/llm_client/mod.rs
Normal file
24
worker/src/llm_client/mod.rs
Normal 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::*;
|
||||||
193
worker/src/llm_client/providers/anthropic.rs
Normal file
193
worker/src/llm_client/providers/anthropic.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
worker/src/llm_client/providers/mod.rs
Normal file
5
worker/src/llm_client/providers/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
//! プロバイダ実装
|
||||||
|
//!
|
||||||
|
//! 各プロバイダ固有のHTTPクライアント実装
|
||||||
|
|
||||||
|
pub mod anthropic;
|
||||||
372
worker/src/llm_client/scheme/anthropic/events.rs
Normal file
372
worker/src/llm_client/scheme/anthropic/events.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
worker/src/llm_client/scheme/anthropic/mod.rs
Normal file
39
worker/src/llm_client/scheme/anthropic/mod.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
195
worker/src/llm_client/scheme/anthropic/request.rs
Normal file
195
worker/src/llm_client/scheme/anthropic/request.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
7
worker/src/llm_client/scheme/mod.rs
Normal file
7
worker/src/llm_client/scheme/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! APIスキーマ定義
|
||||||
|
//!
|
||||||
|
//! 各APIスキーマごとの変換ロジック
|
||||||
|
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||||
|
//! - レスポンス変換: SSEイベント → Event
|
||||||
|
|
||||||
|
pub mod anthropic;
|
||||||
238
worker/src/llm_client/testing.rs
Normal file
238
worker/src/llm_client/testing.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
198
worker/src/llm_client/types.rs
Normal file
198
worker/src/llm_client/types.rs
Normal 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>,
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
228
worker/tests/anthropic_fixtures.rs
Normal file
228
worker/tests/anthropic_fixtures.rs
Normal 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]);
|
||||||
|
}
|
||||||
7
worker/tests/fixtures/anthropic_1767624445.jsonl
vendored
Normal file
7
worker/tests/fixtures/anthropic_1767624445.jsonl
vendored
Normal 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\"}}"}
|
||||||
Loading…
Reference in New Issue
Block a user