alpha-release: 0.0.1 #1
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
|
||||
.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層が出力するフラットなイベント列挙
|
||||
///
|
||||
/// Timeline層がこのイベントストリームを受け取り、ブロック構造化を行う
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
// Meta events (not tied to a block)
|
||||
Ping(PingEvent),
|
||||
|
|
@ -31,7 +31,7 @@ pub enum Event {
|
|||
// =============================================================================
|
||||
|
||||
/// Pingイベント(ハートビート)
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PingEvent {
|
||||
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 status: ResponseStatus,
|
||||
}
|
||||
|
||||
/// レスポンスステータス
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseStatus {
|
||||
/// ストリーム開始
|
||||
Started,
|
||||
|
|
@ -71,7 +71,7 @@ pub enum ResponseStatus {
|
|||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorEvent {
|
||||
pub code: Option<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 {
|
||||
/// テキスト生成
|
||||
Text,
|
||||
|
|
@ -95,7 +95,7 @@ pub enum BlockType {
|
|||
}
|
||||
|
||||
/// ブロック開始イベント
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStart {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
|
|
@ -112,7 +112,7 @@ impl BlockStart {
|
|||
}
|
||||
|
||||
/// ブロックのメタデータ
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockMetadata {
|
||||
Text,
|
||||
Thinking,
|
||||
|
|
@ -121,7 +121,7 @@ pub enum BlockMetadata {
|
|||
}
|
||||
|
||||
/// ブロックデルタイベント
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockDelta {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
|
|
@ -130,7 +130,7 @@ pub struct BlockDelta {
|
|||
}
|
||||
|
||||
/// デルタの内容
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeltaContent {
|
||||
/// テキストデルタ
|
||||
Text(String),
|
||||
|
|
@ -152,7 +152,7 @@ impl DeltaContent {
|
|||
}
|
||||
|
||||
/// ブロック停止イベント
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStop {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
|
|
@ -169,7 +169,7 @@ impl BlockStop {
|
|||
}
|
||||
|
||||
/// ブロック中断イベント
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockAbort {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
|
|
@ -186,7 +186,7 @@ impl BlockAbort {
|
|||
}
|
||||
|
||||
/// 停止理由
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum StopReason {
|
||||
/// 自然終了
|
||||
EndTurn,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[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"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||
worker-macros = { path = "../worker-macros" }
|
||||
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の使用パターンを示すサンプル
|
||||
|
||||
use worker::{
|
||||
Event, Handler, TextBlockEvent, TextBlockKind, Timeline,
|
||||
ToolUseBlockEvent, ToolUseBlockKind, UsageEvent, UsageKind,
|
||||
Event, Handler, TextBlockEvent, TextBlockKind, Timeline, ToolUseBlockEvent, ToolUseBlockKind,
|
||||
UsageEvent, UsageKind,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
|
|
@ -81,7 +81,9 @@ struct TextCollector {
|
|||
|
||||
impl TextCollector {
|
||||
fn new() -> Self {
|
||||
Self { results: Vec::new() }
|
||||
Self {
|
||||
results: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
//!
|
||||
//! このクレートは以下を提供します:
|
||||
//! - Timeline: イベントストリームの状態管理とハンドラーへのディスパッチ
|
||||
//! - LlmClient: LLMプロバイダとの通信
|
||||
//! - 型消去されたHandler実装
|
||||
|
||||
pub mod llm_client;
|
||||
mod 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) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
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) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
BlockType::Text => &mut self.text_block_handlers,
|
||||
BlockType::Thinking => &mut self.thinking_block_handlers,
|
||||
|
|
@ -551,7 +556,9 @@ mod tests {
|
|||
}
|
||||
|
||||
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();
|
||||
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